Files
at-container-registry/pkg/appview/server.go
T
Evan JarrettandClaude Opus 5 a7569a7717 registry: allow anonymous pull of public images
Credential-less pulls of public images. /auth/token issues a pull-only
token with an empty subject when no Basic auth is present; the
destination hold still enforces captain.Public, and push or delete always
challenges.

  - token.IsPullOnlyScope and AuthMethodAnonymous;
    Handler.issueAnonymousToken skips the authorizer gate and the
    service-auth pre-mint, since there is no identity to reconcile and no
    AppView-to-hold service token to bind. The token is still stamped
    with the resolved registry domain, so anonymous pull works on
    secondary front doors whose access controller demands their own
    audience.
  - auth.allow_anonymous_pull (default true) turns it fully off, restoring
    the previous always-challenge behavior. Mirrored into the deploy
    template, since the default means existing deploys pick this up.
  - RegistryContext.Anonymous is plumbed from the middleware.
  - ProxyBlobStore sends no Authorization header when the service token is
    empty, and returns 401 rather than 403 for anonymous denials so Docker
    prompts for credentials, including when a stale captain cache lets the
    request through and the hold says private.
  - BearerChallenge wraps the /v2/ subtree so a 401 raised deep in the
    stack via errcode.ServeJSON still carries WWW-Authenticate.
    Distribution's own scoped challenges are left alone.

IsPullOnlyScope allowlists the pull action instead of denylisting push and
delete. Distribution's actionSet.contains treats "*" as *every* action, so
a scope of `repository:victim/img:*` names neither denied string and would
have handed an unauthenticated caller a token valid for push and delete on
someone else's repository — clearing the authgate entirely, since anonymous
tokens deliberately skip it. Writes would still have failed further down
(no PDS credential), but the gate itself was bypassable. Now every
requested action must be exactly "pull". Covered by new claims tests.

Unresolvable identities return NAME_UNKNOWN instead of a bare error that
distribution renders as 500. This path was previously unreachable without
credentials; anonymous pull opens it to the internet, and a 5xx on
arbitrary input both misreports a bad request as a server fault and sends
clients that retry 5xx into a retry loop. That loop was real: in the auth
matrix, regclient spent 83s on a single case before this fix, and the
suite now runs in 5s.

Stat preserves an authorization verdict from getPresignedURL rather than
flattening it to ErrBlobUnknown. Distribution calls Stat before ServeBlob
on GET and HEAD, so without this an anonymous pull from a private hold
answered 404 and BearerChallenge had no 401 to annotate — the 401 path
above could never actually reach a client.

The auth matrix is updated to match: anonymous pull of the seeded public
repo now succeeds, anonymous push is denied against a real identity's
namespace (rather than an unresolvable one, which was testing name
resolution rather than authorization), and a new case pins the
NAME_UNKNOWN behavior for an unknown identity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 21:14:58 -05:00

1001 lines
36 KiB
Go

package appview
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"html/template"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"slices"
"strings"
"syscall"
"time"
"github.com/distribution/distribution/v3/registry/api/errcode"
"github.com/distribution/distribution/v3/registry/handlers"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"atcr.io/pkg/appview/authgate"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/holdhealth"
"atcr.io/pkg/appview/jetstream"
appviewlabeler "atcr.io/pkg/appview/labeler"
"atcr.io/pkg/appview/middleware"
"atcr.io/pkg/appview/readme"
"atcr.io/pkg/appview/registryauth"
"atcr.io/pkg/appview/routes"
"atcr.io/pkg/appview/storage"
"atcr.io/pkg/appview/webhooks"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
"atcr.io/pkg/auth/oauth"
"atcr.io/pkg/auth/token"
"atcr.io/pkg/billing"
"atcr.io/pkg/logging"
"github.com/bluesky-social/indigo/atproto/atcrypto"
indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
)
// OAuthPostAuthHook is called after the default OAuth post-auth logic
// (profile creation, avatar fetch, crew registration). Hooks added after
// NewAppViewServer but before the first request work correctly.
type OAuthPostAuthHook func(ctx context.Context, did, handle, pdsEndpoint, sessionID string) error
// TokenPostAuthHook is called after the default token post-auth logic
// (profile creation). Hooks added after NewAppViewServer but before the
// first request work correctly.
type TokenPostAuthHook func(ctx context.Context, did, handle, pdsEndpoint, accessToken string) error
// AppViewServer is the AppView service with an exposed router for extensibility.
// Consumers can add routes to Router and hooks before calling Serve().
type AppViewServer struct {
// Router is the chi router. Add routes before calling Serve().
Router chi.Router
// Config is the AppView configuration.
Config *Config
// Database is the read-write SQLite database.
Database *sql.DB
// ReadOnlyDB is the read-only SQLite database connection.
ReadOnlyDB *sql.DB
// SessionStore manages web UI sessions.
SessionStore *db.SessionStore
// DeviceStore manages device authorization flows.
DeviceStore *db.DeviceStore
// OAuthStore manages OAuth session persistence.
OAuthStore *db.OAuthStore
// OAuthServer handles OAuth authorization and callback endpoints.
OAuthServer *oauth.Server
// OAuthClientApp is the indigo OAuth client application.
OAuthClientApp *indigooauth.ClientApp
// Refresher manages OAuth session refresh and caching.
Refresher *oauth.Refresher
// Templates are the parsed HTML templates.
Templates *template.Template
// HealthChecker checks hold service health.
HealthChecker *holdhealth.Checker
// ReadmeFetcher fetches README content for repository pages.
ReadmeFetcher *readme.Fetcher
// TokenIssuer issues registry JWTs (nil if auth is not configured).
TokenIssuer *token.Issuer
// HoldAuthorizer checks hold access permissions.
HoldAuthorizer auth.HoldAuthorizer
// OAuthKey is the P-256 private key used for OAuth client auth and appview service identity.
OAuthKey *atcrypto.PrivateKeyP256
// BillingManager handles Stripe billing and tier updates (nil if billing disabled).
BillingManager *billing.Manager
// WebhookDispatcher dispatches scan webhooks (stored in appview DB).
WebhookDispatcher *webhooks.Dispatcher
// Private fields for lifecycle management
oauthHooks []OAuthPostAuthHook
tokenHooks []TokenPostAuthHook
httpServer *http.Server
healthWorker *holdhealth.Worker
workerCancel context.CancelFunc
branding *BrandingOverrides
}
// AddOAuthPostAuthHook registers a hook that runs after the default OAuth
// post-auth logic. Multiple hooks run in registration order.
func (s *AppViewServer) AddOAuthPostAuthHook(hook OAuthPostAuthHook) {
s.oauthHooks = append(s.oauthHooks, hook)
}
// AddTokenPostAuthHook registers a hook that runs after the default token
// post-auth logic. Multiple hooks run in registration order.
func (s *AppViewServer) AddTokenPostAuthHook(hook TokenPostAuthHook) {
s.tokenHooks = append(s.tokenHooks, hook)
}
// NewAppViewServer creates a fully-initialized AppView server ready for the
// consumer to add routes and hooks before calling Serve(). Pass nil for
// branding to use default atcr.io assets and templates.
func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer, error) {
// Initialize structured logging with optional remote shipping
logging.InitLoggerWithShipper(cfg.LogLevel, logging.ShipperConfig{
Backend: cfg.LogShipper.Backend,
URL: cfg.LogShipper.URL,
BatchSize: cfg.LogShipper.BatchSize,
FlushInterval: cfg.LogShipper.FlushInterval,
Service: "appview",
Username: cfg.LogShipper.Username,
Password: cfg.LogShipper.Password,
})
slog.Info("Configuration loaded successfully from environment")
if cfg.AI.APIKey != "" {
slog.Info("AI Image Advisor enabled")
}
s := &AppViewServer{
Config: cfg,
branding: branding,
}
// Initialize UI database (required for all stores)
slog.Info("Initializing UI database", "path", cfg.UI.DatabasePath)
libsqlCfg := db.LibsqlConfig{
SyncURL: cfg.UI.LibsqlSyncURL,
AuthToken: cfg.UI.LibsqlAuthToken,
SyncInterval: cfg.UI.LibsqlSyncInterval,
}
s.Database, s.ReadOnlyDB, s.SessionStore = db.InitializeDatabase(cfg.UI.DatabasePath, libsqlCfg)
if s.Database == nil {
return nil, fmt.Errorf("failed to initialize UI database - required for session storage")
}
// Initialize hold health checker
slog.Info("Initializing hold health checker", "cache_ttl", cfg.Health.CacheTTL)
s.HealthChecker = holdhealth.NewChecker(cfg.Health.CacheTTL)
// Initialize README fetcher for rendering repo page descriptions
s.ReadmeFetcher = readme.NewFetcher()
// Start background health check worker
startupDelay := 5 * time.Second
dbAdapter := holdhealth.NewDBAdapter(s.Database)
s.healthWorker = holdhealth.NewWorkerWithStartupDelay(s.HealthChecker, dbAdapter, cfg.Health.CheckInterval, startupDelay)
workerCtx, workerCancel := context.WithCancel(context.Background())
s.workerCancel = workerCancel
s.healthWorker.Start(workerCtx)
slog.Info("Hold health worker started", "startup_delay", startupDelay, "refresh_interval", cfg.Health.CheckInterval, "cache_ttl", cfg.Health.CacheTTL)
// Initialize OAuth components
slog.Info("Initializing OAuth components")
s.OAuthStore = db.NewOAuthStore(s.Database)
slog.Info("Using SQLite for OAuth session storage")
s.DeviceStore = db.NewDeviceStore(s.Database)
slog.Info("Using SQLite for device storage")
baseURL := cfg.Server.BaseURL
defaultHoldDID := cfg.Server.PrimaryHoldDID()
testMode := cfg.Server.TestMode
slog.Debug("Base URL for OAuth", "base_url", baseURL)
if testMode {
slog.Info("TEST_MODE enabled - will use HTTP for local DID resolution")
atproto.SetTestMode(true)
}
oauthKey, err := loadOAuthKey(s.Database)
if err != nil {
return nil, fmt.Errorf("failed to load OAuth key: %w", err)
}
s.OAuthKey = oauthKey
// Create OAuth client app
desiredScopes := oauth.GetDefaultScopes(defaultHoldDID)
s.OAuthClientApp, err = oauth.NewClientAppWithKey(baseURL, s.OAuthStore, desiredScopes, oauthKey, cfg.Server.ClientName)
if err != nil {
return nil, fmt.Errorf("failed to create OAuth client app: %w", err)
}
// Invalidate sessions with mismatched scopes on startup
invalidatedCount, err := s.OAuthStore.InvalidateSessionsWithMismatchedScopes(context.Background(), desiredScopes)
if err != nil {
slog.Warn("Failed to invalidate sessions with mismatched scopes", "error", err)
} else if invalidatedCount > 0 {
slog.Info("Invalidated OAuth sessions due to scope changes", "count", invalidatedCount)
}
// Create oauth token refresher
s.Refresher = oauth.NewRefresher(s.OAuthClientApp)
// Wire up UI session store to refresher
if s.SessionStore != nil {
s.Refresher.SetUISessionStore(s.SessionStore)
}
// Set global refresher for middleware
middleware.SetGlobalRefresher(s.Refresher)
// Set global database for hold DID lookups and manifest reference checks
holdDIDDB := db.NewHoldDIDDB(s.Database)
middleware.SetGlobalDatabase(holdDIDDB)
middleware.SetGlobalManifestRefChecker(holdDIDDB)
// Set label checker for takedown filtering
middleware.SetGlobalLabelChecker(db.NewLabelChecker(s.Database))
// Create RemoteHoldAuthorizer for hold authorization with caching
s.HoldAuthorizer = auth.NewRemoteHoldAuthorizer(s.Database, testMode)
middleware.SetGlobalAuthorizer(s.HoldAuthorizer)
slog.Info("Hold authorizer initialized with database caching")
// Clear all denial caches on startup for a clean slate
if remote, ok := s.HoldAuthorizer.(*auth.RemoteHoldAuthorizer); ok {
go func() {
if err := remote.ClearAllDenials(); err != nil {
slog.Warn("Failed to clear denial caches on startup", "error", err)
}
}()
}
// Initialize billing manager
appviewDID := DIDFromBaseURL(baseURL)
s.BillingManager = billing.New(
&cfg.Billing,
oauthKey,
appviewDID,
cfg.Server.ManagedHolds,
baseURL,
s.Database,
)
// Allow hold captains to bypass billing feature gates
if len(cfg.Server.ManagedHolds) > 0 {
managedHolds := cfg.Server.ManagedHolds
roDB := s.ReadOnlyDB
s.BillingManager.SetCaptainChecker(func(userDID string) bool {
isCaptain, _ := db.IsHoldCaptain(roDB, userDID, managedHolds)
return isCaptain
})
// Paid features require the user's active (default) hold to be a managed
// hold. An unset default hold means they use the operator's primary
// managed hold, so it counts as managed. Read the primary DB (not roDB):
// a hold switch writes default_hold_did to the primary, and the replica
// may lag, so reading roDB could keep paid features alive briefly after a
// switch to self-hosted.
primaryDB := s.Database
s.BillingManager.SetActiveHoldChecker(func(userDID string) bool {
holdDID := db.GetUserDefaultHoldDID(primaryDB, userDID)
return holdDID == "" || slices.Contains(managedHolds, holdDID)
})
}
if s.BillingManager.Enabled() {
// Fail closed: an empty Stripe webhook secret makes webhooks forgeable
// (Stripe HMACs with the empty key, which an attacker can reproduce).
if !s.BillingManager.WebhookConfigured() {
return nil, fmt.Errorf("billing is enabled but STRIPE_WEBHOOK_SECRET is not set; refusing to start with a forgeable webhook endpoint")
}
slog.Info("Billing enabled", "appview_did", appviewDID, "managed_holds", len(cfg.Server.ManagedHolds))
go s.BillingManager.RefreshHoldTiers()
}
// Create webhook dispatcher
appviewMeta := atproto.AppviewMetadata{
ClientName: cfg.Server.ClientName,
ClientShortName: cfg.Server.ClientShortName,
BaseURL: cfg.Server.BaseURL,
FaviconURL: cfg.Server.BaseURL + "/favicon-96x96.png",
RegistryDomains: cfg.Server.RegistryDomains,
}
// Gate dispatch on the same entitlement the creation form uses, so webhooks
// stop firing paid behavior when a user loses entitlement (switches to a
// self-hosted hold or downgrades) after creating them.
s.WebhookDispatcher = webhooks.NewDispatcher(s.Database, appviewMeta, s.BillingManager.GetWebhookLimits)
middleware.SetGlobalWebhookDispatcher(s.WebhookDispatcher)
// Initialize Jetstream workers
s.initializeJetstream()
// Initialize labeler subscriber
if cfg.Labeler.DID != "" {
sub := appviewlabeler.SubscriberFromConfig(cfg.Labeler.DID, s.Database)
if sub != nil {
sub.Start()
slog.Info("Labeler subscriber started", "labeler", cfg.Labeler.DID)
}
}
// Create main chi router
mainRouter := chi.NewRouter()
mainRouter.Use(chimiddleware.RealIP)
mainRouter.Use(chimiddleware.Logger)
mainRouter.Use(chimiddleware.Recoverer)
mainRouter.Use(chimiddleware.GetHead)
mainRouter.Use(routes.CORSMiddleware())
// Vanity import prefix must match the `module` line in go.mod exactly,
// otherwise `go install` fails the module-path check.
mainRouter.Use(middleware.GoImport("atcr.io", cfg.UI.SourceURL))
// Domain routing middleware. Gated on the raw config (an empty
// registry_domains means single-domain, where /v2/ must stay on the UI
// host) but fed cfg.Auth.Services, which is the same list normalized. The
// middleware matches a port-stripped host, so a domain configured with a
// port could never match its own requests when compared raw; sharing the
// normalized list also keeps routing and the access controller agreeing on
// exactly one set of names.
if len(cfg.Server.RegistryDomains) > 0 {
mainRouter.Use(DomainRoutingMiddleware(cfg.Auth.Services, cfg.Server.BaseURL))
slog.Info("Domain routing middleware enabled",
"registry_domains", cfg.Auth.Services,
"ui_base_url", cfg.Server.BaseURL)
}
// Load templates
ComputeAssetHashes(branding)
s.Templates, err = Templates(branding)
if err != nil {
return nil, fmt.Errorf("failed to load UI templates: %w", err)
}
// Register UI routes
routes.RegisterUIRoutes(mainRouter, routes.UIDependencies{
Database: s.Database,
ReadOnlyDB: s.ReadOnlyDB,
SessionStore: s.SessionStore,
OAuthClientApp: s.OAuthClientApp,
OAuthStore: s.OAuthStore,
Refresher: s.Refresher,
BaseURL: baseURL,
RegistryDomain: primaryRegistryDomain(cfg.Server.RegistryDomains),
RegistryDomains: cfg.Server.RegistryDomains,
DeviceStore: s.DeviceStore,
HealthChecker: s.HealthChecker,
ReadmeFetcher: s.ReadmeFetcher,
Templates: s.Templates,
DefaultHoldDID: defaultHoldDID,
ManagedHolds: cfg.Server.ManagedHolds,
ClientName: cfg.Server.ClientName,
ClientShortName: cfg.Server.ClientShortName,
BillingManager: s.BillingManager,
WebhookDispatcher: s.WebhookDispatcher,
ClaudeAPIKey: cfg.AI.APIKey,
SourceURL: cfg.UI.SourceURL,
LegalConfig: routes.LegalConfig{
CompanyName: cfg.Legal.CompanyName,
Jurisdiction: cfg.Legal.Jurisdiction,
},
})
// Register Stripe webhook route (if billing enabled)
s.BillingManager.RegisterRoutes(mainRouter)
// Create OAuth server
s.OAuthServer = oauth.NewServer(s.OAuthClientApp)
s.OAuthServer.SetRefresher(s.Refresher)
if s.SessionStore != nil {
s.OAuthServer.SetUISessionStore(s.SessionStore)
}
// Register OAuth post-auth callback (closure captures s for hook dispatch)
s.OAuthServer.SetPostAuthCallback(func(ctx context.Context, did, handle, pdsEndpoint, sessionID string) error {
slog.Debug("OAuth post-auth callback", "component", "appview/callback", "did", did)
// Create ATProto client with session provider
client := atproto.NewClientWithSessionProvider(pdsEndpoint, did, s.Refresher)
// Ensure sailor profile exists
slog.Debug("Ensuring profile exists", "component", "appview/callback", "did", did, "default_hold_did", defaultHoldDID)
if err := storage.EnsureProfile(ctx, client, defaultHoldDID); err != nil {
slog.Warn("Failed to ensure profile", "component", "appview/callback", "did", did, "error", err)
} else {
slog.Debug("Profile ensured", "component", "appview/callback", "did", did)
}
// Fetch user's profile record from PDS
profileRecord, err := client.GetProfileRecord(ctx, did)
if err != nil {
slog.Warn("Failed to fetch profile record", "component", "appview/callback", "did", did, "error", err)
profileRecord = nil
}
// Construct avatar URL from blob CID
avatarURL := ""
if profileRecord != nil && profileRecord.Avatar != nil && profileRecord.Avatar.Ref.Link != "" {
avatarURL = atproto.BlobCDNURL(did, profileRecord.Avatar.Ref.Link)
slog.Debug("Constructed avatar URL", "component", "appview/callback", "avatar_url", avatarURL)
}
// Store user in database
if avatarURL != "" {
err = db.UpsertUser(s.Database, &db.User{
DID: did,
Handle: handle,
PDSEndpoint: pdsEndpoint,
Avatar: avatarURL,
LastSeen: time.Now(),
})
} else {
err = db.UpsertUserIgnoreAvatar(s.Database, &db.User{
DID: did,
Handle: handle,
PDSEndpoint: pdsEndpoint,
Avatar: avatarURL,
LastSeen: time.Now(),
})
}
if err != nil {
slog.Warn("Failed to store user in database", "component", "appview/callback", "error", err)
return nil
}
slog.Debug("Stored user", "component", "appview/callback", "did", did, "has_avatar", avatarURL != "")
// Migrate profile URL→DID if needed
profile, err := storage.GetProfile(ctx, client)
if err != nil {
slog.Warn("Failed to get profile", "component", "appview/callback", "did", did, "error", err)
return nil
}
var holdDID string
if profile != nil && profile.DefaultHold != "" {
if strings.HasPrefix(profile.DefaultHold, "http://") || strings.HasPrefix(profile.DefaultHold, "https://") {
slog.Debug("Migrating hold URL to DID", "component", "appview/callback", "did", did, "hold_url", profile.DefaultHold)
if resolvedDID, resolveErr := atproto.ResolveHoldDID(ctx, profile.DefaultHold); resolveErr != nil {
slog.Warn("Failed to resolve hold DID from URL", "component", "appview/callback", "did", did, "hold_url", profile.DefaultHold, "error", resolveErr)
} else {
holdDID = resolvedDID
profile.DefaultHold = holdDID
if err := storage.UpdateProfile(ctx, client, profile); err != nil {
slog.Warn("Failed to update profile with hold DID", "component", "appview/callback", "did", did, "error", err)
} else {
slog.Debug("Updated profile with hold DID", "component", "appview/callback", "hold_did", holdDID)
}
}
} else {
holdDID = profile.DefaultHold
}
// Register crew in background
slog.Debug("Attempting crew registration", "component", "appview/callback", "did", did, "hold_did", holdDID)
go func(userDID, pdsEndpoint, holdDID string, refresher *oauth.Refresher, authorizer auth.HoldAuthorizer) {
ctx := context.Background()
storage.EnsureCrewMembership(ctx, userDID, holdDID, authorizer,
func(ctx context.Context, holdDID string) (string, error) {
return auth.GetOrFetchServiceToken(ctx, refresher, userDID, holdDID, pdsEndpoint)
})
}(did, pdsEndpoint, holdDID, s.Refresher, s.HoldAuthorizer)
}
// Drain manifests from old hold to successor in background
go func(client *atproto.Client, did string) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
storage.MigrateManifestsForSuccessor(ctx, client, s.HoldAuthorizer, db.NewHoldDIDDB(s.Database), did)
}(client, did)
// Run consumer hooks
for _, hook := range s.oauthHooks {
if err := hook(ctx, did, handle, pdsEndpoint, sessionID); err != nil {
slog.Warn("OAuth post-auth hook error", "component", "appview/callback", "error", err)
}
}
return nil
})
// Create token issuer
if cfg.Distribution.Auth[registryauth.AuthType] != nil {
rsaKey, certDER, err := loadJWTKeyAndCert(s.Database, cfg.Auth.CertPath)
if err != nil {
return nil, fmt.Errorf("failed to load JWT key material: %w", err)
}
s.TokenIssuer = token.NewIssuerFromKey(rsaKey, certDER, cfg.Auth.PrimaryService(), cfg.Auth.PrimaryService(), cfg.Auth.TokenExpiration)
slog.Info("Auth keys initialized")
}
// Create registry app (distribution library handler)
ctx := context.Background()
app := handlers.NewApp(ctx, cfg.Distribution)
// Wrap with auth method extraction middleware, then with the Retry-After
// emitter so it can read the carrier installed before deeper handlers run.
// Outermost is the Bearer challenge guard, so any 401 from deep in the stack
// (e.g. anonymous read of a private hold) still carries WWW-Authenticate.
wrappedApp := middleware.BearerChallenge(cfg.Server.BaseURL+"/auth/token", cfg.Auth.Services)(
middleware.RetryAfterMiddleware(middleware.ExtractAuthMethod(app)))
// Mount registry at /v2/
mainRouter.Handle("/v2/*", wrappedApp)
// Mount static files
if s.SessionStore != nil && s.Templates != nil {
publicHandler := CacheMiddleware(PublicHandler(branding), 31536000)
rootFiles, err := PublicRootFiles(branding)
if err != nil {
slog.Warn("Failed to scan static root files", "error", err)
} else {
for _, filename := range rootFiles {
file := filename
mainRouter.Get("/"+file, func(w http.ResponseWriter, r *http.Request) {
r.URL.Path = "/" + file
publicHandler.ServeHTTP(w, r)
})
}
slog.Info("Registered dynamic root file routes", "count", len(rootFiles), "files", rootFiles)
}
mainRouter.Handle("/css/*", CacheMiddleware(http.StripPrefix("/css/", PublicSubdir("css", branding)), 31536000))
mainRouter.Handle("/js/*", CacheMiddleware(http.StripPrefix("/js/", PublicSubdir("js", branding)), 31536000))
mainRouter.Handle("/fonts/*", CacheMiddleware(http.StripPrefix("/fonts/", PublicSubdir("fonts", branding)), 31536000))
mainRouter.Handle("/static/*", CacheMiddleware(http.StripPrefix("/static/", PublicSubdir("static", branding)), 31536000))
slog.Info("UI enabled", "home", "/", "settings", "/settings")
}
// Mount OAuth endpoints
mainRouter.Get("/auth/oauth/authorize", s.OAuthServer.ServeAuthorize)
mainRouter.Get("/auth/oauth/callback", s.OAuthServer.ServeCallback)
// OAuth client metadata endpoint
mainRouter.Get("/oauth-client-metadata.json", func(w http.ResponseWriter, r *http.Request) {
config := s.OAuthClientApp.Config
logoURI := cfg.Server.BaseURL + "/web-app-manifest-192x192.png"
policyURI := cfg.Server.BaseURL + "/privacy"
tosURI := cfg.Server.BaseURL + "/terms"
metadata := config.ClientMetadata()
metadata.ClientName = &cfg.Server.ClientName
metadata.ClientURI = &cfg.Server.BaseURL
metadata.LogoURI = &logoURI
metadata.PolicyURI = &policyURI
metadata.TosURI = &tosURI
if config.IsConfidential() && metadata.JWKS == nil {
jwks := config.PublicJWKS()
metadata.JWKS = &jwks
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Cache-Control", "public, max-age=300")
if err := json.NewEncoder(w).Encode(metadata); err != nil {
http.Error(w, "Failed to encode metadata", http.StatusInternalServerError)
}
})
// Mount auth endpoints
if s.TokenIssuer != nil {
tokenHandler := token.NewHandler(s.TokenIssuer, s.DeviceStore)
// Stamp each JWT with the registry domain the client is pushing to, so
// the audience names the front door actually used.
tokenHandler.SetServices(cfg.Auth.Services)
// Anonymous pull: when enabled, credential-less pull-only requests get a
// token with an empty subject; the destination hold still enforces
// captain.Public. Push always requires credentials.
tokenHandler.SetAllowAnonymousPull(cfg.Auth.AllowAnonymousPull)
tokenHandler.SetOAuthSessionValidator(s.Refresher)
// Auth-phase gate: crew reconciliation for any token request, plus
// hold membership + quota for non-wildcard push. Gating here means
// the JWT carries the authorization, so /v2/* doesn't need to
// re-check on every blob.
tokenHandler.SetAuthorizer(authgate.New(s.Database, s.HoldAuthorizer, s.Refresher, defaultHoldDID))
// Bind the registry JWT lifetime to the AppView↔hold service-auth.
// Pre-minting the service-auth here lets us stamp the JWT's exp
// from the cached expiry, so both expire concurrently.
tokenHandler.SetServiceAuthFetcher(authgate.NewServiceAuthFetcher(s.Database, s.Refresher, defaultHoldDID))
// Token post-auth callback (closure captures s for hook dispatch)
tokenHandler.SetPostAuthCallback(func(ctx context.Context, did, handle, pdsEndpoint, accessToken string) error {
slog.Debug("Token post-auth callback", "component", "appview/callback", "did", did)
atprotoClient := atproto.NewClient(pdsEndpoint, did, accessToken)
if err := storage.EnsureProfile(ctx, atprotoClient, defaultHoldDID); err != nil {
slog.Warn("Failed to ensure profile", "component", "appview/callback", "did", did, "error", err)
} else {
slog.Debug("Profile ensured with default hold", "component", "appview/callback", "did", did, "default_hold_did", defaultHoldDID)
}
// Crew enrollment is handled synchronously by authgate.Authorize on
// the same /auth/token request, which also warms the approval cache.
// No duplicate goroutine needed here.
// Run consumer hooks
for _, hook := range s.tokenHooks {
if err := hook(ctx, did, handle, pdsEndpoint, accessToken); err != nil {
slog.Warn("Token post-auth hook error", "component", "appview/callback", "error", err)
}
}
return nil
})
// Both Docker token specs are served on the same path: GET is the
// original Basic-auth form, POST is the OAuth2 form that containerd and
// Docker try first whenever they hold a secret. Serving only GET made
// every such client eat a 405 and retry.
mainRouter.Get("/auth/token", tokenHandler.ServeHTTP)
mainRouter.Post("/auth/token", tokenHandler.ServeHTTP)
// Device authorization endpoints (public)
routes.RegisterDeviceEndpoints(mainRouter, s.DeviceStore, baseURL)
slog.Info("Auth endpoints enabled",
"basic_auth", "/auth/token",
"device_code", "/auth/device/code",
"device_token", "/auth/device/token",
"oauth_authorize", "/auth/oauth/authorize",
"oauth_callback", "/auth/oauth/callback",
"oauth_metadata", "/oauth-client-metadata.json")
}
// Health check endpoint (for Docker health checks / load balancers)
mainRouter.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(map[string]string{"status": "ok"}); err != nil {
http.Error(w, "encode error", http.StatusInternalServerError)
return
}
})
// Appview metadata endpoint (public, used by holds for branding)
mainRouter.Get(atproto.AppviewGetMetadata, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=3600")
if err := json.NewEncoder(w).Encode(atproto.AppviewMetadata{
ClientName: cfg.Server.ClientName,
ClientShortName: cfg.Server.ClientShortName,
BaseURL: cfg.Server.BaseURL,
FaviconURL: cfg.Server.BaseURL + "/favicon-96x96.png",
RegistryDomains: cfg.Server.RegistryDomains,
}); err != nil {
http.Error(w, "encode error", http.StatusInternalServerError)
}
})
// Appview DID document endpoint (service identity for key discovery)
mainRouter.Get("/.well-known/did.json", s.handleDIDDocument)
s.Router = mainRouter
return s, nil
}
// Serve starts the HTTP server on the configured address and blocks until
// shutdown signal.
func (s *AppViewServer) Serve() error {
listener, err := net.Listen("tcp", s.Config.Server.Addr)
if err != nil {
return fmt.Errorf("failed to create listener: %w", err)
}
return s.ServeWithListener(listener)
}
// ServeWithListener starts the HTTP server on the provided listener and
// blocks until a shutdown signal arrives or Shutdown() is called. Tests use
// this to bind a 127.0.0.1:0 listener and learn the assigned port before
// driving requests.
func (s *AppViewServer) ServeWithListener(listener net.Listener) error {
s.httpServer = &http.Server{
Handler: s.Router,
}
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(stop)
serveErr := make(chan error, 1)
go func() {
slog.Info("Starting registry server", "addr", listener.Addr().String())
err := s.httpServer.Serve(listener)
if err != nil && err != http.ErrServerClosed {
serveErr <- err
return
}
serveErr <- nil
}()
select {
case <-stop:
slog.Info("Shutting down registry server")
if s.Config.Server.TestMode {
listener.Close()
}
slog.Info("Stopping hold health worker")
s.healthWorker.Stop()
if s.workerCancel != nil {
s.workerCancel()
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := s.httpServer.Shutdown(shutdownCtx); err != nil && err != http.ErrServerClosed {
logging.Shutdown()
return fmt.Errorf("server shutdown error: %w", err)
}
case err := <-serveErr:
s.healthWorker.Stop()
if s.workerCancel != nil {
s.workerCancel()
}
logging.Shutdown()
if err != nil {
return fmt.Errorf("server error: %w", err)
}
return nil
}
logging.Shutdown()
return nil
}
// Shutdown gracefully stops the HTTP server. Safe to call from tests that
// don't want to send SIGTERM. Production code goes through the signal handler
// inside ServeWithListener.
func (s *AppViewServer) Shutdown(ctx context.Context) error {
if s.httpServer == nil {
return nil
}
return s.httpServer.Shutdown(ctx)
}
// DomainRoutingMiddleware enforces three-tier domain routing:
//
// 1. UI domain (BaseURL hostname): serves web UI, auth, and static assets.
// Blocks /v2/* with an OCI UNSUPPORTED error — registry API lives on
// the dedicated registry domain(s).
// 2. Registry domains: allows /v2/* for Docker clients. Redirects everything
// else to the UI domain with 307 Temporary Redirect.
// 3. Unknown domains (CDN origins, IPs, etc.): redirects all requests to the
// UI domain with 307, except /health for load balancer probes.
func DomainRoutingMiddleware(registryDomains []string, uiBaseURL string) func(http.Handler) http.Handler {
regDomains := make(map[string]bool, len(registryDomains))
for _, d := range registryDomains {
regDomains[d] = true
}
// Extract UI hostname from BaseURL (e.g., "https://seamark.dev" -> "seamark.dev")
var uiHost string
if parsed, err := url.Parse(uiBaseURL); err == nil {
uiHost = parsed.Hostname()
}
primaryReg := primaryRegistryDomain(registryDomains)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := r.Host
if idx := strings.LastIndex(host, ":"); idx != -1 {
host = host[:idx]
}
path := r.URL.Path
isV2 := path == "/v2" || path == "/v2/" || strings.HasPrefix(path, "/v2/")
switch {
case host == uiHost:
// UI domain: block /v2/*, serve everything else
if isV2 {
if err := errcode.ServeJSON(w, errcode.ErrorCodeUnsupported.WithMessage(
fmt.Sprintf("registry API is not available on this domain, use %s", primaryReg),
)); err != nil {
slog.Error("failed to write OCI error response", "error", err)
}
return
}
next.ServeHTTP(w, r)
case regDomains[host]:
// Registry domain: allow /v2/*, /auth/token, /auth/device/*, redirect everything else
// Auth endpoints must be served directly to avoid 307 redirects that strip
// the Authorization header on cross-host redirects (Go http.Client behavior).
isAuth := path == "/auth/token" || strings.HasPrefix(path, "/auth/device/")
if isV2 || isAuth {
next.ServeHTTP(w, r)
return
}
http.Redirect(w, r, uiBaseURL+r.URL.RequestURI(), http.StatusTemporaryRedirect)
default:
// Unknown domain: allow /health, redirect everything else
if path == "/health" {
next.ServeHTTP(w, r)
return
}
http.Redirect(w, r, uiBaseURL+r.URL.RequestURI(), http.StatusTemporaryRedirect)
}
})
}
}
// primaryRegistryDomain returns the first registry domain, or empty string if none.
func primaryRegistryDomain(domains []string) string {
if len(domains) > 0 {
return domains[0]
}
return ""
}
// DID returns the appview's did:web identity derived from its BaseURL.
func (s *AppViewServer) DID() string {
return DIDFromBaseURL(s.Config.Server.BaseURL)
}
// DIDFromBaseURL derives a did:web identifier from a base URL.
// Per the did:web spec, non-standard ports are percent-encoded.
// Examples:
//
// "https://atcr.io" → "did:web:atcr.io"
// "http://localhost:5000" → "did:web:localhost%3A5000"
func DIDFromBaseURL(baseURL string) string {
u, err := url.Parse(baseURL)
if err != nil {
return "did:web:localhost"
}
hostname := u.Hostname()
if hostname == "" {
hostname = "localhost"
}
port := u.Port()
isStandardPort := (u.Scheme == "https" && port == "443") ||
(u.Scheme == "http" && port == "80") ||
port == ""
if isStandardPort {
return "did:web:" + hostname
}
return fmt.Sprintf("did:web:%s%%3A%s", hostname, port)
}
// handleDIDDocument serves the appview's DID document at /.well-known/did.json.
// This is a service identity for key discovery — no PDS, no repo, no firehose.
// Holds use this to discover the appview's P-256 public key for JWT verification.
func (s *AppViewServer) handleDIDDocument(w http.ResponseWriter, r *http.Request) {
did := s.DID()
pubKey, err := s.OAuthKey.PublicKey()
if err != nil {
slog.Error("Failed to get public key for DID document", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
doc := map[string]any{
"@context": []string{
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
},
"id": did,
"verificationMethod": []map[string]any{
{
"id": did + "#appview",
"type": "Multikey",
"controller": did,
"publicKeyMultibase": pubKey.Multibase(),
},
},
"authentication": []string{
did + "#appview",
},
"assertionMethod": []string{
did + "#appview",
},
"service": []map[string]any{
{
"id": "#atcr_appview",
"type": "AtcrAppView",
"serviceEndpoint": s.Config.Server.BaseURL,
},
},
}
w.Header().Set("Content-Type", "application/did+ld+json")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Header().Set("Access-Control-Allow-Origin", "*")
if err := json.NewEncoder(w).Encode(doc); err != nil {
slog.Error("Failed to encode DID document", "error", err)
}
}
// initializeJetstream initializes the Jetstream workers for real-time events and backfill.
func (s *AppViewServer) initializeJetstream() {
jetstreamURLs := s.Config.Jetstream.URLs
// Explicitly empty URLs disables Jetstream. The YAML config always
// populates a default list, so the only way to reach this branch is to
// blank the slice in code (tests, embedded deployments). Without this
// check, NewWorker silently falls back to the public Bluesky endpoint.
if len(jetstreamURLs) == 0 {
slog.Info("Jetstream disabled (no URLs configured)", "component", "jetstream")
return
}
go func() {
worker := jetstream.NewWorker(s.Database, jetstreamURLs, 0)
// Set webhook dispatcher on live worker (backfill skips dispatch)
if s.WebhookDispatcher != nil {
worker.Processor().SetWebhookDispatcher(s.WebhookDispatcher)
}
worker.StartWithFailover(context.Background())
}()
slog.Info("Jetstream real-time worker started", "component", "jetstream", "endpoints", len(jetstreamURLs))
if s.Config.Jetstream.BackfillEnabled {
relayEndpoints := s.Config.Jetstream.RelayEndpoints
defaultHoldDID := s.Config.Server.PrimaryHoldDID()
testMode := s.Config.Server.TestMode
backfillWorker, err := jetstream.NewBackfillWorker(s.Database, relayEndpoints, defaultHoldDID, testMode, s.Refresher)
if err != nil {
slog.Warn("Failed to create backfill worker", "component", "jetstream/backfill", "error", err)
} else {
go func() {
startupDelay := 5 * time.Second
slog.Info("Waiting for services to be ready", "component", "jetstream/backfill", "startup_delay", startupDelay)
time.Sleep(startupDelay)
slog.Info("Starting sync-based backfill", "component", "jetstream/backfill", "relay_endpoints", relayEndpoints)
if err := backfillWorker.Start(context.Background()); err != nil {
slog.Warn("Backfill finished with error", "component", "jetstream/backfill", "error", err)
} else {
slog.Info("Backfill completed successfully", "component", "jetstream/backfill")
}
}()
interval := s.Config.Jetstream.BackfillInterval
if interval > 0 {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
slog.Info("Starting periodic backfill", "component", "jetstream/backfill", "interval", interval)
if err := backfillWorker.Start(context.Background()); err != nil {
slog.Warn("Periodic backfill finished with error", "component", "jetstream/backfill", "error", err)
} else {
slog.Info("Periodic backfill completed successfully", "component", "jetstream/backfill")
}
}
}()
slog.Info("Periodic backfill scheduler started", "component", "jetstream/backfill", "interval", interval)
} else {
slog.Info("Periodic backfill disabled (interval=0), only startup backfill will run", "component", "jetstream/backfill")
}
}
}
}