···1414# Environment configuration
1515.env
16161717-# Docker-created quota config (actual config is in deploy/quotas.yaml)
1818-quotas.yaml
1717+# Deploy state (contains server UUIDs and IPs)
1818+deploy/upcloud/state.json
19192020# Generated assets (run go generate to rebuild)
2121pkg/appview/licenses/spdx-licenses.json
+2-2
config-appview.example.yaml
···3535 client_name: AT Container Registry
3636 # Short name used in page titles and browser tabs.
3737 client_short_name: ATCR
3838- # Separate domain for OCI registry API (e.g. "buoy.cr"). Browser visits redirect to BaseURL.
3939- registry_domain: ""
3838+ # Separate domains for OCI registry API (e.g. ["buoy.cr"]). First is primary. Browser visits redirect to BaseURL.
3939+ registry_domains: []
4040# Web UI settings.
4141ui:
4242 # SQLite database for OAuth sessions, stars, pull counts, and device approvals.
···5757 // Short name used in page titles and browser tabs.
5858 ClientShortName string `yaml:"client_short_name" comment:"Short name used in page titles and browser tabs."`
59596060- // Separate domain for OCI registry API.
6161- RegistryDomain string `yaml:"registry_domain" comment:"Separate domain for OCI registry API (e.g. \"buoy.cr\"). Browser visits redirect to BaseURL."`
6060+ // Separate domains for OCI registry API. First entry is the primary (used for JWT service name and UI display).
6161+ RegistryDomains []string `yaml:"registry_domains" comment:"Separate domains for OCI registry API (e.g. [\"buoy.cr\"]). First is primary. Browser visits redirect to BaseURL."`
6262}
63636464// UIConfig defines web UI settings
···145145 v.SetDefault("server.client_name", "AT Container Registry")
146146 v.SetDefault("server.client_short_name", "ATCR")
147147 v.SetDefault("server.oauth_key_path", "/var/lib/atcr/oauth/client.key")
148148- v.SetDefault("server.registry_domain", "")
148148+ v.SetDefault("server.registry_domains", []string{})
149149150150 // UI defaults
151151 v.SetDefault("ui.database_path", "/var/lib/atcr/ui.db")
···241241242242// deriveServiceName extracts the JWT service name from the config.
243243func deriveServiceName(cfg *Config) string {
244244- if cfg.Server.RegistryDomain != "" {
245245- return cfg.Server.RegistryDomain
244244+ if len(cfg.Server.RegistryDomains) > 0 {
245245+ return cfg.Server.RegistryDomains[0]
246246 }
247247 return getServiceName(cfg.Server.BaseURL)
248248}
+2
pkg/appview/db/hold_store_test.go
···8787 }
8888 // Limit to single connection to avoid race conditions in tests
8989 db.SetMaxOpenConns(1)
9090+ // Clean slate: shared-cache in-memory DB may retain data from prior subtests
9191+ db.Exec("DELETE FROM hold_captain_records")
9092 t.Cleanup(func() { db.Close() })
9193 return db
9294}
+25-9
pkg/appview/server.go
···240240 mainRouter.Use(routes.CORSMiddleware())
241241242242 // Registry domain redirect middleware
243243- if cfg.Server.RegistryDomain != "" {
244244- mainRouter.Use(RegistryDomainRedirect(cfg.Server.RegistryDomain, cfg.Server.BaseURL))
243243+ if len(cfg.Server.RegistryDomains) > 0 {
244244+ mainRouter.Use(RegistryDomainRedirect(cfg.Server.RegistryDomains, cfg.Server.BaseURL))
245245 slog.Info("Registry domain redirect enabled",
246246- "registry_domain", cfg.Server.RegistryDomain,
246246+ "registry_domains", cfg.Server.RegistryDomains,
247247 "ui_base_url", cfg.Server.BaseURL)
248248 }
249249···263263 OAuthStore: s.OAuthStore,
264264 Refresher: s.Refresher,
265265 BaseURL: baseURL,
266266- RegistryDomain: cfg.Server.RegistryDomain,
266266+ RegistryDomain: primaryRegistryDomain(cfg.Server.RegistryDomains),
267267 DeviceStore: s.DeviceStore,
268268 HealthChecker: s.HealthChecker,
269269 ReadmeFetcher: s.ReadmeFetcher,
···499499 mainRouter.Get("/health", func(w http.ResponseWriter, r *http.Request) {
500500 w.Header().Set("Content-Type", "application/json")
501501 w.WriteHeader(http.StatusOK)
502502- json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
502502+ if err := json.NewEncoder(w).Encode(map[string]string{"status": "ok"}); err != nil {
503503+ http.Error(w, "encode error", http.StatusInternalServerError)
504504+ return
505505+ }
503506 })
504507505508 // Register credential helper version API (public endpoint)
···577580 )
578581}
579582580580-// RegistryDomainRedirect redirects all non-registry requests from the registry
581581-// domain to the UI domain. Only /v2 and /v2/* pass through for Docker clients.
583583+// RegistryDomainRedirect redirects all non-registry requests from registry
584584+// domains to the UI domain. Only /v2 and /v2/* pass through for Docker clients.
582585// Uses 307 (Temporary Redirect) to preserve POST method/body.
583583-func RegistryDomainRedirect(registryDomain, uiBaseURL string) func(http.Handler) http.Handler {
586586+func RegistryDomainRedirect(registryDomains []string, uiBaseURL string) func(http.Handler) http.Handler {
587587+ domains := make(map[string]bool, len(registryDomains))
588588+ for _, d := range registryDomains {
589589+ domains[d] = true
590590+ }
591591+584592 return func(next http.Handler) http.Handler {
585593 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
586594 host := r.Host
···588596 host = host[:idx]
589597 }
590598591591- if host == registryDomain {
599599+ if domains[host] {
592600 path := r.URL.Path
593601 if path == "/v2" || path == "/v2/" || strings.HasPrefix(path, "/v2/") {
594602 next.ServeHTTP(w, r)
···603611 next.ServeHTTP(w, r)
604612 })
605613 }
614614+}
615615+616616+// primaryRegistryDomain returns the first registry domain, or empty string if none.
617617+func primaryRegistryDomain(domains []string) string {
618618+ if len(domains) > 0 {
619619+ return domains[0]
620620+ }
621621+ return ""
606622}
607623608624// initializeJetstream initializes the Jetstream workers for real-time events and backfill.
···114114 stats, err := h.pdsServer.GetQuotaForUserWithTier(r.Context(), userDID, h.manager.quotaMgr)
115115 if err == nil {
116116 info.CurrentUsage = stats.TotalSize
117117- info.CrewTier = stats.Tier // tier from local crew record (what's actually enforced)
117117+ info.CrewTier = stats.Tier // tier from local crew record (what's actually enforced)
118118 info.CurrentLimit = stats.Limit
119119120120 // If no subscription but crew has a tier, show that as current
+7-8
pkg/hold/db/sqlite_store.go
···11-// Package db contains a vendored from github.com/bluesky-social/indigo/carstore/sqlite_store.go
11+// Package db contains a vendored from github.com/bluesky-social/indigo/carstore/sqlite_store.go
22// Source: github.com/bluesky-social/indigo@v0.0.0-20260203235305-a86f3ae1f8ec/carstore/
33// Reason: indigo's carstore hardcodes mattn/go-sqlite3, which conflicts with go-libsql
44// (both bundle SQLite C libraries and cannot coexist in the same binary).
55//
66// This package replaces the mattn driver with go-libsql and removes Prometheus metrics.
77// Once upstream accepts a driver-agnostic constructor, this vendored copy can be removed.
88-// Modifications:
99-// - Replaced mattn/go-sqlite3 driver with go-libsql
1010-// - Removed all Prometheus metric counters and .Inc() calls
1111-// - Changed package from 'carstore' to 'db'
1212-// - Added NewSQLiteStoreWithDB constructor for injecting an existing *sql.DB
1313-// - Changed sql.Open("sqlite3", path) to sql.Open("libsql", ...) with proper DSN
1414-88+// Modifications:
99+// - Replaced mattn/go-sqlite3 driver with go-libsql
1010+// - Removed all Prometheus metric counters and .Inc() calls
1111+// - Changed package from 'carstore' to 'db'
1212+// - Added NewSQLiteStoreWithDB constructor for injecting an existing *sql.DB
1313+// - Changed sql.Open("sqlite3", path) to sql.Open("libsql", ...) with proper DSN
1514package db
16151716import (