···22222323// Config represents the AppView service configuration
2424type Config struct {
2525- Version string `yaml:"version" comment:"Configuration format version."`
2626- LogLevel string `yaml:"log_level" comment:"Log level: debug, info, warn, error."`
2727- LogShipper config.LogShipperConfig `yaml:"log_shipper" comment:"Remote log shipping settings."`
2828- Server ServerConfig `yaml:"server" comment:"HTTP server and identity settings."`
2929- UI UIConfig `yaml:"ui" comment:"Web UI settings."`
3030- Health HealthConfig `yaml:"health" comment:"Health check and cache settings."`
3131- Jetstream JetstreamConfig `yaml:"jetstream" comment:"ATProto Jetstream event stream settings."`
3232- Auth AuthConfig `yaml:"auth" comment:"JWT authentication settings."`
3333- CredentialHelper CredentialHelperConfig `yaml:"credential_helper" comment:"Credential helper download settings."`
3434- Legal LegalConfig `yaml:"legal" comment:"Legal page customization for self-hosted instances."`
3535- AI AIConfig `yaml:"ai" comment:"AI-powered image advisor settings."`
3636- Billing billing.Config `yaml:"billing" comment:"Stripe billing integration (requires -tags billing build)."`
3737- Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility
2525+ Version string `yaml:"version" comment:"Configuration format version."`
2626+ LogLevel string `yaml:"log_level" comment:"Log level: debug, info, warn, error."`
2727+ LogShipper config.LogShipperConfig `yaml:"log_shipper" comment:"Remote log shipping settings."`
2828+ Server ServerConfig `yaml:"server" comment:"HTTP server and identity settings."`
2929+ UI UIConfig `yaml:"ui" comment:"Web UI settings."`
3030+ Health HealthConfig `yaml:"health" comment:"Health check and cache settings."`
3131+ Jetstream JetstreamConfig `yaml:"jetstream" comment:"ATProto Jetstream event stream settings."`
3232+ Auth AuthConfig `yaml:"auth" comment:"JWT authentication settings."`
3333+ Legal LegalConfig `yaml:"legal" comment:"Legal page customization for self-hosted instances."`
3434+ AI AIConfig `yaml:"ai" comment:"AI-powered image advisor settings."`
3535+ Billing billing.Config `yaml:"billing" comment:"Stripe billing integration (requires -tags billing build)."`
3636+ Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility
3837}
39384039// ServerConfig defines server settings
···124123 // ServiceName is the service name used for JWT issuer and service fields.
125124 // Derived from base URL hostname (e.g., "atcr.io")
126125 ServiceName string `yaml:"-"`
127127-}
128128-129129-// CredentialHelperConfig defines credential helper download settings
130130-type CredentialHelperConfig struct {
131131- // TangledRepo is the Tangled repository URL for downloads
132132- TangledRepo string `yaml:"tangled_repo" comment:"Tangled repository URL for credential helper downloads."`
133126}
134127135128// LegalConfig defines legal page customization for self-hosted instances
···258251 // Post-load: fixed values
259252 cfg.Auth.TokenExpiration = 5 * time.Minute
260253 cfg.Auth.ServiceName = deriveServiceName(cfg)
261261- cfg.CredentialHelper.TangledRepo = "https://tangled.org/evan.jarrett.net/at-container-registry"
262254263255 // Post-load: CompanyName defaults to ClientName
264256 if cfg.Legal.CompanyName == "" {
+48
pkg/appview/db/hold_store.go
···387387 return holds, nil
388388}
389389390390+// GetAccessibleHoldDIDs returns the set of hold DIDs whose content the viewer
391391+// is allowed to see in listings. If viewerDID is empty (anonymous), this
392392+// returns holds with public=1 OR allow_all_crew=1. For signed-in viewers it
393393+// additionally includes holds where the viewer is owner or crew.
394394+//
395395+// The returned slice is suitable for use in an IN (...) clause against
396396+// manifests.hold_endpoint / tags.hold_endpoint (which store the hold DID).
397397+func GetAccessibleHoldDIDs(db DBTX, viewerDID string) ([]string, error) {
398398+ var rows *sql.Rows
399399+ var err error
400400+401401+ if viewerDID == "" {
402402+ rows, err = db.Query(`
403403+ SELECT hold_did
404404+ FROM hold_captain_records
405405+ WHERE public = 1 OR allow_all_crew = 1
406406+ `)
407407+ } else {
408408+ rows, err = db.Query(`
409409+ SELECT DISTINCT h.hold_did
410410+ FROM hold_captain_records h
411411+ LEFT JOIN hold_crew_members c
412412+ ON h.hold_did = c.hold_did AND c.member_did = ?1
413413+ WHERE h.public = 1
414414+ OR h.allow_all_crew = 1
415415+ OR h.owner_did = ?1
416416+ OR c.member_did IS NOT NULL
417417+ `, viewerDID)
418418+ }
419419+ if err != nil {
420420+ return nil, fmt.Errorf("failed to query accessible holds: %w", err)
421421+ }
422422+ defer rows.Close()
423423+424424+ var dids []string
425425+ for rows.Next() {
426426+ var did string
427427+ if err := rows.Scan(&did); err != nil {
428428+ return nil, fmt.Errorf("failed to scan accessible hold: %w", err)
429429+ }
430430+ dids = append(dids, did)
431431+ }
432432+ if err := rows.Err(); err != nil {
433433+ return nil, fmt.Errorf("error iterating accessible holds: %w", err)
434434+ }
435435+ return dids, nil
436436+}
437437+390438// GetCrewMemberships returns all holds where a user is a crew member
391439func GetCrewMemberships(db DBTX, memberDID string) ([]CrewMember, error) {
392440 query := `
+91
pkg/appview/db/hold_store_test.go
···464464 }
465465 }
466466}
467467+468468+// TestGetAccessibleHoldDIDs tests the viewer→hold visibility computation
469469+// used to filter listings to what the viewer is allowed to see.
470470+func TestGetAccessibleHoldDIDs(t *testing.T) {
471471+ db := setupHoldTestDB(t)
472472+473473+ // Seed 4 captain records covering each visibility combo
474474+ records := []*HoldCaptainRecord{
475475+ {HoldDID: "did:web:public.example", OwnerDID: "did:plc:alice", Public: true, AllowAllCrew: false, UpdatedAt: time.Now()},
476476+ {HoldDID: "did:web:selfserv.example", OwnerDID: "did:plc:bob", Public: false, AllowAllCrew: true, UpdatedAt: time.Now()},
477477+ {HoldDID: "did:web:invite.example", OwnerDID: "did:plc:carol", Public: false, AllowAllCrew: false, UpdatedAt: time.Now()},
478478+ {HoldDID: "did:web:carol-hold.example", OwnerDID: "did:plc:carol", Public: false, AllowAllCrew: false, UpdatedAt: time.Now()},
479479+ }
480480+ for _, r := range records {
481481+ if err := UpsertCaptainRecord(db, r); err != nil {
482482+ t.Fatalf("seed captain %s: %v", r.HoldDID, err)
483483+ }
484484+ }
485485+486486+ // dave is crew of did:web:invite.example
487487+ if err := UpsertCrewMember(db, &CrewMember{
488488+ HoldDID: "did:web:invite.example", MemberDID: "did:plc:dave", Rkey: "rk1",
489489+ }); err != nil {
490490+ t.Fatalf("seed crew: %v", err)
491491+ }
492492+493493+ contains := func(haystack []string, needle string) bool {
494494+ for _, s := range haystack {
495495+ if s == needle {
496496+ return true
497497+ }
498498+ }
499499+ return false
500500+ }
501501+502502+ t.Run("anonymous viewer sees public + self-service only", func(t *testing.T) {
503503+ dids, err := GetAccessibleHoldDIDs(db, "")
504504+ if err != nil {
505505+ t.Fatalf("unexpected error: %v", err)
506506+ }
507507+ if len(dids) != 2 {
508508+ t.Fatalf("expected 2 DIDs (public+self-service), got %d: %v", len(dids), dids)
509509+ }
510510+ if !contains(dids, "did:web:public.example") {
511511+ t.Errorf("missing public hold: %v", dids)
512512+ }
513513+ if !contains(dids, "did:web:selfserv.example") {
514514+ t.Errorf("missing self-service hold: %v", dids)
515515+ }
516516+ if contains(dids, "did:web:invite.example") {
517517+ t.Errorf("anon should not see invite-only hold: %v", dids)
518518+ }
519519+ })
520520+521521+ t.Run("crew member also sees invite-only hold", func(t *testing.T) {
522522+ dids, err := GetAccessibleHoldDIDs(db, "did:plc:dave")
523523+ if err != nil {
524524+ t.Fatalf("unexpected error: %v", err)
525525+ }
526526+ if !contains(dids, "did:web:invite.example") {
527527+ t.Errorf("crew member should see invite-only hold they belong to: %v", dids)
528528+ }
529529+ if contains(dids, "did:web:carol-hold.example") {
530530+ t.Errorf("dave is not crew of carol's private hold: %v", dids)
531531+ }
532532+ })
533533+534534+ t.Run("owner sees their own private hold", func(t *testing.T) {
535535+ dids, err := GetAccessibleHoldDIDs(db, "did:plc:carol")
536536+ if err != nil {
537537+ t.Fatalf("unexpected error: %v", err)
538538+ }
539539+ // carol owns invite.example and carol-hold.example, both private
540540+ if !contains(dids, "did:web:invite.example") {
541541+ t.Errorf("owner should see their invite-only hold: %v", dids)
542542+ }
543543+ if !contains(dids, "did:web:carol-hold.example") {
544544+ t.Errorf("owner should see their second private hold: %v", dids)
545545+ }
546546+ })
547547+548548+ t.Run("random authenticated viewer gets same set as anonymous", func(t *testing.T) {
549549+ dids, err := GetAccessibleHoldDIDs(db, "did:plc:nobody")
550550+ if err != nil {
551551+ t.Fatalf("unexpected error: %v", err)
552552+ }
553553+ if len(dids) != 2 {
554554+ t.Fatalf("expected 2 DIDs, got %d: %v", len(dids), dids)
555555+ }
556556+ })
557557+}
+93-29
pkg/appview/db/queries.go
···1515 return fmt.Sprintf("https://imgs.blue/%s/%s", did, cid)
1616}
17171818+// accessibleHoldsSubquery returns SQL that evaluates to the set of hold DIDs
1919+// the viewer is allowed to see in listings. Requires the viewerDID to be
2020+// passed twice as query arguments (once for the owner_did check and once
2121+// for the crew membership check). Empty viewerDID (anonymous) naturally
2222+// matches no owner or crew rows, so only public + self-service holds
2323+// (allow_all_crew=1) are returned.
2424+const accessibleHoldsSubquery = `(
2525+ SELECT hold_did FROM hold_captain_records
2626+ WHERE public = 1
2727+ OR allow_all_crew = 1
2828+ OR owner_did = ?
2929+ OR hold_did IN (SELECT hold_did FROM hold_crew_members WHERE member_did = ?)
3030+)`
3131+1832// GetArtifactType determines the artifact type based on config media type
1933// Returns: "helm-chart", "container-image", or "unknown"
2034func GetArtifactType(configMediaType string) string {
···6882 WITH latest_manifests AS (
6983 SELECT did, repository, MAX(id) as latest_id
7084 FROM manifests
8585+ WHERE hold_endpoint IN ` + accessibleHoldsSubquery + `
7186 GROUP BY did, repository
7287 ),
7388 matching_repos AS (
···118133 LIMIT ? OFFSET ?
119134 `
120135121121- rows, err := db.Query(sqlQuery, searchPattern, query, searchPattern, searchPattern, currentUserDID, limit, offset)
136136+ rows, err := db.Query(sqlQuery, currentUserDID, currentUserDID, searchPattern, query, searchPattern, searchPattern, currentUserDID, limit, offset)
122137 if err != nil {
123138 return nil, 0, err
124139 }
···159174 WITH latest_manifests AS (
160175 SELECT did, repository, MAX(id) as latest_id
161176 FROM manifests
177177+ WHERE hold_endpoint IN ` + accessibleHoldsSubquery + `
162178 GROUP BY did, repository
163179 )
164180 SELECT COUNT(DISTINCT lm.did || '/' || lm.repository)
···175191 `
176192177193 var total int
178178- if err := db.QueryRow(countQuery, searchPattern, query, searchPattern, searchPattern).Scan(&total); err != nil {
194194+ if err := db.QueryRow(countQuery, currentUserDID, currentUserDID, searchPattern, query, searchPattern, searchPattern).Scan(&total); err != nil {
179195 return nil, 0, err
180196 }
181197182198 return cards, total, nil
183199}
184200185185-// GetUserRepositories fetches all repositories for a user
186186-func GetUserRepositories(db DBTX, did string) ([]Repository, error) {
187187- // Get repository summary
201201+// GetUserRepositories fetches all repositories for a user.
202202+// viewerDID scopes results to repositories whose manifests live on holds the
203203+// viewer can access (empty viewerDID = anonymous → public + self-service only).
204204+func GetUserRepositories(db DBTX, did string, viewerDID string) ([]Repository, error) {
205205+ // Get repository summary.
206206+ // Both tags and manifests are filtered via join onto manifests.hold_endpoint
207207+ // so repositories where every row lives on an inaccessible hold drop out.
188208 rows, err := db.Query(`
189209 SELECT
190210 repository,
···192212 COUNT(DISTINCT digest) as manifest_count,
193213 MAX(created_at) as last_push
194214 FROM (
195195- SELECT repository, tag, digest, created_at FROM tags WHERE did = ?
215215+ SELECT t.repository, t.tag, t.digest, t.created_at
216216+ FROM tags t
217217+ JOIN manifests tm ON t.did = tm.did AND t.repository = tm.repository AND t.digest = tm.digest
218218+ WHERE t.did = ? AND tm.hold_endpoint IN `+accessibleHoldsSubquery+`
196219 UNION
197197- SELECT repository, NULL, digest, created_at FROM manifests WHERE did = ?
220220+ SELECT m.repository, NULL, m.digest, m.created_at
221221+ FROM manifests m
222222+ WHERE m.did = ? AND m.hold_endpoint IN `+accessibleHoldsSubquery+`
198223 )
199224 GROUP BY repository
200225 ORDER BY last_push DESC
201201- `, did, did)
226226+ `, did, viewerDID, viewerDID, did, viewerDID, viewerDID)
202227203228 if err != nil {
204229 return nil, err
···779804// Only multi-arch tags (manifest lists) have platform info in manifest_references
780805// Single-arch tags will have empty Platforms slice (platform is obvious for single-arch)
781806// Attestation references (unknown/unknown platforms) are filtered out but tracked via HasAttestations
782782-func GetTagsWithPlatforms(db DBTX, did, repository string, limit, offset int) ([]TagWithPlatforms, error) {
783783- return getTagsWithPlatformsFiltered(db, did, repository, "", limit, offset)
807807+func GetTagsWithPlatforms(db DBTX, did, repository string, limit, offset int, viewerDID string) ([]TagWithPlatforms, error) {
808808+ return getTagsWithPlatformsFiltered(db, did, repository, "", limit, offset, viewerDID, true)
784809}
785810786811// getTagsWithPlatformsFiltered is the shared implementation for GetTagsWithPlatforms and GetTagByName.
787812// If tagName is non-empty, only that specific tag is returned.
788788-func getTagsWithPlatformsFiltered(db DBTX, did, repository, tagName string, limit, offset int) ([]TagWithPlatforms, error) {
813813+// When applyHoldFilter is true, rows are filtered by hold access for viewerDID.
814814+func getTagsWithPlatformsFiltered(db DBTX, did, repository, tagName string, limit, offset int, viewerDID string, applyHoldFilter bool) ([]TagWithPlatforms, error) {
789815 var tagFilter string
816816+ var holdFilter string
790817 var args []any
818818+ args = append(args, did, repository)
791819 if tagName != "" {
792792- tagFilter = "AND tag = ?"
793793- args = append(args, did, repository, tagName, limit, offset)
794794- } else {
795795- args = append(args, did, repository, limit, offset)
820820+ tagFilter = "AND t.tag = ?"
821821+ args = append(args, tagName)
796822 }
823823+ if applyHoldFilter {
824824+ holdFilter = "AND m.hold_endpoint IN " + accessibleHoldsSubquery
825825+ args = append(args, viewerDID, viewerDID)
826826+ }
827827+ args = append(args, limit, offset)
797828798829 query := `
799830 WITH paged_tags AS (
800800- SELECT id, did, repository, tag, digest, created_at
801801- FROM tags
802802- WHERE did = ? AND repository = ?
831831+ SELECT t.id, t.did, t.repository, t.tag, t.digest, t.created_at
832832+ FROM tags t
833833+ JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest
834834+ WHERE t.did = ? AND t.repository = ?
803835 ` + tagFilter + `
804804- ORDER BY created_at DESC
836836+ ` + holdFilter + `
837837+ ORDER BY t.created_at DESC
805838 LIMIT ? OFFSET ?
806839 )
807840 SELECT
···11171150// GetTopLevelManifests returns only manifest lists and orphaned single-arch manifests
11181151// Filters out platform-specific manifests that are referenced by manifest lists
11191152// Note: Annotations are stored separately in repository_annotations table - use GetRepositoryMetadata to fetch them
11201120-func GetTopLevelManifests(db DBTX, did, repository string, limit, offset int) ([]ManifestWithMetadata, error) {
11531153+func GetTopLevelManifests(db DBTX, did, repository string, limit, offset int, viewerDID string) ([]ManifestWithMetadata, error) {
11211154 rows, err := db.Query(`
11221155 WITH manifest_list_children AS (
11231156 -- Get all digests that are children of manifest lists
···11381171 WHERE m.did = ? AND m.repository = ?
11391172 AND m.subject_digest IS NULL
11401173 AND m.artifact_type != 'unknown'
11741174+ AND m.hold_endpoint IN `+accessibleHoldsSubquery+`
11411175 AND (
11421176 -- Include manifest lists
11431177 m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%'
···11481182 GROUP BY m.id
11491183 ORDER BY m.created_at DESC
11501184 LIMIT ? OFFSET ?
11511151- `, did, repository, did, repository, limit, offset)
11851185+ `, did, repository, did, repository, viewerDID, viewerDID, limit, offset)
1152118611531187 if err != nil {
11541188 return nil, err
···20192053 WITH latest_manifests AS (
20202054 SELECT did, repository, MAX(id) as latest_id
20212055 FROM manifests
20562056+ WHERE hold_endpoint IN ` + accessibleHoldsSubquery + `
20222057 GROUP BY did, repository
20232058 )
20242059 SELECT
···20462081 LIMIT ?
20472082 `
2048208320492049- rows, err := db.Query(query, currentUserDID, limit)
20842084+ rows, err := db.Query(query, currentUserDID, currentUserDID, currentUserDID, limit)
20502085 if err != nil {
20512086 return nil, err
20522087 }
···20922127 SELECT did, repository, MAX(id) as latest_id
20932128 FROM manifests
20942129 WHERE did = ?
21302130+ AND hold_endpoint IN ` + accessibleHoldsSubquery + `
20952131 GROUP BY did, repository
20962132 )
20972133 SELECT
···21182154 ORDER BY MAX(rs.last_push, m.created_at) DESC
21192155 `
2120215621212121- rows, err := db.Query(query, userDID, currentUserDID)
21572157+ rows, err := db.Query(query, userDID, currentUserDID, currentUserDID, currentUserDID)
21222158 if err != nil {
21232159 return nil, err
21242160 }
···24642500// GetTagByName returns a single tag with platform information by tag name.
24652501// Returns nil, nil if the tag doesn't exist.
24662502func GetTagByName(db DBTX, did, repository, tagName string) (*TagWithPlatforms, error) {
24672467- tags, err := getTagsWithPlatformsFiltered(db, did, repository, tagName, 1, 0)
25032503+ tags, err := getTagsWithPlatformsFiltered(db, did, repository, tagName, 1, 0, "", false)
24682504 if err != nil {
24692505 return nil, err
24702506 }
···24742510 return &tags[0], nil
24752511}
2476251225132513+// GetRepoHoldDIDs returns the distinct hold DIDs that host manifests for a
25142514+// given repository, restricted to holds the viewer can access.
25152515+func GetRepoHoldDIDs(db DBTX, did, repository string, viewerDID string) ([]string, error) {
25162516+ rows, err := db.Query(`
25172517+ SELECT DISTINCT m.hold_endpoint
25182518+ FROM manifests m
25192519+ WHERE m.did = ? AND m.repository = ?
25202520+ AND m.hold_endpoint != ''
25212521+ AND m.hold_endpoint IN `+accessibleHoldsSubquery+`
25222522+ `, did, repository, viewerDID, viewerDID)
25232523+ if err != nil {
25242524+ return nil, err
25252525+ }
25262526+ defer rows.Close()
25272527+ var holds []string
25282528+ for rows.Next() {
25292529+ var h string
25302530+ if err := rows.Scan(&h); err != nil {
25312531+ return nil, err
25322532+ }
25332533+ holds = append(holds, h)
25342534+ }
25352535+ return holds, rows.Err()
25362536+}
25372537+24772538// GetAllTagNames returns all tag names for a repository, ordered by most recent first.
24782478-func GetAllTagNames(db DBTX, did, repository string) ([]string, error) {
25392539+// Filters out tags whose manifests live on holds the viewer can't access.
25402540+func GetAllTagNames(db DBTX, did, repository string, viewerDID string) ([]string, error) {
24792541 rows, err := db.Query(`
24802480- SELECT tag FROM tags
24812481- WHERE did = ? AND repository = ?
24822482- ORDER BY created_at DESC
24832483- `, did, repository)
25422542+ SELECT t.tag FROM tags t
25432543+ JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest
25442544+ WHERE t.did = ? AND t.repository = ?
25452545+ AND m.hold_endpoint IN `+accessibleHoldsSubquery+`
25462546+ ORDER BY t.created_at DESC
25472547+ `, did, repository, viewerDID, viewerDID)
24842548 if err != nil {
24852549 return nil, err
24862550 }
+78-2
pkg/appview/db/queries_test.go
···855855 t.Fatalf("Failed to create test user: %v", err)
856856 }
857857858858+ // Register the test hold as public so the hold-access filter allows it
859859+ if err := UpsertCaptainRecord(db, &HoldCaptainRecord{
860860+ HoldDID: "did:web:hold.example.com",
861861+ OwnerDID: "did:plc:holdowner",
862862+ Public: true,
863863+ }); err != nil {
864864+ t.Fatalf("Failed to insert captain record: %v", err)
865865+ }
866866+858867 // Test 1: Single-arch manifest (no platform info)
859868 singleArchManifest := &Manifest{
860869 DID: testUser.DID,
···882891 t.Fatalf("Failed to insert single-arch tag: %v", err)
883892 }
884893885885- tagsWithPlatforms, err := GetTagsWithPlatforms(db, testUser.DID, "myapp", 100, 0)
894894+ tagsWithPlatforms, err := GetTagsWithPlatforms(db, testUser.DID, "myapp", 100, 0, "")
886895 if err != nil {
887896 t.Fatalf("Failed to get tags with platforms: %v", err)
888897 }
···951960 t.Fatalf("Failed to insert multi-arch tag: %v", err)
952961 }
953962954954- multiTagsWithPlatforms, err := GetTagsWithPlatforms(db, testUser.DID, "multiapp", 100, 0)
963963+ multiTagsWithPlatforms, err := GetTagsWithPlatforms(db, testUser.DID, "multiapp", 100, 0, "")
955964 if err != nil {
956965 t.Fatalf("Failed to get multi-arch tags with platforms: %v", err)
957966 }
···15311540 t.Errorf("Expected 3 digests, got %d: %v", len(digests), digests)
15321541 }
15331542}
15431543+15441544+// TestGetUserRepositories_HoldAccessFilter verifies that repositories whose
15451545+// manifests live on inaccessible holds are hidden from viewers without access.
15461546+func TestGetUserRepositories_HoldAccessFilter(t *testing.T) {
15471547+ db, err := InitDB("file:TestGetUserRepositories_HoldAccessFilter?mode=memory&cache=shared", LibsqlConfig{})
15481548+ if err != nil {
15491549+ t.Fatalf("init db: %v", err)
15501550+ }
15511551+ defer db.Close()
15521552+15531553+ testUser := &User{DID: "did:plc:alice", Handle: "alice.test", PDSEndpoint: "https://pds.example", LastSeen: time.Now()}
15541554+ if err := UpsertUser(db, testUser); err != nil {
15551555+ t.Fatalf("upsert user: %v", err)
15561556+ }
15571557+15581558+ // Public hold and a private invite-only hold
15591559+ if err := UpsertCaptainRecord(db, &HoldCaptainRecord{
15601560+ HoldDID: "did:web:public.example", OwnerDID: "did:plc:holdowner", Public: true,
15611561+ }); err != nil {
15621562+ t.Fatalf("seed public captain: %v", err)
15631563+ }
15641564+ if err := UpsertCaptainRecord(db, &HoldCaptainRecord{
15651565+ HoldDID: "did:web:private.example", OwnerDID: "did:plc:holdowner", Public: false, AllowAllCrew: false,
15661566+ }); err != nil {
15671567+ t.Fatalf("seed private captain: %v", err)
15681568+ }
15691569+15701570+ // Two repos: one on the public hold, one on the private hold
15711571+ if _, err := InsertManifest(db, &Manifest{
15721572+ DID: testUser.DID, Repository: "publicrepo", Digest: "sha256:pub",
15731573+ HoldEndpoint: "did:web:public.example", SchemaVersion: 2,
15741574+ MediaType: "application/vnd.oci.image.manifest.v1+json", CreatedAt: time.Now(),
15751575+ }); err != nil {
15761576+ t.Fatalf("insert public manifest: %v", err)
15771577+ }
15781578+ if _, err := InsertManifest(db, &Manifest{
15791579+ DID: testUser.DID, Repository: "privaterepo", Digest: "sha256:priv",
15801580+ HoldEndpoint: "did:web:private.example", SchemaVersion: 2,
15811581+ MediaType: "application/vnd.oci.image.manifest.v1+json", CreatedAt: time.Now(),
15821582+ }); err != nil {
15831583+ t.Fatalf("insert private manifest: %v", err)
15841584+ }
15851585+15861586+ // Anonymous viewer should see only the publicrepo
15871587+ repos, err := GetUserRepositories(db, testUser.DID, "")
15881588+ if err != nil {
15891589+ t.Fatalf("GetUserRepositories anon: %v", err)
15901590+ }
15911591+ if len(repos) != 1 || repos[0].Name != "publicrepo" {
15921592+ t.Errorf("anon viewer: expected [publicrepo], got %v", repos)
15931593+ }
15941594+15951595+ // Make the private-hold owner a crew member and re-query as them
15961596+ if err := UpsertCrewMember(db, &CrewMember{
15971597+ HoldDID: "did:web:private.example", MemberDID: "did:plc:crewdave", Rkey: "rk1",
15981598+ }); err != nil {
15991599+ t.Fatalf("upsert crew: %v", err)
16001600+ }
16011601+16021602+ repos, err = GetUserRepositories(db, testUser.DID, "did:plc:crewdave")
16031603+ if err != nil {
16041604+ t.Fatalf("GetUserRepositories crew: %v", err)
16051605+ }
16061606+ if len(repos) != 2 {
16071607+ t.Errorf("crew viewer: expected both repos, got %d: %v", len(repos), repos)
16081608+ }
16091609+}
-29
pkg/appview/handlers/api.go
···164164 render.JSON(w, r, map[string]bool{"starred": false})
165165}
166166167167-// CredentialHelperVersionResponse is the response for the credential helper version API
168168-type CredentialHelperVersionResponse struct {
169169- Latest string `json:"latest"`
170170- DownloadURLs map[string]string `json:"download_urls"`
171171- Checksums map[string]string `json:"checksums"`
172172- ReleaseNotes string `json:"release_notes,omitempty"`
173173-}
174174-175175-// CredentialHelperVersionHandler returns the latest credential helper version info
176176-// Note: Version info is fetched dynamically from TangledRepo's releases
177177-type CredentialHelperVersionHandler struct {
178178- TangledRepo string
179179-}
180180-181181-func (h *CredentialHelperVersionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
182182- // This endpoint directs users to the Tangled repository for downloads
183183- // Version info should be fetched from the repository's releases page
184184- response := CredentialHelperVersionResponse{
185185- Latest: "",
186186- DownloadURLs: map[string]string{"tangled_repo": h.TangledRepo},
187187- Checksums: nil,
188188- ReleaseNotes: "Visit the Tangled repository for the latest releases: " + h.TangledRepo,
189189- }
190190-191191- render.SetContentType(render.ContentTypeJSON)
192192- w.Header().Set("Cache-Control", "public, max-age=300") // Cache for 5 minutes
193193- render.JSON(w, r, response)
194194-}
195195-196167// renderStarComponent renders the star component HTML for HTMX responses
197168func renderStarComponent(w http.ResponseWriter, tmpl *template.Template, handle, repository string, isStarred bool, starCount int) {
198169 data := map[string]any{
···7272 return
7373 }
74747575+ // Resolve viewer DID for hold-access filtering (empty string = anonymous)
7676+ var viewerDID string
7777+ if vu := middleware.GetUser(r); vu != nil {
7878+ viewerDID = vu.DID
7979+ }
8080+7581 // Fetch all tag names for the selector dropdown
7676- allTags, err := db.GetAllTagNames(h.ReadOnlyDB, owner.DID, repository)
8282+ allTags, err := db.GetAllTagNames(h.ReadOnlyDB, owner.DID, repository, viewerDID)
7783 if err != nil {
7884 slog.Warn("Failed to fetch tag names", "error", err)
7985 }
···277283 h.ClientShortName,
278284 ))
279285286286+ // Compute cross-hold badge: if the viewer has a default hold set and this
287287+ // repo has tags on any other accessible hold, flag them so the template
288288+ // can show an informational chip.
289289+ var nonDefaultHolds []string
290290+ if viewerDID != "" {
291291+ viewerDefaultHold := db.GetUserHoldDID(h.ReadOnlyDB, viewerDID)
292292+ if viewerDefaultHold != "" {
293293+ repoHolds, herr := db.GetRepoHoldDIDs(h.ReadOnlyDB, owner.DID, repository, viewerDID)
294294+ if herr != nil {
295295+ slog.Warn("Failed to fetch repo hold DIDs", "error", herr)
296296+ }
297297+ for _, rh := range repoHolds {
298298+ if rh != viewerDefaultHold {
299299+ nonDefaultHolds = append(nonDefaultHolds, rh)
300300+ }
301301+ }
302302+ }
303303+ }
304304+280305 data := struct {
281306 PageData
282282- Meta *PageMeta
283283- Owner *db.User
284284- Repository *db.Repository
285285- AllTags []string
286286- SelectedTag *SelectedTagData
287287- Stats *db.RepositoryStats
288288- TagCount int
289289- IsStarred bool
290290- IsOwner bool
291291- ReadmeHTML template.HTML
292292- RawDescription string
293293- ArtifactType string
307307+ Meta *PageMeta
308308+ Owner *db.User
309309+ Repository *db.Repository
310310+ AllTags []string
311311+ SelectedTag *SelectedTagData
312312+ Stats *db.RepositoryStats
313313+ TagCount int
314314+ IsStarred bool
315315+ IsOwner bool
316316+ ReadmeHTML template.HTML
317317+ RawDescription string
318318+ ArtifactType string
319319+ NonDefaultHolds []string
294320 }{
295295- PageData: NewPageData(r, &h.BaseUIHandler),
296296- Meta: meta,
297297- Owner: owner,
298298- Repository: repo,
299299- AllTags: allTags,
300300- SelectedTag: selectedTag,
301301- Stats: stats,
302302- TagCount: tagCount,
303303- IsStarred: isStarred,
304304- IsOwner: isOwner,
305305- ReadmeHTML: readmeHTML,
306306- RawDescription: rawDescription,
307307- ArtifactType: artifactType,
321321+ PageData: NewPageData(r, &h.BaseUIHandler),
322322+ Meta: meta,
323323+ Owner: owner,
324324+ Repository: repo,
325325+ AllTags: allTags,
326326+ SelectedTag: selectedTag,
327327+ Stats: stats,
328328+ TagCount: tagCount,
329329+ IsStarred: isStarred,
330330+ IsOwner: isOwner,
331331+ ReadmeHTML: readmeHTML,
332332+ RawDescription: rawDescription,
333333+ ArtifactType: artifactType,
334334+ NonDefaultHolds: nonDefaultHolds,
308335 }
309336310337 // If the owner has disabled AI advisor in their profile, hide the button
···388415 }
389416 }
390417418418+ // Resolve viewer DID for hold-access filtering (empty string = anonymous)
419419+ var viewerDID string
420420+ if vu := middleware.GetUser(r); vu != nil {
421421+ viewerDID = vu.DID
422422+ }
423423+391424 // Count total tags for pagination
392425 totalTags, err := db.CountTags(h.ReadOnlyDB, owner.DID, repository)
393426 if err != nil {
···396429 }
397430398431 // Fetch tags with platform information and compressed sizes
399399- tagsWithPlatforms, err := db.GetTagsWithPlatforms(h.ReadOnlyDB, owner.DID, repository, pageSize, offset)
432432+ tagsWithPlatforms, err := db.GetTagsWithPlatforms(h.ReadOnlyDB, owner.DID, repository, pageSize, offset, viewerDID)
400433 if err != nil {
401434 http.Error(w, err.Error(), http.StatusInternalServerError)
402435 return
···405438 // Fetch untagged manifests only on first page
406439 var manifests []db.ManifestWithMetadata
407440 if offset == 0 {
408408- manifests, err = db.GetTopLevelManifests(h.ReadOnlyDB, owner.DID, repository, 50, 0)
441441+ manifests, err = db.GetTopLevelManifests(h.ReadOnlyDB, owner.DID, repository, 50, 0, viewerDID)
409442 if err != nil {
410443 http.Error(w, err.Error(), http.StatusInternalServerError)
411444 return
+39-49
pkg/appview/public/static/install.ps1
···66# Configuration
77$BinaryName = "docker-credential-atcr.exe"
88$InstallDir = if ($env:ATCR_INSTALL_DIR) { $env:ATCR_INSTALL_DIR } else { "$env:ProgramFiles\ATCR" }
99-$ApiUrl = if ($env:ATCR_API_URL) { $env:ATCR_API_URL } else { "https://atcr.io/api/credential-helper/version" }
1010-1111-# Fallback configuration (used if API is unavailable)
1212-$FallbackVersion = "v0.0.1"
1313-$FallbackTangledRepo = "https://tangled.org/evan.jarrett.net/at-container-registry"
99+$TangledRepo = if ($env:ATCR_TANGLED_REPO) { $env:ATCR_TANGLED_REPO } else { "https://tangled.org/did:plc:e3kzdezk5gsirzh7eoqplc64" }
14101511Write-Host "ATCR Credential Helper Installer for Windows" -ForegroundColor Green
1612Write-Host ""
···1915function Get-Architecture {
2016 $arch = (Get-WmiObject Win32_Processor).Architecture
2117 switch ($arch) {
2222- 9 { return @{ Display = "x86_64"; Key = "amd64" } } # x64
2323- 12 { return @{ Display = "arm64"; Key = "arm64" } } # ARM64
1818+ 9 { return "x86_64" } # x64
1919+ 12 { return "arm64" } # ARM64
2420 default {
2521 Write-Host "Unsupported architecture: $arch" -ForegroundColor Red
2622 exit 1
···2824 }
2925}
30263131-$ArchInfo = Get-Architecture
3232-$Arch = $ArchInfo.Display
3333-$ArchKey = $ArchInfo.Key
3434-$PlatformKey = "windows_$ArchKey"
3535-2727+$Arch = Get-Architecture
3628Write-Host "Detected: Windows $Arch" -ForegroundColor Green
37293838-# Fetch version info from API
3939-function Get-VersionInfo {
4040- Write-Host "Fetching latest version info..." -ForegroundColor Yellow
3030+# Resolve the latest version via the tangled /tags/latest redirect
3131+function Get-LatestVersion {
3232+ Write-Host "Resolving latest version..." -ForegroundColor Yellow
41334234 try {
4343- $response = Invoke-WebRequest -Uri $ApiUrl -UseBasicParsing -TimeoutSec 10
4444- $json = $response.Content | ConvertFrom-Json
4545-4646- if ($json.latest -and $json.download_urls.$PlatformKey) {
4747- return @{
4848- Version = $json.latest
4949- DownloadUrl = $json.download_urls.$PlatformKey
5050- }
5151- }
3535+ $response = Invoke-WebRequest -Uri "$TangledRepo/tags/latest" -UseBasicParsing -MaximumRedirection 0 -ErrorAction SilentlyContinue
3636+ $location = $response.Headers.Location
5237 } catch {
5353- Write-Host "API unavailable, using fallback version" -ForegroundColor Yellow
3838+ # PowerShell 5 throws when -MaximumRedirection 0 receives a redirect; grab it off the exception.
3939+ $location = $_.Exception.Response.Headers.Location
4040+ if ($location) { $location = $location.ToString() }
4141+ }
4242+4343+ if (-not $location) {
4444+ Write-Host "Failed to resolve latest version from $TangledRepo/tags/latest" -ForegroundColor Red
4545+ exit 1
5446 }
55475656- return $null
4848+ $tag = $location.TrimEnd('/').Split('/')[-1]
4949+ if (-not $tag.StartsWith('v')) {
5050+ Write-Host "Unexpected redirect location: $location" -ForegroundColor Red
5151+ exit 1
5252+ }
5353+5454+ Write-Host "Found latest version: $tag" -ForegroundColor Green
5555+ return $tag
5756}
58575959-# Get download URL for fallback
6060-function Get-FallbackUrl {
5858+# Build the download URL from version and platform
5959+function Get-DownloadUrl {
6160 param([string]$Version, [string]$Arch)
62616362 $versionClean = $Version.TrimStart('v')
6464- # Note: Windows builds use .zip format
6565- $fileName = "docker-credential-atcr_${versionClean}_Windows_${Arch}.zip"
6666- return "$FallbackTangledRepo/tags/$Version/download/$fileName"
6363+ $fileName = "docker-credential-atcr_${versionClean}_Windows_${Arch}.tar.gz"
6464+ return "$TangledRepo/tags/$Version/download/$fileName"
6765}
68666967# Determine version and download URL
7070-$Version = $null
7171-$DownloadUrl = $null
7272-7368if ($env:ATCR_VERSION) {
7469 $Version = $env:ATCR_VERSION
7575- $DownloadUrl = Get-FallbackUrl -Version $Version -Arch $Arch
7670 Write-Host "Using specified version: $Version" -ForegroundColor Yellow
7771} else {
7878- $versionInfo = Get-VersionInfo
7979-8080- if ($versionInfo) {
8181- $Version = $versionInfo.Version
8282- $DownloadUrl = $versionInfo.DownloadUrl
8383- Write-Host "Found latest version: $Version" -ForegroundColor Green
8484- } else {
8585- $Version = $FallbackVersion
8686- $DownloadUrl = Get-FallbackUrl -Version $Version -Arch $Arch
8787- Write-Host "Using fallback version: $Version" -ForegroundColor Yellow
8888- }
7272+ $Version = Get-LatestVersion
8973}
90747575+$DownloadUrl = Get-DownloadUrl -Version $Version -Arch $Arch
9176Write-Host "Installing version: $Version" -ForegroundColor Green
92779378# Download and install binary
···9984 Write-Host "Downloading from: $DownloadUrl" -ForegroundColor Yellow
1008510186 $tempDir = New-Item -ItemType Directory -Path "$env:TEMP\atcr-install-$(Get-Random)" -Force
102102- $zipPath = Join-Path $tempDir "docker-credential-atcr.zip"
8787+ $archivePath = Join-Path $tempDir "docker-credential-atcr.tar.gz"
1038810489 try {
105105- Invoke-WebRequest -Uri $DownloadUrl -OutFile $zipPath -UseBasicParsing
9090+ Invoke-WebRequest -Uri $DownloadUrl -OutFile $archivePath -UseBasicParsing
10691 } catch {
10792 Write-Host "Failed to download release: $_" -ForegroundColor Red
10893 exit 1
10994 }
1109511196 Write-Host "Extracting..." -ForegroundColor Yellow
112112- Expand-Archive -Path $zipPath -DestinationPath $tempDir -Force
9797+ # Modern Windows ships tar.exe; use it to handle .tar.gz produced by goreleaser.
9898+ & tar.exe -xzf $archivePath -C $tempDir
9999+ if ($LASTEXITCODE -ne 0) {
100100+ Write-Host "Failed to extract archive" -ForegroundColor Red
101101+ exit 1
102102+ }
113103114104 # Create install directory
115105 if (-not (Test-Path $InstallDir)) {
+24-50
pkg/appview/public/static/install.sh
···1313# Configuration
1414BINARY_NAME="docker-credential-atcr"
1515INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}"
1616-API_URL="${ATCR_API_URL:-https://atcr.io/api/credential-helper/version}"
1717-1818-# Fallback configuration (used if API is unavailable)
1919-FALLBACK_VERSION="v0.0.1"
2020-FALLBACK_TANGLED_REPO="https://tangled.org/evan.jarrett.net/at-container-registry"
1616+TANGLED_REPO="${ATCR_TANGLED_REPO:-https://tangled.org/did:plc:e3kzdezk5gsirzh7eoqplc64}"
21172218# Detect OS and architecture
2319detect_platform() {
···2723 case "$os" in
2824 linux*)
2925 OS="Linux"
3030- OS_KEY="linux"
3126 ;;
3227 darwin*)
3328 OS="Darwin"
3434- OS_KEY="darwin"
3529 ;;
3630 *)
3731 echo -e "${RED}Unsupported OS: $os${NC}"
···4236 case "$arch" in
4337 x86_64|amd64)
4438 ARCH="x86_64"
4545- ARCH_KEY="amd64"
4639 ;;
4740 aarch64|arm64)
4841 ARCH="arm64"
4949- ARCH_KEY="arm64"
5042 ;;
5143 *)
5244 echo -e "${RED}Unsupported architecture: $arch${NC}"
5345 exit 1
5446 ;;
5547 esac
5656-5757- PLATFORM_KEY="${OS_KEY}_${ARCH_KEY}"
5848}
59496060-# Fetch version info from API
6161-fetch_version_info() {
6262- echo -e "${YELLOW}Fetching latest version info...${NC}"
5050+# Resolve the latest version by reading the tangled /tags/latest redirect
5151+fetch_latest_version() {
5252+ echo -e "${YELLOW}Resolving latest version...${NC}"
63536464- # Try to fetch from API
6565- local api_response
6666- if api_response=$(curl -fsSL --max-time 10 "$API_URL" 2>/dev/null); then
6767- # Parse JSON response (requires jq or basic parsing)
6868- if command -v jq &> /dev/null; then
6969- VERSION=$(echo "$api_response" | jq -r '.latest')
7070- DOWNLOAD_URL=$(echo "$api_response" | jq -r ".download_urls.${PLATFORM_KEY}")
5454+ local redirect
5555+ redirect=$(curl -s --max-time 10 -o /dev/null -D - "${TANGLED_REPO}/tags/latest" | awk 'tolower($1) == "location:" { print $2 }' | tr -d '\r\n')
71567272- if [ "$VERSION" != "null" ] && [ "$DOWNLOAD_URL" != "null" ] && [ -n "$VERSION" ] && [ -n "$DOWNLOAD_URL" ]; then
7373- echo -e "${GREEN}Found latest version: ${VERSION}${NC}"
7474- return 0
7575- fi
7676- else
7777- # Fallback: basic grep parsing if jq not available
7878- VERSION=$(echo "$api_response" | grep -o '"latest":"[^"]*"' | cut -d'"' -f4)
7979- # Try to extract the specific platform URL
8080- DOWNLOAD_URL=$(echo "$api_response" | grep -o "\"${PLATFORM_KEY}\":\"[^\"]*\"" | cut -d'"' -f4)
5757+ if [ -z "$redirect" ]; then
5858+ echo -e "${RED}Failed to resolve latest version from ${TANGLED_REPO}/tags/latest${NC}"
5959+ exit 1
6060+ fi
81618282- if [ -n "$VERSION" ] && [ -n "$DOWNLOAD_URL" ]; then
8383- echo -e "${GREEN}Found latest version: ${VERSION}${NC}"
8484- return 0
8585- fi
8686- fi
6262+ VERSION="${redirect##*/}"
6363+6464+ if [ -z "$VERSION" ] || [ "${VERSION#v}" = "$VERSION" ]; then
6565+ echo -e "${RED}Unexpected redirect location: ${redirect}${NC}"
6666+ exit 1
8767 fi
88688989- echo -e "${YELLOW}API unavailable, using fallback version${NC}"
9090- return 1
6969+ echo -e "${GREEN}Found latest version: ${VERSION}${NC}"
9170}
92719393-# Set fallback download URL
9494-use_fallback() {
9595- VERSION="$FALLBACK_VERSION"
7272+# Build the download URL from version and platform
7373+build_download_url() {
9674 local version_without_v="${VERSION#v}"
9797- DOWNLOAD_URL="${FALLBACK_TANGLED_REPO}/tags/${VERSION}/download/docker-credential-atcr_${version_without_v}_${OS}_${ARCH}.tar.gz"
7575+ DOWNLOAD_URL="${TANGLED_REPO}/tags/${VERSION}/download/docker-credential-atcr_${version_without_v}_${OS}_${ARCH}.tar.gz"
9876}
997710078# Download and install binary
···164142 detect_platform
165143 echo -e "Detected: ${GREEN}${OS} ${ARCH}${NC}"
166144167167- # Check if version is manually specified
168145 if [ -n "$ATCR_VERSION" ]; then
169169- echo -e "Using specified version: ${GREEN}${ATCR_VERSION}${NC}"
170146 VERSION="$ATCR_VERSION"
171171- local version_without_v="${VERSION#v}"
172172- DOWNLOAD_URL="${FALLBACK_TANGLED_REPO}/tags/${VERSION}/download/docker-credential-atcr_${version_without_v}_${OS}_${ARCH}.tar.gz"
147147+ echo -e "Using specified version: ${GREEN}${VERSION}${NC}"
173148 else
174174- # Try to fetch from API, fall back if unavailable
175175- if ! fetch_version_info; then
176176- use_fallback
177177- fi
178178- echo -e "Installing version: ${GREEN}${VERSION}${NC}"
149149+ fetch_latest_version
179150 fi
151151+152152+ build_download_url
153153+ echo -e "Installing version: ${GREEN}${VERSION}${NC}"
180154181155 install_binary
182156 verify_installation
-9
pkg/appview/routes/routes.go
···269269 })
270270}
271271272272-// RegisterCredentialHelperEndpoint registers the credential helper version API
273273-// endpoint (GET /api/credential-helper/version). Separated from RegisterUIRoutes
274274-// for the same import-cycle reason as RegisterDeviceEndpoints.
275275-func RegisterCredentialHelperEndpoint(router chi.Router, tangledRepo string) {
276276- router.Handle("/api/credential-helper/version", &uihandlers.CredentialHelperVersionHandler{
277277- TangledRepo: tangledRepo,
278278- })
279279-}
280280-281272// trimRegistryURL removes http:// or https:// prefix from a URL
282273// for use in Docker commands where only the host:port is needed
283274func trimRegistryURL(url string) string {
-3
pkg/appview/server.go
···611611 // Appview DID document endpoint (service identity for key discovery)
612612 mainRouter.Get("/.well-known/did.json", s.handleDIDDocument)
613613614614- // Register credential helper version API (public endpoint)
615615- routes.RegisterCredentialHelperEndpoint(mainRouter, cfg.CredentialHelper.TangledRepo)
616616-617614 s.Router = mainRouter
618615619616 return s, nil