···4343 log.Fatalf("Failed to initialize embedded PDS: %v", err)
4444 }
45454646- // Bootstrap PDS with captain record and hold owner as first crew member
4747- if err := holdPDS.Bootstrap(ctx, cfg.Registration.OwnerDID, cfg.Server.Public, cfg.Registration.AllowAllCrew); err != nil {
4646+ // Create storage driver from config (needed for bootstrap profile avatar)
4747+ driver, err := factory.Create(ctx, cfg.Storage.Type(), cfg.Storage.Parameters())
4848+ if err != nil {
4949+ log.Fatalf("failed to create storage driver: %v", err)
5050+ return
5151+ }
5252+5353+ // Bootstrap PDS with captain record, hold owner as first crew member, and profile
5454+ if err := holdPDS.Bootstrap(ctx, driver, cfg.Registration.OwnerDID, cfg.Server.Public, cfg.Registration.AllowAllCrew, cfg.Registration.ProfileAvatarURL); err != nil {
4855 log.Fatalf("Failed to bootstrap PDS: %v", err)
4956 }
5057
+1-127
docs/ANNOTATIONS_REFACTOR.md
···61616262## Migration Strategy
63636464-### Migration File: `0004_refactor_annotations_table.yaml`
6565-6666-```yaml
6767-description: Migrate manifest annotations to separate table
6868-query: |
6969- -- Step 1: Create new annotations table
7070- CREATE TABLE IF NOT EXISTS repository_annotations (
7171- did TEXT NOT NULL,
7272- repository TEXT NOT NULL,
7373- key TEXT NOT NULL,
7474- value TEXT NOT NULL,
7575- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
7676- PRIMARY KEY(did, repository, key),
7777- FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
7878- );
7979- CREATE INDEX IF NOT EXISTS idx_repository_annotations_did_repo ON repository_annotations(did, repository);
8080- CREATE INDEX IF NOT EXISTS idx_repository_annotations_key ON repository_annotations(key);
8181-8282- -- Step 2: Migrate existing data from manifests to annotations
8383- -- For each repository, use the most recent manifest with non-empty data
8484- INSERT OR REPLACE INTO repository_annotations (did, repository, key, value, updated_at)
8585- SELECT
8686- m.did,
8787- m.repository,
8888- 'org.opencontainers.image.title' as key,
8989- m.title as value,
9090- m.created_at as updated_at
9191- FROM manifests m
9292- WHERE m.title IS NOT NULL AND m.title != ''
9393- AND m.created_at = (
9494- SELECT MAX(created_at) FROM manifests m2
9595- WHERE m2.did = m.did AND m2.repository = m.repository
9696- AND m2.title IS NOT NULL AND m2.title != ''
9797- );
9898-9999- INSERT OR REPLACE INTO repository_annotations (did, repository, key, value, updated_at)
100100- SELECT m.did, m.repository, 'org.opencontainers.image.description', m.description, m.created_at
101101- FROM manifests m
102102- WHERE m.description IS NOT NULL AND m.description != ''
103103- AND m.created_at = (
104104- SELECT MAX(created_at) FROM manifests m2
105105- WHERE m2.did = m.did AND m2.repository = m.repository
106106- AND m2.description IS NOT NULL AND m2.description != ''
107107- );
108108-109109- INSERT OR REPLACE INTO repository_annotations (did, repository, key, value, updated_at)
110110- SELECT m.did, m.repository, 'org.opencontainers.image.source', m.source_url, m.created_at
111111- FROM manifests m
112112- WHERE m.source_url IS NOT NULL AND m.source_url != ''
113113- AND m.created_at = (
114114- SELECT MAX(created_at) FROM manifests m2
115115- WHERE m2.did = m.did AND m2.repository = m.repository
116116- AND m2.source_url IS NOT NULL AND m2.source_url != ''
117117- );
118118-119119- INSERT OR REPLACE INTO repository_annotations (did, repository, key, value, updated_at)
120120- SELECT m.did, m.repository, 'org.opencontainers.image.documentation', m.documentation_url, m.created_at
121121- FROM manifests m
122122- WHERE m.documentation_url IS NOT NULL AND m.documentation_url != ''
123123- AND m.created_at = (
124124- SELECT MAX(created_at) FROM manifests m2
125125- WHERE m2.did = m.did AND m2.repository = m.repository
126126- AND m2.documentation_url IS NOT NULL AND m2.documentation_url != ''
127127- );
128128-129129- INSERT OR REPLACE INTO repository_annotations (did, repository, key, value, updated_at)
130130- SELECT m.did, m.repository, 'org.opencontainers.image.licenses', m.licenses, m.created_at
131131- FROM manifests m
132132- WHERE m.licenses IS NOT NULL AND m.licenses != ''
133133- AND m.created_at = (
134134- SELECT MAX(created_at) FROM manifests m2
135135- WHERE m2.did = m.did AND m2.repository = m.repository
136136- AND m2.licenses IS NOT NULL AND m2.licenses != ''
137137- );
138138-139139- INSERT OR REPLACE INTO repository_annotations (did, repository, key, value, updated_at)
140140- SELECT m.did, m.repository, 'io.atcr.icon', m.icon_url, m.created_at
141141- FROM manifests m
142142- WHERE m.icon_url IS NOT NULL AND m.icon_url != ''
143143- AND m.created_at = (
144144- SELECT MAX(created_at) FROM manifests m2
145145- WHERE m2.did = m.did AND m2.repository = m.repository
146146- AND m2.icon_url IS NOT NULL AND m2.icon_url != ''
147147- );
148148-149149- INSERT OR REPLACE INTO repository_annotations (did, repository, key, value, updated_at)
150150- SELECT m.did, m.repository, 'io.atcr.readme', m.readme_url, m.created_at
151151- FROM manifests m
152152- WHERE m.readme_url IS NOT NULL AND m.readme_url != ''
153153- AND m.created_at = (
154154- SELECT MAX(created_at) FROM manifests m2
155155- WHERE m2.did = m.did AND m2.repository = m.repository
156156- AND m2.readme_url IS NOT NULL AND m2.readme_url != ''
157157- );
158158-159159- -- Step 3: Drop old columns from manifests table
160160- -- SQLite requires recreating table to drop columns
161161- CREATE TABLE manifests_new (
162162- id INTEGER PRIMARY KEY AUTOINCREMENT,
163163- did TEXT NOT NULL,
164164- repository TEXT NOT NULL,
165165- digest TEXT NOT NULL,
166166- hold_endpoint TEXT NOT NULL,
167167- schema_version INTEGER NOT NULL,
168168- media_type TEXT NOT NULL,
169169- config_digest TEXT,
170170- config_size INTEGER,
171171- created_at TIMESTAMP NOT NULL,
172172- UNIQUE(did, repository, digest),
173173- FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
174174- );
175175-176176- -- Copy data to new table
177177- INSERT INTO manifests_new
178178- SELECT id, did, repository, digest, hold_endpoint, schema_version, media_type,
179179- config_digest, config_size, created_at
180180- FROM manifests;
181181-182182- -- Replace old table
183183- DROP TABLE manifests;
184184- ALTER TABLE manifests_new RENAME TO manifests;
185185-186186- -- Recreate indexes
187187- CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository);
188188- CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC);
189189- CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest);
190190-```
6464+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.
1916519266## Code Changes
19367
+218
docs/HOLD_ENDPOINT_TESTS.md
···11+# Hold Service Endpoint Testing Guide
22+33+## Quick Reference
44+55+Your hold service: `http://172.28.0.3:8080`
66+77+Default DID format for local testing: `did:web:172.28.0.3%3A8080` (URL-encoded `did:web:172.28.0.3:8080`)
88+99+## Individual cURL Commands
1010+1111+### 1. List Repositories
1212+```bash
1313+curl -s "http://172.28.0.3:8080/xrpc/com.atproto.sync.listRepos" | jq .
1414+```
1515+1616+**Expected response:**
1717+```json
1818+{
1919+ "repos": [
2020+ {
2121+ "did": "did:web:172.28.0.3%3A8080",
2222+ "head": "...",
2323+ "rev": "..."
2424+ }
2525+ ]
2626+}
2727+```
2828+2929+### 2. Describe Repository
3030+```bash
3131+curl -s "http://172.28.0.3:8080/xrpc/com.atproto.repo.describeRepo?repo=did:web:172.28.0.3%3A8080" | jq .
3232+```
3333+3434+**Expected response:**
3535+```json
3636+{
3737+ "did": "did:web:172.28.0.3%3A8080",
3838+ "handle": "172.28.0.3:8080",
3939+ "didDoc": {...},
4040+ "collections": ["io.atcr.hold.captain", "io.atcr.hold.crew"]
4141+}
4242+```
4343+4444+### 3. Get Repository (CAR file)
4545+```bash
4646+# Download entire repo as CAR file
4747+curl -s "http://172.28.0.3:8080/xrpc/com.atproto.sync.getRepo?did=did:web:172.28.0.3%3A8080" -o repo.car
4848+4949+# Get repo diff since revision
5050+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
5151+```
5252+5353+**Expected response:** Binary CAR (Content Addressable aRchive) file
5454+5555+### 4. List Captain Records
5656+```bash
5757+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 .
5858+```
5959+6060+**Expected response:**
6161+```json
6262+{
6363+ "records": [
6464+ {
6565+ "uri": "at://did:web:172.28.0.3%3A8080/io.atcr.hold.captain/self",
6666+ "cid": "...",
6767+ "value": {
6868+ "$type": "io.atcr.hold.captain",
6969+ "allowAllCrew": true,
7070+ "public": false,
7171+ "createdAt": "2025-10-22T..."
7272+ }
7373+ }
7474+ ]
7575+}
7676+```
7777+7878+### 5. List Crew Records
7979+```bash
8080+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 .
8181+```
8282+8383+**Expected response:**
8484+```json
8585+{
8686+ "records": [
8787+ {
8888+ "uri": "at://did:web:172.28.0.3%3A8080/io.atcr.hold.crew/{rkey}",
8989+ "cid": "...",
9090+ "value": {
9191+ "$type": "io.atcr.hold.crew",
9292+ "did": "did:plc:...",
9393+ "permissions": ["blob:read", "blob:write"],
9494+ "createdAt": "2025-10-22T..."
9595+ }
9696+ }
9797+ ]
9898+}
9999+```
100100+101101+### 6. Get Specific Record
102102+```bash
103103+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 .
104104+```
105105+106106+### 7. Get Blob
107107+```bash
108108+# Replace with actual CID from your hold
109109+curl -s "http://172.28.0.3:8080/xrpc/com.atproto.sync.getBlob?did=did:web:172.28.0.3%3A8080&cid=bafyreiabc123..." | jq .
110110+```
111111+112112+**Expected response (for OCI blobs):**
113113+```json
114114+{
115115+ "url": "https://s3.amazonaws.com/bucket/path?presigned-params...",
116116+ "expiresAt": "2025-10-22T12:15:00Z"
117117+}
118118+```
119119+120120+### 8. Subscribe to Repository Events (WebSocket)
121121+122122+Using **websocat** (recommended):
123123+```bash
124124+# Install: cargo install websocat
125125+websocat "ws://172.28.0.3:8080/xrpc/com.atproto.sync.subscribeRepos"
126126+```
127127+128128+Using **wscat**:
129129+```bash
130130+# Install: npm install -g wscat
131131+wscat -c "ws://172.28.0.3:8080/xrpc/com.atproto.sync.subscribeRepos"
132132+```
133133+134134+Using **curl** (HTTP upgrade - may not work with all servers):
135135+```bash
136136+curl -i -N \
137137+ -H "Connection: Upgrade" \
138138+ -H "Upgrade: websocket" \
139139+ -H "Sec-WebSocket-Version: 13" \
140140+ -H "Sec-WebSocket-Key: $(echo -n "test" | base64)" \
141141+ "http://172.28.0.3:8080/xrpc/com.atproto.sync.subscribeRepos"
142142+```
143143+144144+**Expected response:** Stream of CBOR-encoded events (commits, identities, handles, etc.)
145145+146146+## DID Resolution
147147+148148+### Get DID Document
149149+```bash
150150+curl -s "http://172.28.0.3:8080/.well-known/did.json" | jq .
151151+```
152152+153153+**Expected response:**
154154+```json
155155+{
156156+ "@context": ["https://www.w3.org/ns/did/v1"],
157157+ "id": "did:web:172.28.0.3%3A8080",
158158+ "service": [
159159+ {
160160+ "id": "#atproto_pds",
161161+ "type": "AtprotoPersonalDataServer",
162162+ "serviceEndpoint": "http://172.28.0.3:8080"
163163+ }
164164+ ]
165165+}
166166+```
167167+168168+### Get DID from Handle
169169+```bash
170170+curl -s "http://172.28.0.3:8080/.well-known/atproto-did"
171171+```
172172+173173+**Expected response:** Plain text DID
174174+```
175175+did:web:172.28.0.3%3A8080
176176+```
177177+178178+## Running the Test Script
179179+180180+```bash
181181+# Default (uses 172.28.0.3:8080)
182182+./test-hold-endpoints.sh
183183+184184+# Custom hold URL
185185+./test-hold-endpoints.sh "http://localhost:8080"
186186+187187+# Custom hold URL and DID
188188+./test-hold-endpoints.sh "http://localhost:8080" "did:web:localhost%3A8080"
189189+```
190190+191191+## Troubleshooting
192192+193193+### "Connection refused"
194194+- Ensure hold service is running: `docker ps` or check process
195195+- Verify IP address: `docker inspect <container> | grep IPAddress`
196196+197197+### "Empty response" or "404 Not Found"
198198+- Check hold service logs for errors
199199+- Verify DID format (use URL-encoded version with `%3A` for `:`)
200200+- Ensure hold has been initialized (should have captain record)
201201+202202+### WebSocket connection fails
203203+- Install websocat: `cargo install websocat`
204204+- Or install wscat: `npm install -g wscat`
205205+- WebSocket endpoints only work with proper WS clients, not regular curl
206206+207207+### "No records found"
208208+- Captain record created on hold startup if `HOLD_OWNER` is set
209209+- Crew records created when users call `io.atcr.hold.requestCrew`
210210+- Blobs only exist after pushing container images
211211+212212+## Next Steps
213213+214214+After verifying these endpoints work:
215215+1. Test OCI upload endpoints (requires authentication)
216216+2. Push a real container image to create blob data
217217+3. Test blob retrieval with real CIDs
218218+4. Monitor WebSocket events during pushes
+78
pkg/appview/db/annotations.go
···11+package db
22+33+import (
44+ "database/sql"
55+ "time"
66+)
77+88+// GetRepositoryAnnotations retrieves all annotations for a repository
99+func GetRepositoryAnnotations(db *sql.DB, did, repository string) (map[string]string, error) {
1010+ rows, err := db.Query(`
1111+ SELECT key, value
1212+ FROM repository_annotations
1313+ WHERE did = ? AND repository = ?
1414+ `, did, repository)
1515+ if err != nil {
1616+ return nil, err
1717+ }
1818+ defer rows.Close()
1919+2020+ annotations := make(map[string]string)
2121+ for rows.Next() {
2222+ var key, value string
2323+ if err := rows.Scan(&key, &value); err != nil {
2424+ return nil, err
2525+ }
2626+ annotations[key] = value
2727+ }
2828+2929+ return annotations, rows.Err()
3030+}
3131+3232+// UpsertRepositoryAnnotations replaces all annotations for a repository
3333+// Only called when manifest has at least one non-empty annotation
3434+func UpsertRepositoryAnnotations(db *sql.DB, did, repository string, annotations map[string]string) error {
3535+ tx, err := db.Begin()
3636+ if err != nil {
3737+ return err
3838+ }
3939+ defer tx.Rollback()
4040+4141+ // Delete existing annotations
4242+ _, err = tx.Exec(`
4343+ DELETE FROM repository_annotations
4444+ WHERE did = ? AND repository = ?
4545+ `, did, repository)
4646+ if err != nil {
4747+ return err
4848+ }
4949+5050+ // Insert new annotations
5151+ stmt, err := tx.Prepare(`
5252+ INSERT INTO repository_annotations (did, repository, key, value, updated_at)
5353+ VALUES (?, ?, ?, ?, ?)
5454+ `)
5555+ if err != nil {
5656+ return err
5757+ }
5858+ defer stmt.Close()
5959+6060+ now := time.Now()
6161+ for key, value := range annotations {
6262+ _, err = stmt.Exec(did, repository, key, value, now)
6363+ if err != nil {
6464+ return err
6565+ }
6666+ }
6767+6868+ return tx.Commit()
6969+}
7070+7171+// DeleteRepositoryAnnotations removes all annotations for a repository
7272+func DeleteRepositoryAnnotations(db *sql.DB, did, repository string) error {
7373+ _, err := db.Exec(`
7474+ DELETE FROM repository_annotations
7575+ WHERE did = ? AND repository = ?
7676+ `, did, repository)
7777+ return err
7878+}
···11-description: Add readme_url column to manifests table (idempotent - handles both fresh and existing databases)
11+description: Add readme_url to manifests (obsolete - kept for migration history)
22query: |
33- -- Idempotent migration: adds readme_url column if it doesn't exist
44- -- Works for both fresh installs (where schema.sql created it) and existing databases
55-66- -- Create temp table with new schema
77- CREATE TABLE manifests_temp (
88- id INTEGER PRIMARY KEY AUTOINCREMENT,
99- did TEXT NOT NULL,
1010- repository TEXT NOT NULL,
1111- digest TEXT NOT NULL,
1212- hold_endpoint TEXT NOT NULL,
1313- schema_version INTEGER NOT NULL,
1414- media_type TEXT NOT NULL,
1515- config_digest TEXT,
1616- config_size INTEGER,
1717- created_at TIMESTAMP NOT NULL,
1818- title TEXT,
1919- description TEXT,
2020- source_url TEXT,
2121- documentation_url TEXT,
2222- licenses TEXT,
2323- icon_url TEXT,
2424- readme_url TEXT,
2525- UNIQUE(did, repository, digest),
2626- FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
2727- );
2828-2929- -- Copy data from existing manifests table
3030- -- Use INSERT OR IGNORE to handle case where table is already correct
3131- INSERT OR IGNORE INTO manifests_temp
3232- SELECT id, did, repository, digest, hold_endpoint, schema_version, media_type,
3333- config_digest, config_size, created_at, title, description, source_url,
3434- documentation_url, licenses, icon_url,
3535- NULL as readme_url -- Will be NULL for existing data
3636- FROM manifests;
3737-3838- -- Only proceed with table swap if we actually copied data
3939- -- (manifests_temp will be empty if manifests table already has readme_url)
4040- DROP TABLE IF EXISTS manifests;
4141- ALTER TABLE manifests_temp RENAME TO manifests;
4242-4343- -- Recreate indexes
4444- CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository);
4545- CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC);
4646- CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest);
33+ -- This migration is obsolete. The readme_url and other annotations
44+ -- are now stored in the repository_annotations table (see schema.sql).
55+ -- Backfill will populate annotation data from PDS records.
66+ -- This migration is kept as a no-op to maintain migration history.
77+ SELECT 1;
···11+description: Remove annotation columns from manifests table
22+query: |
33+ -- Drop annotation columns from manifests table (if they exist)
44+ -- Annotations are now stored in repository_annotations table
55+ -- SQLite doesn't support DROP COLUMN IF EXISTS, so we recreate the table
66+77+ -- Create new manifests table without annotation columns
88+ CREATE TABLE IF NOT EXISTS manifests_new (
99+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1010+ did TEXT NOT NULL,
1111+ repository TEXT NOT NULL,
1212+ digest TEXT NOT NULL,
1313+ hold_endpoint TEXT NOT NULL,
1414+ schema_version INTEGER NOT NULL,
1515+ media_type TEXT NOT NULL,
1616+ config_digest TEXT,
1717+ config_size INTEGER,
1818+ created_at TIMESTAMP NOT NULL,
1919+ UNIQUE(did, repository, digest),
2020+ FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
2121+ );
2222+2323+ -- Copy data (only core fields, annotation columns are dropped)
2424+ INSERT INTO manifests_new (id, did, repository, digest, hold_endpoint, schema_version, media_type, config_digest, config_size, created_at)
2525+ SELECT id, did, repository, digest, hold_endpoint, schema_version, media_type, config_digest, config_size, created_at
2626+ FROM manifests;
2727+2828+ -- Swap tables
2929+ DROP TABLE manifests;
3030+ ALTER TABLE manifests_new RENAME TO manifests;
3131+3232+ -- Recreate indexes
3333+ CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository);
3434+ CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC);
3535+ CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest);
+12-21
pkg/appview/db/models.go
···13131414// Manifest represents an OCI manifest stored in the cache
1515type Manifest struct {
1616- ID int64
1717- DID string
1818- Repository string
1919- Digest string
2020- HoldEndpoint string
2121- SchemaVersion int
2222- MediaType string
2323- ConfigDigest string
2424- ConfigSize int64
2525- CreatedAt time.Time
2626- Title string
2727- Description string
2828- SourceURL string
2929- DocumentationURL string
3030- Licenses string
3131- IconURL string
3232- ReadmeURL string
3333- PlatformOS string // UNUSED: Reserved for future use, always NULL
3434- PlatformArchitecture string // UNUSED: Reserved for future use, always NULL
3535- PlatformVariant string // UNUSED: Reserved for future use, always NULL
3636- PlatformOSVersion string // UNUSED: Reserved for future use, always NULL
1616+ ID int64
1717+ DID string
1818+ Repository string
1919+ Digest string
2020+ HoldEndpoint string
2121+ SchemaVersion int
2222+ MediaType string
2323+ ConfigDigest string
2424+ ConfigSize int64
2525+ CreatedAt time.Time
2626+ // Annotations removed - now stored in repository_annotations table
3727}
38283929// Layer represents a layer in a manifest
···10090 Licenses string
10191 IconURL string
10292 ReadmeURL string
9393+ Version string
10394}
1049510596// RepositoryStats represents statistics for a repository
+72-258
pkg/appview/db/queries.go
···3939 t.repository,
4040 t.tag,
4141 t.digest,
4242- COALESCE(m.title, ''),
4343- COALESCE(m.description, ''),
4444- COALESCE(m.icon_url, ''),
4242+ COALESCE((SELECT value FROM repository_annotations WHERE did = u.did AND repository = t.repository AND key = 'org.opencontainers.image.title'), ''),
4343+ COALESCE((SELECT value FROM repository_annotations WHERE did = u.did AND repository = t.repository AND key = 'org.opencontainers.image.description'), ''),
4444+ COALESCE((SELECT value FROM repository_annotations WHERE did = u.did AND repository = t.repository AND key = 'io.atcr.icon'), ''),
4545 COALESCE(rs.pull_count, 0),
4646 COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0),
4747 t.created_at,
···9494 return pushes, total, nil
9595}
96969797-// SearchPushes searches for pushes matching the query across handles, DIDs, repositories, and manifest annotations
9797+// SearchPushes searches for pushes matching the query across handles, DIDs, repositories, and annotations
9898func SearchPushes(db *sql.DB, query string, limit, offset int) ([]Push, int, error) {
9999 // Escape LIKE wildcards so they're treated literally
100100 query = escapeLikePattern(query)
···109109 t.repository,
110110 t.tag,
111111 t.digest,
112112- COALESCE(m.title, ''),
113113- COALESCE(m.description, ''),
114114- COALESCE(m.icon_url, ''),
112112+ COALESCE((SELECT value FROM repository_annotations WHERE did = u.did AND repository = t.repository AND key = 'org.opencontainers.image.title'), ''),
113113+ COALESCE((SELECT value FROM repository_annotations WHERE did = u.did AND repository = t.repository AND key = 'org.opencontainers.image.description'), ''),
114114+ COALESCE((SELECT value FROM repository_annotations WHERE did = u.did AND repository = t.repository AND key = 'io.atcr.icon'), ''),
115115 COALESCE(rs.pull_count, 0),
116116 COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0),
117117 t.created_at,
···123123 WHERE u.handle LIKE ? ESCAPE '\'
124124 OR u.did = ?
125125 OR t.repository LIKE ? ESCAPE '\'
126126- OR m.title LIKE ? ESCAPE '\'
127127- OR m.description LIKE ? ESCAPE '\'
126126+ OR EXISTS (
127127+ SELECT 1 FROM repository_annotations ra
128128+ WHERE ra.did = u.did AND ra.repository = t.repository
129129+ AND ra.value LIKE ? ESCAPE '\'
130130+ )
128131 ORDER BY t.created_at DESC
129132 LIMIT ? OFFSET ?
130133 `
131134132132- rows, err := db.Query(sqlQuery, searchPattern, query, searchPattern, searchPattern, searchPattern, limit, offset)
135135+ rows, err := db.Query(sqlQuery, searchPattern, query, searchPattern, searchPattern, limit, offset)
133136 if err != nil {
134137 return nil, 0, err
135138 }
···153156 WHERE u.handle LIKE ? ESCAPE '\'
154157 OR u.did = ?
155158 OR t.repository LIKE ? ESCAPE '\'
156156- OR m.title LIKE ? ESCAPE '\'
157157- OR m.description LIKE ? ESCAPE '\'
159159+ OR EXISTS (
160160+ SELECT 1 FROM repository_annotations ra
161161+ WHERE ra.did = u.did AND ra.repository = t.repository
162162+ AND ra.value LIKE ? ESCAPE '\'
163163+ )
158164 `
159165160166 var total int
161161- if err := db.QueryRow(countQuery, searchPattern, query, searchPattern, searchPattern, searchPattern).Scan(&total); err != nil {
167167+ if err := db.QueryRow(countQuery, searchPattern, query, searchPattern, searchPattern).Scan(&total); err != nil {
162168 return nil, 0, err
163169 }
164170···242248 // Get manifests for this repo
243249 manifestRows, err := db.Query(`
244250 SELECT id, digest, hold_endpoint, schema_version, media_type,
245245- config_digest, config_size, created_at,
246246- title, description, source_url, documentation_url, licenses, icon_url
251251+ config_digest, config_size, created_at
247252 FROM manifests
248253 WHERE did = ? AND repository = ?
249254 ORDER BY created_at DESC
···258263 m.DID = did
259264 m.Repository = r.Name
260265261261- // Use sql.NullString for nullable annotation fields
262262- var title, description, sourceURL, documentationURL, licenses, iconURL sql.NullString
263263-264266 if err := manifestRows.Scan(&m.ID, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion,
265265- &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.CreatedAt,
266266- &title, &description, &sourceURL, &documentationURL, &licenses, &iconURL); err != nil {
267267+ &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.CreatedAt); err != nil {
267268 manifestRows.Close()
268269 return nil, err
269270 }
270271271271- // Convert NullString to string
272272- if title.Valid {
273273- m.Title = title.String
274274- }
275275- if description.Valid {
276276- m.Description = description.String
277277- }
278278- if sourceURL.Valid {
279279- m.SourceURL = sourceURL.String
280280- }
281281- if documentationURL.Valid {
282282- m.DocumentationURL = documentationURL.String
283283- }
284284- if licenses.Valid {
285285- m.Licenses = licenses.String
286286- }
287287- if iconURL.Valid {
288288- m.IconURL = iconURL.String
289289- }
290290-291272 r.Manifests = append(r.Manifests, m)
292273 }
293274 manifestRows.Close()
294275295295- // Aggregate repository-level annotations from most recent manifest
296296- if len(r.Manifests) > 0 {
297297- latest := r.Manifests[0]
298298- r.Title = latest.Title
299299- r.Description = latest.Description
300300- r.SourceURL = latest.SourceURL
301301- r.DocumentationURL = latest.DocumentationURL
302302- r.Licenses = latest.Licenses
303303- r.IconURL = latest.IconURL
276276+ // Fetch repository-level annotations from annotations table
277277+ annotations, err := GetRepositoryAnnotations(db, did, r.Name)
278278+ if err != nil {
279279+ return nil, err
304280 }
305281282282+ r.Title = annotations["org.opencontainers.image.title"]
283283+ r.Description = annotations["org.opencontainers.image.description"]
284284+ r.SourceURL = annotations["org.opencontainers.image.source"]
285285+ r.DocumentationURL = annotations["org.opencontainers.image.documentation"]
286286+ r.Licenses = annotations["org.opencontainers.image.licenses"]
287287+ r.IconURL = annotations["io.atcr.icon"]
288288+ r.ReadmeURL = annotations["io.atcr.readme"]
289289+306290 repos = append(repos, r)
307291 }
308292309293 return repos, nil
310294}
311295312312-// GetRepositoryMetadata retrieves metadata for a repository from its most recent manifest
313313-// Prioritizes manifests with non-empty metadata fields
314314-func GetRepositoryMetadata(db *sql.DB, did string, repository string) (title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL string, err error) {
315315- var titleNull, descriptionNull, sourceURLNull, documentationURLNull, licensesNull, iconURLNull, readmeURLNull sql.NullString
316316-317317- // Try to find a manifest with metadata first (prefer manifests with any non-empty annotation field)
318318- err = db.QueryRow(`
319319- SELECT title, description, source_url, documentation_url, licenses, icon_url, readme_url
320320- FROM manifests
321321- WHERE did = ? AND repository = ?
322322- AND (
323323- (title IS NOT NULL AND title != '')
324324- OR (description IS NOT NULL AND description != '')
325325- OR (source_url IS NOT NULL AND source_url != '')
326326- OR (documentation_url IS NOT NULL AND documentation_url != '')
327327- OR (licenses IS NOT NULL AND licenses != '')
328328- OR (icon_url IS NOT NULL AND icon_url != '')
329329- OR (readme_url IS NOT NULL AND readme_url != '')
330330- )
331331- ORDER BY created_at DESC
332332- LIMIT 1
333333- `, did, repository).Scan(&titleNull, &descriptionNull, &sourceURLNull, &documentationURLNull, &licensesNull, &iconURLNull, &readmeURLNull)
334334-335335- // If no manifest with metadata found, fall back to latest manifest (any type)
336336- if err == sql.ErrNoRows {
337337- err = db.QueryRow(`
338338- SELECT title, description, source_url, documentation_url, licenses, icon_url, readme_url
339339- FROM manifests
340340- WHERE did = ? AND repository = ?
341341- ORDER BY created_at DESC
342342- LIMIT 1
343343- `, did, repository).Scan(&titleNull, &descriptionNull, &sourceURLNull, &documentationURLNull, &licensesNull, &iconURLNull, &readmeURLNull)
344344- }
345345-346346- if err == sql.ErrNoRows {
347347- // No manifests found - return empty strings
348348- return "", "", "", "", "", "", "", nil
349349- }
350350- if err != nil {
351351- return "", "", "", "", "", "", "", err
352352- }
353353-354354- // Convert NullString to string
355355- if titleNull.Valid {
356356- title = titleNull.String
357357- }
358358- if descriptionNull.Valid {
359359- description = descriptionNull.String
360360- }
361361- if sourceURLNull.Valid {
362362- sourceURL = sourceURLNull.String
363363- }
364364- if documentationURLNull.Valid {
365365- documentationURL = documentationURLNull.String
366366- }
367367- if licensesNull.Valid {
368368- licenses = licensesNull.String
369369- }
370370- if iconURLNull.Valid {
371371- iconURL = iconURLNull.String
372372- }
373373- if readmeURLNull.Valid {
374374- readmeURL = readmeURLNull.String
375375- }
376376-377377- return title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, nil
296296+// GetRepositoryMetadata retrieves metadata for a repository from annotations table
297297+// Returns a map of annotation key -> value for easy access in templates and handlers
298298+func GetRepositoryMetadata(db *sql.DB, did string, repository string) (map[string]string, error) {
299299+ return GetRepositoryAnnotations(db, did, repository)
378300}
379301380302// GetUserByDID retrieves a user by DID
···555477}
556478557479// InsertManifest inserts or updates a manifest record
558558-// Uses UPSERT to update labels/annotations if manifest already exists
480480+// Uses UPSERT to update core metadata if manifest already exists
559481// Returns the manifest ID (works correctly for both insert and update)
482482+// Note: Annotations are stored separately in repository_annotations table
560483func InsertManifest(db *sql.DB, manifest *Manifest) (int64, error) {
561484 _, err := db.Exec(`
562485 INSERT INTO manifests
563486 (did, repository, digest, hold_endpoint, schema_version, media_type,
564564- config_digest, config_size, created_at,
565565- title, description, source_url, documentation_url, licenses, icon_url, readme_url)
566566- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
487487+ config_digest, config_size, created_at)
488488+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
567489 ON CONFLICT(did, repository, digest) DO UPDATE SET
568490 hold_endpoint = excluded.hold_endpoint,
569491 schema_version = excluded.schema_version,
570492 media_type = excluded.media_type,
571493 config_digest = excluded.config_digest,
572572- config_size = excluded.config_size,
573573- title = excluded.title,
574574- description = excluded.description,
575575- source_url = excluded.source_url,
576576- documentation_url = excluded.documentation_url,
577577- licenses = excluded.licenses,
578578- icon_url = excluded.icon_url,
579579- readme_url = excluded.readme_url
494494+ config_size = excluded.config_size
580495 `, manifest.DID, manifest.Repository, manifest.Digest, manifest.HoldEndpoint,
581496 manifest.SchemaVersion, manifest.MediaType, manifest.ConfigDigest,
582582- manifest.ConfigSize, manifest.CreatedAt,
583583- manifest.Title, manifest.Description, manifest.SourceURL,
584584- manifest.DocumentationURL, manifest.Licenses, manifest.IconURL, manifest.ReadmeURL)
497497+ manifest.ConfigSize, manifest.CreatedAt)
585498586499 if err != nil {
587500 return 0, err
···719632}
720633721634// GetManifest fetches a single manifest by digest
635635+// Note: Annotations are stored separately in repository_annotations table
722636func GetManifest(db *sql.DB, digest string) (*Manifest, error) {
723637 var m Manifest
724724-725725- // Use sql.NullString for nullable annotation fields
726726- var title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL sql.NullString
727638728639 err := db.QueryRow(`
729640 SELECT id, did, repository, digest, hold_endpoint, schema_version,
730730- media_type, config_digest, config_size, created_at,
731731- title, description, source_url, documentation_url, licenses, icon_url, readme_url
641641+ media_type, config_digest, config_size, created_at
732642 FROM manifests
733643 WHERE digest = ?
734644 `, digest).Scan(&m.ID, &m.DID, &m.Repository, &m.Digest, &m.HoldEndpoint,
735645 &m.SchemaVersion, &m.MediaType, &m.ConfigDigest, &m.ConfigSize,
736736- &m.CreatedAt,
737737- &title, &description, &sourceURL, &documentationURL, &licenses, &iconURL, &readmeURL)
646646+ &m.CreatedAt)
738647739648 if err != nil {
740649 return nil, err
741650 }
742651743743- // Convert NullString to string
744744- if title.Valid {
745745- m.Title = title.String
746746- }
747747- if description.Valid {
748748- m.Description = description.String
749749- }
750750- if sourceURL.Valid {
751751- m.SourceURL = sourceURL.String
752752- }
753753- if documentationURL.Valid {
754754- m.DocumentationURL = documentationURL.String
755755- }
756756- if licenses.Valid {
757757- m.Licenses = licenses.String
758758- }
759759- if iconURL.Valid {
760760- m.IconURL = iconURL.String
761761- }
762762- if readmeURL.Valid {
763763- m.ReadmeURL = readmeURL.String
764764- }
765765-766652 return &m, nil
767653}
768654···855741856742// GetTopLevelManifests returns only manifest lists and orphaned single-arch manifests
857743// Filters out platform-specific manifests that are referenced by manifest lists
744744+// Note: Annotations are stored separately in repository_annotations table - use GetRepositoryMetadata to fetch them
858745func GetTopLevelManifests(db *sql.DB, did, repository string, limit, offset int) ([]ManifestWithMetadata, error) {
859746 rows, err := db.Query(`
860747 WITH manifest_list_children AS (
···866753 )
867754 SELECT
868755 m.id, m.did, m.repository, m.digest, m.media_type,
869869- m.schema_version, m.created_at, m.title, m.description,
870870- m.source_url, m.documentation_url, m.licenses, m.icon_url,
756756+ m.schema_version, m.created_at,
871757 m.config_digest, m.config_size, m.hold_endpoint,
872758 GROUP_CONCAT(DISTINCT t.tag) as tags,
873759 COUNT(DISTINCT mr.digest) as platform_count
···895781 var manifests []ManifestWithMetadata
896782 for rows.Next() {
897783 var m ManifestWithMetadata
898898- var tags, title, description, sourceURL, documentationURL, licenses, iconURL, configDigest sql.NullString
784784+ var tags, configDigest sql.NullString
899785 var configSize sql.NullInt64
900786901787 if err := rows.Scan(
902788 &m.ID, &m.DID, &m.Repository, &m.Digest, &m.MediaType,
903903- &m.SchemaVersion, &m.CreatedAt, &title, &description,
904904- &sourceURL, &documentationURL, &licenses, &iconURL,
789789+ &m.SchemaVersion, &m.CreatedAt,
905790 &configDigest, &configSize, &m.HoldEndpoint,
906791 &tags, &m.PlatformCount,
907792 ); err != nil {
···909794 }
910795911796 // Set nullable fields
912912- if title.Valid {
913913- m.Title = title.String
914914- }
915915- if description.Valid {
916916- m.Description = description.String
917917- }
918918- if sourceURL.Valid {
919919- m.SourceURL = sourceURL.String
920920- }
921921- if documentationURL.Valid {
922922- m.DocumentationURL = documentationURL.String
923923- }
924924- if licenses.Valid {
925925- m.Licenses = licenses.String
926926- }
927927- if iconURL.Valid {
928928- m.IconURL = iconURL.String
929929- }
930797 if configDigest.Valid {
931798 m.ConfigDigest = configDigest.String
932799 }
···998865}
9998661000867// GetManifestDetail returns a manifest with full platform details and tags
868868+// Note: Annotations are stored separately in repository_annotations table - use GetRepositoryMetadata to fetch them
1001869func GetManifestDetail(db *sql.DB, did, repository, digest string) (*ManifestWithMetadata, error) {
1002870 // First, get the manifest and its tags
1003871 var m ManifestWithMetadata
10041004- var tags, title, description, sourceURL, documentationURL, licenses, iconURL, configDigest sql.NullString
872872+ var tags, configDigest sql.NullString
1005873 var configSize sql.NullInt64
10068741007875 err := db.QueryRow(`
1008876 SELECT
1009877 m.id, m.did, m.repository, m.digest, m.media_type,
10101010- m.schema_version, m.created_at, m.title, m.description,
10111011- m.source_url, m.documentation_url, m.licenses, m.icon_url,
878878+ m.schema_version, m.created_at,
1012879 m.config_digest, m.config_size, m.hold_endpoint,
1013880 GROUP_CONCAT(DISTINCT t.tag) as tags
1014881 FROM manifests m
···1017884 GROUP BY m.id
1018885 `, did, repository, digest).Scan(
1019886 &m.ID, &m.DID, &m.Repository, &m.Digest, &m.MediaType,
10201020- &m.SchemaVersion, &m.CreatedAt, &title, &description,
10211021- &sourceURL, &documentationURL, &licenses, &iconURL,
887887+ &m.SchemaVersion, &m.CreatedAt,
1022888 &configDigest, &configSize, &m.HoldEndpoint,
1023889 &tags,
1024890 )
···1031897 }
10328981033899 // Set nullable fields
10341034- if title.Valid {
10351035- m.Title = title.String
10361036- }
10371037- if description.Valid {
10381038- m.Description = description.String
10391039- }
10401040- if sourceURL.Valid {
10411041- m.SourceURL = sourceURL.String
10421042- }
10431043- if documentationURL.Valid {
10441044- m.DocumentationURL = documentationURL.String
10451045- }
10461046- if licenses.Valid {
10471047- m.Licenses = licenses.String
10481048- }
10491049- if iconURL.Valid {
10501050- m.IconURL = iconURL.String
10511051- }
1052900 if configDigest.Valid {
1053901 m.ConfigDigest = configDigest.String
1054902 }
···13031151 // Get manifests for this repo
13041152 manifestRows, err := db.Query(`
13051153 SELECT id, digest, hold_endpoint, schema_version, media_type,
13061306- config_digest, config_size, created_at,
13071307- title, description, source_url, documentation_url, licenses, icon_url
11541154+ config_digest, config_size, created_at
13081155 FROM manifests
13091156 WHERE did = ? AND repository = ?
13101157 ORDER BY created_at DESC
···13191166 m.DID = did
13201167 m.Repository = repository
1321116813221322- // Use sql.NullString for nullable annotation fields
13231323- var title, description, sourceURL, documentationURL, licenses, iconURL sql.NullString
13241324-13251169 if err := manifestRows.Scan(&m.ID, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion,
13261326- &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.CreatedAt,
13271327- &title, &description, &sourceURL, &documentationURL, &licenses, &iconURL); err != nil {
11701170+ &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.CreatedAt); err != nil {
13281171 manifestRows.Close()
13291172 return nil, err
13301330- }
13311331-13321332- // Convert NullString to string
13331333- if title.Valid {
13341334- m.Title = title.String
13351335- }
13361336- if description.Valid {
13371337- m.Description = description.String
13381338- }
13391339- if sourceURL.Valid {
13401340- m.SourceURL = sourceURL.String
13411341- }
13421342- if documentationURL.Valid {
13431343- m.DocumentationURL = documentationURL.String
13441344- }
13451345- if licenses.Valid {
13461346- m.Licenses = licenses.String
13471347- }
13481348- if iconURL.Valid {
13491349- m.IconURL = iconURL.String
13501173 }
1351117413521175 r.Manifests = append(r.Manifests, m)
13531176 }
13541177 manifestRows.Close()
1355117813561356- // Aggregate repository-level annotations from most recent manifest
13571357- if len(r.Manifests) > 0 {
13581358- latest := r.Manifests[0]
13591359- r.Title = latest.Title
13601360- r.Description = latest.Description
13611361- r.SourceURL = latest.SourceURL
13621362- r.DocumentationURL = latest.DocumentationURL
13631363- r.Licenses = latest.Licenses
13641364- r.IconURL = latest.IconURL
11791179+ // Fetch repository-level annotations from annotations table
11801180+ annotations, err := GetRepositoryAnnotations(db, did, repository)
11811181+ if err != nil {
11821182+ return nil, err
13651183 }
11841184+11851185+ r.Title = annotations["org.opencontainers.image.title"]
11861186+ r.Description = annotations["org.opencontainers.image.description"]
11871187+ r.SourceURL = annotations["org.opencontainers.image.source"]
11881188+ r.DocumentationURL = annotations["org.opencontainers.image.documentation"]
11891189+ r.Licenses = annotations["org.opencontainers.image.licenses"]
11901190+ r.IconURL = annotations["io.atcr.icon"]
11911191+ r.ReadmeURL = annotations["io.atcr.readme"]
1366119213671193 return &r, nil
13681194}
···16481474 m.did,
16491475 u.handle,
16501476 m.repository,
16511651- m.title,
16521652- m.description,
16531653- m.icon_url,
14771477+ COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.title'), ''),
14781478+ COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.description'), ''),
14791479+ COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'io.atcr.icon'), ''),
16541480 rs.pull_count,
16551481 rs.star_count
16561482 FROM latest_manifests lm
···16701496 var featured []FeaturedRepository
16711497 for rows.Next() {
16721498 var f FeaturedRepository
16731673- var title, description, iconURL sql.NullString
1674149916751500 if err := rows.Scan(&f.OwnerDID, &f.OwnerHandle, &f.Repository,
16761676- &title, &description, &iconURL, &f.PullCount, &f.StarCount); err != nil {
15011501+ &f.Title, &f.Description, &f.IconURL, &f.PullCount, &f.StarCount); err != nil {
16771502 return nil, err
16781678- }
16791679-16801680- // Convert NullString to string
16811681- if title.Valid {
16821682- f.Title = title.String
16831683- }
16841684- if description.Valid {
16851685- f.Description = description.String
16861686- }
16871687- if iconURL.Valid {
16881688- f.IconURL = iconURL.String
16891503 }
1690150416911505 featured = append(featured, f)
+156-90
pkg/appview/db/queries_test.go
···2525 t.Fatalf("Failed to insert user: %v", err)
2626 }
27272828- // Test 1: No manifests - should return empty strings
2929- title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, err := GetRepositoryMetadata(db, testUser.DID, "nonexistent")
2828+ // Test 1: No manifests - should return empty map
2929+ metadata, err := GetRepositoryMetadata(db, testUser.DID, "nonexistent")
3030 if err != nil {
3131 t.Fatalf("Expected no error for nonexistent repo, got: %v", err)
3232 }
3333- if title != "" || description != "" || sourceURL != "" || documentationURL != "" || licenses != "" || iconURL != "" || readmeURL != "" {
3434- t.Error("Expected all empty strings for nonexistent repository")
3333+ if len(metadata) != 0 {
3434+ t.Errorf("Expected empty map for nonexistent repository, got %d entries", len(metadata))
3535 }
36363737- // Test 2: Insert manifest with metadata
3737+ // Test 2: Insert manifest and annotations
3838 _, err = db.Exec(`
3939- INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at,
4040- title, description, source_url, documentation_url, licenses, icon_url)
4141- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3939+ INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at)
4040+ VALUES (?, ?, ?, ?, ?, ?, ?)
4241 `, testUser.DID, "myapp", "sha256:abc123", "did:web:hold.example.com", 2, "application/vnd.oci.image.manifest.v1+json",
4343- time.Now().Add(-2*time.Hour),
4444- "My App", "A cool application", "https://github.com/user/myapp", "https://docs.example.com", "MIT", "https://example.com/icon.png")
4242+ time.Now().Add(-2*time.Hour))
4543 if err != nil {
4644 t.Fatalf("Failed to insert manifest: %v", err)
4545+ }
4646+4747+ // Insert annotations separately
4848+ err = UpsertRepositoryAnnotations(db, testUser.DID, "myapp", map[string]string{
4949+ "org.opencontainers.image.title": "My App",
5050+ "org.opencontainers.image.description": "A cool application",
5151+ "org.opencontainers.image.source": "https://github.com/user/myapp",
5252+ "org.opencontainers.image.documentation": "https://docs.example.com",
5353+ "org.opencontainers.image.licenses": "MIT",
5454+ "io.atcr.icon": "https://example.com/icon.png",
5555+ })
5656+ if err != nil {
5757+ t.Fatalf("Failed to insert annotations: %v", err)
4758 }
48594960 // Test 3: Retrieve metadata
5050- title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, err = GetRepositoryMetadata(db, testUser.DID, "myapp")
6161+ metadata, err = GetRepositoryMetadata(db, testUser.DID, "myapp")
5162 if err != nil {
5263 t.Fatalf("Failed to get repository metadata: %v", err)
5364 }
54655555- if title != "My App" {
5656- t.Errorf("Expected title 'My App', got '%s'", title)
6666+ if metadata["org.opencontainers.image.title"] != "My App" {
6767+ t.Errorf("Expected title 'My App', got '%s'", metadata["org.opencontainers.image.title"])
5768 }
5858- if description != "A cool application" {
5959- t.Errorf("Expected description 'A cool application', got '%s'", description)
6969+ if metadata["org.opencontainers.image.description"] != "A cool application" {
7070+ t.Errorf("Expected description 'A cool application', got '%s'", metadata["org.opencontainers.image.description"])
6071 }
6161- if sourceURL != "https://github.com/user/myapp" {
6262- t.Errorf("Expected sourceURL 'https://github.com/user/myapp', got '%s'", sourceURL)
7272+ if metadata["org.opencontainers.image.source"] != "https://github.com/user/myapp" {
7373+ t.Errorf("Expected sourceURL 'https://github.com/user/myapp', got '%s'", metadata["org.opencontainers.image.source"])
6374 }
6464- if documentationURL != "https://docs.example.com" {
6565- t.Errorf("Expected documentationURL 'https://docs.example.com', got '%s'", documentationURL)
7575+ if metadata["org.opencontainers.image.documentation"] != "https://docs.example.com" {
7676+ t.Errorf("Expected documentationURL 'https://docs.example.com', got '%s'", metadata["org.opencontainers.image.documentation"])
6677 }
6767- if licenses != "MIT" {
6868- t.Errorf("Expected licenses 'MIT', got '%s'", licenses)
7878+ if metadata["org.opencontainers.image.licenses"] != "MIT" {
7979+ t.Errorf("Expected licenses 'MIT', got '%s'", metadata["org.opencontainers.image.licenses"])
6980 }
7070- if iconURL != "https://example.com/icon.png" {
7171- t.Errorf("Expected iconURL 'https://example.com/icon.png', got '%s'", iconURL)
8181+ if metadata["io.atcr.icon"] != "https://example.com/icon.png" {
8282+ t.Errorf("Expected iconURL 'https://example.com/icon.png', got '%s'", metadata["io.atcr.icon"])
7283 }
73847474- // Test 4: Insert newer manifest with different metadata
8585+ // Test 4: Insert newer manifest with different annotations
7586 _, err = db.Exec(`
7676- INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at,
7777- title, description, source_url, documentation_url, licenses, icon_url)
7878- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
8787+ INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at)
8888+ VALUES (?, ?, ?, ?, ?, ?, ?)
7989 `, testUser.DID, "myapp", "sha256:def456", "did:web:hold.example.com", 2, "application/vnd.oci.image.manifest.v1+json",
8080- time.Now(), // Most recent
8181- "My App v2", "An even cooler application", "https://github.com/user/myapp-v2", "https://v2.docs.example.com", "Apache-2.0", "https://example.com/icon-v2.png")
9090+ time.Now()) // Most recent
8291 if err != nil {
8392 t.Fatalf("Failed to insert newer manifest: %v", err)
8493 }
85949595+ // Update annotations with new values (simulates latest manifest having different annotations)
9696+ err = UpsertRepositoryAnnotations(db, testUser.DID, "myapp", map[string]string{
9797+ "org.opencontainers.image.title": "My App v2",
9898+ "org.opencontainers.image.description": "An even cooler application",
9999+ "org.opencontainers.image.source": "https://github.com/user/myapp-v2",
100100+ "org.opencontainers.image.documentation": "https://v2.docs.example.com",
101101+ "org.opencontainers.image.licenses": "Apache-2.0",
102102+ "io.atcr.icon": "https://example.com/icon-v2.png",
103103+ })
104104+ if err != nil {
105105+ t.Fatalf("Failed to update annotations: %v", err)
106106+ }
107107+86108 // Test 5: Should return metadata from most recent manifest
8787- title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, err = GetRepositoryMetadata(db, testUser.DID, "myapp")
109109+ metadata, err = GetRepositoryMetadata(db, testUser.DID, "myapp")
88110 if err != nil {
89111 t.Fatalf("Failed to get repository metadata: %v", err)
90112 }
911139292- if title != "My App v2" {
9393- t.Errorf("Expected title from newest manifest 'My App v2', got '%s'", title)
114114+ if metadata["org.opencontainers.image.title"] != "My App v2" {
115115+ t.Errorf("Expected title from newest manifest 'My App v2', got '%s'", metadata["org.opencontainers.image.title"])
94116 }
9595- if description != "An even cooler application" {
9696- t.Errorf("Expected description from newest manifest, got '%s'", description)
117117+ if metadata["org.opencontainers.image.description"] != "An even cooler application" {
118118+ t.Errorf("Expected description from newest manifest, got '%s'", metadata["org.opencontainers.image.description"])
97119 }
9898- if licenses != "Apache-2.0" {
9999- t.Errorf("Expected licenses 'Apache-2.0', got '%s'", licenses)
120120+ if metadata["org.opencontainers.image.licenses"] != "Apache-2.0" {
121121+ t.Errorf("Expected licenses 'Apache-2.0', got '%s'", metadata["org.opencontainers.image.licenses"])
100122 }
101123102124 // Test 6: Manifest with NULL metadata fields
···108130 t.Fatalf("Failed to insert minimal manifest: %v", err)
109131 }
110132111111- // Test 7: Should handle NULL fields gracefully
112112- title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, err = GetRepositoryMetadata(db, testUser.DID, "minimal-app")
133133+ // Test 7: Should handle missing annotations gracefully
134134+ metadata, err = GetRepositoryMetadata(db, testUser.DID, "minimal-app")
113135 if err != nil {
114136 t.Fatalf("Failed to get repository metadata for minimal app: %v", err)
115137 }
116138117117- if title != "" || description != "" || sourceURL != "" || documentationURL != "" || licenses != "" || iconURL != "" || readmeURL != "" {
118118- t.Error("Expected all empty strings for manifest with NULL metadata fields")
139139+ if len(metadata) != 0 {
140140+ t.Error("Expected empty map for manifest with no annotations")
119141 }
120142}
121143···139161 t.Fatalf("Failed to insert user: %v", err)
140162 }
141163142142- // Test 1: Insert new manifest with all fields populated
164164+ // Test 1: Insert new manifest with core fields
143165 manifest1 := &Manifest{
144144- DID: testUser.DID,
145145- Repository: "myapp",
146146- Digest: "sha256:abc123",
147147- HoldEndpoint: "did:web:hold.example.com",
148148- SchemaVersion: 2,
149149- MediaType: "application/vnd.oci.image.manifest.v1+json",
150150- ConfigDigest: "sha256:config123",
151151- ConfigSize: 1024,
152152- CreatedAt: time.Now(),
153153- Title: "My App",
154154- Description: "A cool application",
155155- SourceURL: "https://github.com/user/myapp",
156156- DocumentationURL: "https://docs.example.com",
157157- Licenses: "MIT",
158158- IconURL: "https://example.com/icon.png",
159159- ReadmeURL: "https://github.com/user/myapp/blob/main/README.md",
166166+ DID: testUser.DID,
167167+ Repository: "myapp",
168168+ Digest: "sha256:abc123",
169169+ HoldEndpoint: "did:web:hold.example.com",
170170+ SchemaVersion: 2,
171171+ MediaType: "application/vnd.oci.image.manifest.v1+json",
172172+ ConfigDigest: "sha256:config123",
173173+ ConfigSize: 1024,
174174+ CreatedAt: time.Now(),
160175 }
161176162177 id1, err := InsertManifest(db, manifest1)
···167182 t.Error("Expected non-zero manifest ID")
168183 }
169184185185+ // Insert annotations separately
186186+ annotations := map[string]string{
187187+ "org.opencontainers.image.title": "My App",
188188+ "org.opencontainers.image.description": "A cool application",
189189+ "org.opencontainers.image.source": "https://github.com/user/myapp",
190190+ "org.opencontainers.image.documentation": "https://docs.example.com",
191191+ "org.opencontainers.image.licenses": "MIT",
192192+ "io.atcr.icon": "https://example.com/icon.png",
193193+ "io.atcr.readme": "https://github.com/user/myapp/blob/main/README.md",
194194+ }
195195+ err = UpsertRepositoryAnnotations(db, testUser.DID, "myapp", annotations)
196196+ if err != nil {
197197+ t.Fatalf("Failed to insert annotations: %v", err)
198198+ }
199199+170200 // Verify the manifest was inserted correctly
171201 retrieved, err := GetManifest(db, manifest1.Digest)
172202 if err != nil {
···175205 if retrieved.ID != id1 {
176206 t.Errorf("Expected ID %d, got %d", id1, retrieved.ID)
177207 }
178178- if retrieved.Title != "My App" {
179179- t.Errorf("Expected title 'My App', got '%s'", retrieved.Title)
208208+209209+ // Verify annotations were inserted
210210+ retrievedAnnotations, err := GetRepositoryAnnotations(db, testUser.DID, "myapp")
211211+ if err != nil {
212212+ t.Fatalf("Failed to retrieve annotations: %v", err)
180213 }
181181- if retrieved.ReadmeURL != "https://github.com/user/myapp/blob/main/README.md" {
182182- t.Errorf("Expected readme_url, got '%s'", retrieved.ReadmeURL)
214214+ if retrievedAnnotations["org.opencontainers.image.title"] != "My App" {
215215+ t.Errorf("Expected title 'My App', got '%s'", retrievedAnnotations["org.opencontainers.image.title"])
216216+ }
217217+ if retrievedAnnotations["io.atcr.readme"] != "https://github.com/user/myapp/blob/main/README.md" {
218218+ t.Errorf("Expected readme_url, got '%s'", retrievedAnnotations["io.atcr.readme"])
183219 }
184220185221 // Test 2: Insert manifest with minimal fields (NULLs for annotations)
···201237 t.Error("Expected non-zero manifest ID for minimal manifest")
202238 }
203239204204- retrieved2, err := GetManifest(db, manifest2.Digest)
240240+ _, err = GetManifest(db, manifest2.Digest)
205241 if err != nil {
206242 t.Fatalf("Failed to retrieve minimal manifest: %v", err)
207243 }
208208- if retrieved2.Title != "" {
209209- t.Errorf("Expected empty title for minimal manifest, got '%s'", retrieved2.Title)
244244+ // Verify no annotations exist for minimal manifest
245245+ minimalAnnotations, err := GetRepositoryAnnotations(db, testUser.DID, "minimal")
246246+ if err != nil {
247247+ t.Fatalf("Failed to get minimal annotations: %v", err)
248248+ }
249249+ if len(minimalAnnotations) != 0 {
250250+ t.Errorf("Expected no annotations for minimal manifest, got %d", len(minimalAnnotations))
210251 }
211252212253 // Test 3: Upsert existing manifest (same DID+repo+digest) - verify UPDATE path
213254 manifest1Updated := &Manifest{
214214- DID: testUser.DID,
215215- Repository: "myapp",
216216- Digest: "sha256:abc123", // Same digest - should trigger UPDATE
217217- HoldEndpoint: "did:web:hold2.example.com",
218218- SchemaVersion: 2,
219219- MediaType: "application/vnd.oci.image.manifest.v1+json",
220220- ConfigDigest: "sha256:newconfig",
221221- ConfigSize: 2048,
222222- CreatedAt: time.Now(),
223223- Title: "My App v2",
224224- Description: "An updated application",
225225- SourceURL: "https://github.com/user/myapp-v2",
226226- DocumentationURL: "https://v2.docs.example.com",
227227- Licenses: "Apache-2.0",
228228- IconURL: "https://example.com/icon-v2.png",
229229- ReadmeURL: "https://github.com/user/myapp/blob/v2/README.md",
255255+ DID: testUser.DID,
256256+ Repository: "myapp",
257257+ Digest: "sha256:abc123", // Same digest - should trigger UPDATE
258258+ HoldEndpoint: "did:web:hold2.example.com",
259259+ SchemaVersion: 2,
260260+ MediaType: "application/vnd.oci.image.manifest.v1+json",
261261+ ConfigDigest: "sha256:newconfig",
262262+ ConfigSize: 2048,
263263+ CreatedAt: time.Now(),
230264 }
231265232266 id3, err := InsertManifest(db, manifest1Updated)
···238272 t.Errorf("Expected upsert to return same ID %d, got %d", id1, id3)
239273 }
240274275275+ // Update annotations separately
276276+ updatedAnnotations := map[string]string{
277277+ "org.opencontainers.image.title": "My App v2",
278278+ "org.opencontainers.image.description": "An updated application",
279279+ "org.opencontainers.image.source": "https://github.com/user/myapp-v2",
280280+ "org.opencontainers.image.documentation": "https://v2.docs.example.com",
281281+ "org.opencontainers.image.licenses": "Apache-2.0",
282282+ "io.atcr.icon": "https://example.com/icon-v2.png",
283283+ "io.atcr.readme": "https://github.com/user/myapp/blob/v2/README.md",
284284+ }
285285+ err = UpsertRepositoryAnnotations(db, testUser.DID, "myapp", updatedAnnotations)
286286+ if err != nil {
287287+ t.Fatalf("Failed to update annotations: %v", err)
288288+ }
289289+241290 // Verify the manifest was updated
242291 retrievedUpdated, err := GetManifest(db, manifest1.Digest)
243292 if err != nil {
244293 t.Fatalf("Failed to retrieve updated manifest: %v", err)
245294 }
246246- if retrievedUpdated.Title != "My App v2" {
247247- t.Errorf("Expected updated title 'My App v2', got '%s'", retrievedUpdated.Title)
248248- }
249295 if retrievedUpdated.HoldEndpoint != "did:web:hold2.example.com" {
250296 t.Errorf("Expected updated hold_endpoint, got '%s'", retrievedUpdated.HoldEndpoint)
251297 }
252252- if retrievedUpdated.ReadmeURL != "https://github.com/user/myapp/blob/v2/README.md" {
253253- t.Errorf("Expected updated readme_url, got '%s'", retrievedUpdated.ReadmeURL)
298298+299299+ // Verify annotations were updated
300300+ retrievedUpdatedAnnotations, err := GetRepositoryAnnotations(db, testUser.DID, "myapp")
301301+ if err != nil {
302302+ t.Fatalf("Failed to retrieve updated annotations: %v", err)
303303+ }
304304+ if retrievedUpdatedAnnotations["org.opencontainers.image.title"] != "My App v2" {
305305+ t.Errorf("Expected updated title 'My App v2', got '%s'", retrievedUpdatedAnnotations["org.opencontainers.image.title"])
306306+ }
307307+ if retrievedUpdatedAnnotations["io.atcr.readme"] != "https://github.com/user/myapp/blob/v2/README.md" {
308308+ t.Errorf("Expected updated readme_url, got '%s'", retrievedUpdatedAnnotations["io.atcr.readme"])
254309 }
255310256311 // Test 4: Verify count - should have 2 manifests (not 3, because one was upserted)
···404459 SchemaVersion: 2,
405460 MediaType: "application/vnd.oci.image.manifest.v1+json",
406461 CreatedAt: time.Now(),
407407- Title: "App 1",
408462 },
409463 {
410464 DID: testUser.DID,
···414468 SchemaVersion: 2,
415469 MediaType: "application/vnd.oci.image.manifest.v1+json",
416470 CreatedAt: time.Now(),
417417- Title: "App 1 v2",
418471 },
419472 {
420473 DID: testUser.DID,
···424477 SchemaVersion: 2,
425478 MediaType: "application/vnd.oci.image.manifest.v1+json",
426479 CreatedAt: time.Now(),
427427- Title: "App 2",
428480 },
429481 }
430482···435487 }
436488 }
437489490490+ // Insert annotations for test manifests
491491+ err = UpsertRepositoryAnnotations(db, testUser.DID, "app1", map[string]string{
492492+ "org.opencontainers.image.title": "App 1",
493493+ })
494494+ if err != nil {
495495+ t.Fatalf("Failed to insert app1 annotations: %v", err)
496496+ }
497497+ err = UpsertRepositoryAnnotations(db, testUser.DID, "app2", map[string]string{
498498+ "org.opencontainers.image.title": "App 2",
499499+ })
500500+ if err != nil {
501501+ t.Fatalf("Failed to insert app2 annotations: %v", err)
502502+ }
503503+438504 // Test 1: GetManifest - found
439505 retrieved, err := GetManifest(db, "sha256:aaa")
440506 if err != nil {
441507 t.Fatalf("Failed to get manifest: %v", err)
442508 }
443443- if retrieved.Title != "App 1" {
444444- t.Errorf("Expected title 'App 1', got '%s'", retrieved.Title)
509509+ if retrieved.Digest != "sha256:aaa" {
510510+ t.Errorf("Expected digest 'sha256:aaa', got '%s'", retrieved.Digest)
445511 }
446512447513 // Test 2: GetManifest - not found
+12-7
pkg/appview/db/schema.sql
···2828 config_digest TEXT,
2929 config_size INTEGER,
3030 created_at TIMESTAMP NOT NULL,
3131- title TEXT,
3232- description TEXT,
3333- source_url TEXT,
3434- documentation_url TEXT,
3535- licenses TEXT,
3636- icon_url TEXT,
3737- readme_url TEXT,
3831 UNIQUE(did, repository, digest),
3932 FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
4033);
4134CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository);
4235CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC);
4336CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest);
3737+3838+CREATE TABLE IF NOT EXISTS repository_annotations (
3939+ did TEXT NOT NULL,
4040+ repository TEXT NOT NULL,
4141+ key TEXT NOT NULL,
4242+ value TEXT NOT NULL,
4343+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
4444+ PRIMARY KEY(did, repository, key),
4545+ FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
4646+);
4747+CREATE INDEX IF NOT EXISTS idx_repository_annotations_did_repo ON repository_annotations(did, repository);
4848+CREATE INDEX IF NOT EXISTS idx_repository_annotations_key ON repository_annotations(key);
44494550CREATE TABLE IF NOT EXISTS layers (
4651 manifest_id INTEGER NOT NULL,
···4343 </div>
44444545 <!-- Metadata Section -->
4646- {{ if or .Repository.Licenses .Repository.SourceURL .Repository.DocumentationURL }}
4646+ {{ if or .Repository.Licenses .Repository.SourceURL .Repository.DocumentationURL .Repository.Version }}
4747 <div class="repo-metadata">
4848+ {{ if .Repository.Version }}
4949+ <span class="metadata-badge version-badge" title="Version">
5050+ {{ .Repository.Version }}
5151+ </span>
5252+ {{ end }}
4853 {{ if .Repository.Licenses }}
4954 {{ range parseLicenses .Repository.Licenses }}
5055 {{ if .IsValid }}
+5
pkg/hold/config.go
···2828 // If true, creates/maintains a crew record with memberPattern: "*" (allows all authenticated users)
2929 // If false, deletes the wildcard crew record if it exists
3030 AllowAllCrew bool `yaml:"allow_all_crew"`
3131+3232+ // ProfileAvatarURL is the URL to download the avatar image from (from env: HOLD_PROFILE_AVATAR)
3333+ // If set, the avatar will be downloaded and uploaded as a blob during bootstrap
3434+ ProfileAvatarURL string `yaml:"profile_avatar_url"`
3135}
32363337// StorageConfig wraps distribution's storage configuration
···9195 // Registration configuration (optional)
9296 cfg.Registration.OwnerDID = os.Getenv("HOLD_OWNER")
9397 cfg.Registration.AllowAllCrew = os.Getenv("HOLD_ALLOW_ALL_CREW") == "true"
9898+ cfg.Registration.ProfileAvatarURL = getEnvOrDefault("HOLD_PROFILE_AVATAR", "https://imgs.blue/evan.jarrett.net/1TpTOdtS60GdJWBYEqtK22y688jajbQ9a5kbYRFtwuqrkBAE")
949995100 // Database configuration (optional - enables embedded PDS)
96101 // Note: HOLD_DATABASE_DIR is a directory path, carstore creates db.sqlite3 inside it