···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- 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
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+ Labeler LabelerRefConfig `yaml:"labeler" comment:"ATProto labeler for content moderation (DMCA takedowns)."`
3737+ Billing billing.Config `yaml:"billing" comment:"Stripe billing integration (requires -tags billing build)."`
3838+ Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility
3739}
38403941// ServerConfig defines server settings
···126128 // ServiceName is the service name used for JWT issuer and service fields.
127129 // Derived from base URL hostname (e.g., "atcr.io")
128130 ServiceName string `yaml:"-"`
131131+}
132132+133133+// CredentialHelperConfig defines credential helper download settings
134134+type CredentialHelperConfig struct {
135135+ // TangledRepo is the Tangled repository URL for downloads
136136+ TangledRepo string `yaml:"tangled_repo" comment:"Tangled repository URL for credential helper downloads."`
129137}
130138131139// LegalConfig defines legal page customization for self-hosted instances
···143151 APIKey string `yaml:"api_key" comment:"Anthropic API key for AI Image Advisor. Also reads CLAUDE_API_KEY env var as fallback."`
144152}
145153154154+// LabelerRefConfig defines the connection to an ATProto labeler service.
155155+type LabelerRefConfig struct {
156156+ // DID or URL of the labeler service for content moderation.
157157+ DID string `yaml:"did" comment:"DID or URL of the ATProto labeler (e.g., did:web:labeler.atcr.io). Empty disables label filtering."`
158158+}
159159+146160// setDefaults registers all default values on the given Viper instance.
147161func setDefaults(v *viper.Viper) {
148162 v.SetDefault("version", "0.1")
···200214 v.SetDefault("legal.company_name", "")
201215 v.SetDefault("legal.jurisdiction", "")
202216217217+ // Labeler defaults
218218+ v.SetDefault("labeler.did", "")
219219+203220 // Log formatter (used by distribution config, not in Config struct)
204221 v.SetDefault("log_formatter", "text")
205222}
···255272 // Post-load: fixed values
256273 cfg.Auth.TokenExpiration = 5 * time.Minute
257274 cfg.Auth.ServiceName = deriveServiceName(cfg)
275275+ cfg.CredentialHelper.TangledRepo = "https://tangled.org/evan.jarrett.net/at-container-registry"
258276259277 // Post-load: CompanyName defaults to ClientName
260278 if cfg.Legal.CompanyName == "" {
+79
pkg/appview/db/labels.go
···11+package db
22+33+import (
44+ "database/sql"
55+ "time"
66+)
77+88+// LabelChecker wraps a database connection to check takedown labels.
99+// Implements middleware.LabelChecker interface.
1010+type LabelChecker struct {
1111+ db *sql.DB
1212+}
1313+1414+// NewLabelChecker creates a new LabelChecker.
1515+func NewLabelChecker(database *sql.DB) *LabelChecker {
1616+ return &LabelChecker{db: database}
1717+}
1818+1919+// IsTakenDown checks if a (DID, repository) pair has an active takedown label.
2020+func (lc *LabelChecker) IsTakenDown(did, repository string) (bool, error) {
2121+ return IsTakenDown(lc.db, did, repository)
2222+}
2323+2424+// Label represents an ATProto label mirrored from a labeler service.
2525+type Label struct {
2626+ ID int64
2727+ Src string
2828+ URI string
2929+ Val string
3030+ Neg bool
3131+ Cts time.Time
3232+ SubjectDID string
3333+ SubjectRepo string
3434+ Seq int64
3535+}
3636+3737+// IsTakenDown checks if a (DID, repository) pair has an active !takedown label.
3838+// Also matches user-level labels (subject_repo = ”).
3939+func IsTakenDown(db DBTX, did, repository string) (bool, error) {
4040+ var exists bool
4141+ err := db.QueryRow(
4242+ `SELECT EXISTS(
4343+ SELECT 1 FROM labels l1
4444+ WHERE l1.subject_did = ?
4545+ AND (l1.subject_repo = ? OR l1.subject_repo = '')
4646+ AND l1.val = '!takedown' AND l1.neg = 0
4747+ AND NOT EXISTS (
4848+ SELECT 1 FROM labels l2
4949+ WHERE l2.src = l1.src AND l2.uri = l1.uri AND l2.val = l1.val
5050+ AND l2.neg = 1 AND l2.id > l1.id
5151+ )
5252+ AND (l1.exp IS NULL OR l1.exp > CURRENT_TIMESTAMP)
5353+ )`,
5454+ did, repository,
5555+ ).Scan(&exists)
5656+ return exists, err
5757+}
5858+5959+// UpsertLabel inserts or updates a label from a labeler subscription.
6060+func UpsertLabel(db DBTX, l *Label) error {
6161+ _, err := db.Exec(
6262+ `INSERT INTO labels (src, uri, val, neg, cts, subject_did, subject_repo, seq)
6363+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
6464+ ON CONFLICT(src, uri, val, neg) DO UPDATE SET cts = excluded.cts, seq = excluded.seq`,
6565+ l.Src, l.URI, l.Val, l.Neg, l.Cts.UTC().Format(time.RFC3339),
6666+ l.SubjectDID, l.SubjectRepo, l.Seq,
6767+ )
6868+ return err
6969+}
7070+7171+// GetLabelCursor returns the latest sequence number for a given labeler source.
7272+func GetLabelCursor(db DBTX, src string) (int64, error) {
7373+ var cursor int64
7474+ err := db.QueryRow(
7575+ `SELECT COALESCE(MAX(seq), 0) FROM labels WHERE src = ?`,
7676+ src,
7777+ ).Scan(&cursor)
7878+ return cursor, err
7979+}
+16
pkg/appview/db/migrations/0017_create_labels.yaml
···11+description: Create labels table for ATProto content moderation (takedowns)
22+query: |
33+ CREATE TABLE IF NOT EXISTS labels (
44+ id INTEGER PRIMARY KEY AUTOINCREMENT,
55+ src TEXT NOT NULL,
66+ uri TEXT NOT NULL,
77+ val TEXT NOT NULL,
88+ neg BOOLEAN NOT NULL DEFAULT 0,
99+ cts TIMESTAMP NOT NULL,
1010+ subject_did TEXT NOT NULL,
1111+ subject_repo TEXT NOT NULL DEFAULT '',
1212+ seq INTEGER NOT NULL DEFAULT 0,
1313+ UNIQUE(src, uri, val, neg)
1414+ );
1515+ CREATE INDEX IF NOT EXISTS idx_labels_subject ON labels(subject_did, subject_repo);
1616+ CREATE INDEX IF NOT EXISTS idx_labels_val ON labels(val);
+16-1
pkg/appview/db/queries.go
···9999 SELECT DISTINCT lm.did, lm.repository, lm.latest_id
100100 FROM latest_manifests lm
101101 JOIN users u ON lm.did = u.did
102102- WHERE u.handle LIKE ? ESCAPE '\'
102102+ WHERE (u.handle LIKE ? ESCAPE '\'
103103 OR u.did = ?
104104 OR lm.repository LIKE ? ESCAPE '\'
105105 OR EXISTS (
106106 SELECT 1 FROM repository_annotations ra
107107 WHERE ra.did = lm.did AND ra.repository = lm.repository
108108 AND ra.value LIKE ? ESCAPE '\'
109109+ ))
110110+ AND NOT EXISTS (
111111+ SELECT 1 FROM labels
112112+ WHERE (subject_did = lm.did AND (subject_repo = lm.repository OR subject_repo = ''))
113113+ AND val = '!takedown' AND neg = 0
109114 )
110115 ),
111116 repo_stats AS (
···21172122 JOIN users u ON m.did = u.did
21182123 LEFT JOIN repository_stats rs ON m.did = rs.did AND m.repository = rs.repository
21192124 LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository
21252125+ WHERE NOT EXISTS (
21262126+ SELECT 1 FROM labels
21272127+ WHERE (subject_did = m.did AND (subject_repo = m.repository OR subject_repo = ''))
21282128+ AND val = '!takedown' AND neg = 0
21292129+ )
21202130 ORDER BY ` + orderBy + `
21212131 LIMIT ?
21222132 `
···21952205 JOIN users u ON m.did = u.did
21962206 LEFT JOIN repository_stats rs ON m.did = rs.did AND m.repository = rs.repository
21972207 LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository
22082208+ WHERE NOT EXISTS (
22092209+ SELECT 1 FROM labels
22102210+ WHERE (subject_did = m.did AND (subject_repo = m.repository OR subject_repo = ''))
22112211+ AND val = '!takedown' AND neg = 0
22122212+ )
21982213 ORDER BY MAX(rs.last_push, m.created_at) DESC
21992214 `
22002215
+15
pkg/appview/db/schema.sql
···298298 suggestions_json TEXT NOT NULL,
299299 created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
300300);
301301+302302+CREATE TABLE IF NOT EXISTS labels (
303303+ id INTEGER PRIMARY KEY AUTOINCREMENT,
304304+ src TEXT NOT NULL,
305305+ uri TEXT NOT NULL,
306306+ val TEXT NOT NULL,
307307+ neg BOOLEAN NOT NULL DEFAULT 0,
308308+ cts TIMESTAMP NOT NULL,
309309+ subject_did TEXT NOT NULL,
310310+ subject_repo TEXT NOT NULL DEFAULT '',
311311+ seq INTEGER NOT NULL DEFAULT 0,
312312+ UNIQUE(src, uri, val, neg)
313313+);
314314+CREATE INDEX IF NOT EXISTS idx_labels_subject ON labels(subject_did, subject_repo);
315315+CREATE INDEX IF NOT EXISTS idx_labels_val ON labels(val);
···4444 return
4545 }
46464747+ // Check for takedown labels
4848+ if taken, _ := db.IsTakenDown(h.ReadOnlyDB, did, repository); taken {
4949+ RenderNotFound(w, r, &h.BaseUIHandler)
5050+ return
5151+ }
5252+4753 // Look up user by DID
4854 owner, err := db.GetUserByDID(h.ReadOnlyDB, did)
4955 if err != nil {
+34
pkg/appview/jetstream/processor.go
···224224 }
225225 }
226226227227+ // Skip ingestion for taken-down content
228228+ if !isDelete && data != nil {
229229+ if repo := extractRepoFromRecord(collection, data); repo != "" {
230230+ if taken, _ := db.IsTakenDown(p.db, did, repo); taken {
231231+ slog.Debug("Skipping taken-down content",
232232+ "component", "processor",
233233+ "did", did,
234234+ "collection", collection,
235235+ "repository", repo)
236236+ return nil
237237+ }
238238+ }
239239+ }
240240+227241 // User-activity collections create/update user entries
228242 // Skip for deletes - user should already exist, and we don't need to resolve identity
229243 if !isDelete {
···1015102910161030 return nil
10171031}
10321032+10331033+// extractRepoFromRecord extracts the repository field from a record's JSON data.
10341034+// Returns empty string for collections that don't have a repository field
10351035+// (e.g., sailor profile, captain, crew).
10361036+func extractRepoFromRecord(collection string, data []byte) string {
10371037+ switch collection {
10381038+ case atproto.ManifestCollection,
10391039+ atproto.TagCollection,
10401040+ atproto.RepoPageCollection,
10411041+ atproto.StatsCollection,
10421042+ atproto.ScanCollection:
10431043+ var rec struct {
10441044+ Repository string `json:"repository"`
10451045+ }
10461046+ if err := json.Unmarshal(data, &rec); err == nil {
10471047+ return rec.Repository
10481048+ }
10491049+ }
10501050+ return ""
10511051+}
+239
pkg/appview/labeler/subscriber.go
···11+// Package labeler provides a subscription client for consuming labels
22+// from an ATProto labeler service.
33+package labeler
44+55+import (
66+ "database/sql"
77+ "encoding/json"
88+ "fmt"
99+ "log/slog"
1010+ "net/url"
1111+ "strings"
1212+ "time"
1313+1414+ "atcr.io/pkg/appview/db"
1515+1616+ "github.com/gorilla/websocket"
1717+)
1818+1919+// LabelsMessage is the wire format for subscribeLabels events.
2020+type LabelsMessage struct {
2121+ Seq int64 `json:"seq"`
2222+ Labels []LabelEvent `json:"labels"`
2323+}
2424+2525+// LabelEvent is a single label from the labeler.
2626+type LabelEvent struct {
2727+ Src string `json:"src"`
2828+ URI string `json:"uri"`
2929+ CID string `json:"cid,omitempty"`
3030+ Val string `json:"val"`
3131+ Neg bool `json:"neg"`
3232+ Cts string `json:"cts"`
3333+ Exp string `json:"exp,omitempty"`
3434+}
3535+3636+// Subscriber connects to a labeler's subscribeLabels endpoint
3737+// and mirrors labels into the appview database.
3838+type Subscriber struct {
3939+ labelerURL string
4040+ database *sql.DB
4141+ stopCh chan struct{}
4242+}
4343+4444+// NewSubscriber creates a new labeler subscriber.
4545+func NewSubscriber(labelerURL string, database *sql.DB) *Subscriber {
4646+ return &Subscriber{
4747+ labelerURL: labelerURL,
4848+ database: database,
4949+ stopCh: make(chan struct{}),
5050+ }
5151+}
5252+5353+// Start begins the subscription loop in a goroutine.
5454+func (s *Subscriber) Start() {
5555+ go s.run()
5656+}
5757+5858+// Stop signals the subscriber to shut down.
5959+func (s *Subscriber) Stop() {
6060+ close(s.stopCh)
6161+}
6262+6363+func (s *Subscriber) run() {
6464+ backoff := time.Second
6565+6666+ for {
6767+ select {
6868+ case <-s.stopCh:
6969+ return
7070+ default:
7171+ }
7272+7373+ if err := s.connect(); err != nil {
7474+ slog.Warn("Labeler subscription error, reconnecting",
7575+ "error", err,
7676+ "backoff", backoff,
7777+ )
7878+ select {
7979+ case <-s.stopCh:
8080+ return
8181+ case <-time.After(backoff):
8282+ }
8383+ if backoff < 30*time.Second {
8484+ backoff *= 2
8585+ }
8686+ } else {
8787+ backoff = time.Second
8888+ }
8989+ }
9090+}
9191+9292+func (s *Subscriber) connect() error {
9393+ // Get cursor from DB
9494+ // Use the labeler URL as src identifier
9595+ labelerDID := extractDIDFromURL(s.labelerURL)
9696+ cursor, err := db.GetLabelCursor(s.database, labelerDID)
9797+ if err != nil {
9898+ return fmt.Errorf("failed to get cursor: %w", err)
9999+ }
100100+101101+ // Build WebSocket URL
102102+ wsURL := toWebSocketURL(s.labelerURL) + "/xrpc/com.atproto.label.subscribeLabels"
103103+ if cursor > 0 {
104104+ wsURL += fmt.Sprintf("?cursor=%d", cursor)
105105+ }
106106+107107+ slog.Info("Connecting to labeler", "url", wsURL, "cursor", cursor)
108108+109109+ conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
110110+ if err != nil {
111111+ return fmt.Errorf("websocket dial failed: %w", err)
112112+ }
113113+ defer conn.Close()
114114+115115+ slog.Info("Connected to labeler", "url", s.labelerURL)
116116+117117+ for {
118118+ select {
119119+ case <-s.stopCh:
120120+ return nil
121121+ default:
122122+ }
123123+124124+ var msg LabelsMessage
125125+ if err := conn.ReadJSON(&msg); err != nil {
126126+ return fmt.Errorf("read error: %w", err)
127127+ }
128128+129129+ for _, le := range msg.Labels {
130130+ cts, _ := time.Parse(time.RFC3339, le.Cts)
131131+ did, repo := extractSubjectFromURI(le.URI)
132132+133133+ label := &db.Label{
134134+ Src: le.Src,
135135+ URI: le.URI,
136136+ Val: le.Val,
137137+ Neg: le.Neg,
138138+ Cts: cts,
139139+ SubjectDID: did,
140140+ SubjectRepo: repo,
141141+ Seq: msg.Seq,
142142+ }
143143+144144+ if err := db.UpsertLabel(s.database, label); err != nil {
145145+ slog.Warn("Failed to upsert label", "uri", le.URI, "error", err)
146146+ continue
147147+ }
148148+149149+ slog.Info("Mirrored label",
150150+ "uri", le.URI,
151151+ "val", le.Val,
152152+ "neg", le.Neg,
153153+ "subject_did", did,
154154+ "subject_repo", repo,
155155+ )
156156+ }
157157+ }
158158+}
159159+160160+// extractSubjectFromURI extracts the DID and repository from an AT URI.
161161+// Examples:
162162+//
163163+// at://did:plc:xyz → (did:plc:xyz, "")
164164+// at://did:plc:xyz/io.atcr.manifest/abc → (did:plc:xyz, "") - repo extracted from record
165165+// at://did:plc:xyz/io.atcr.repo/myimage → (did:plc:xyz, "myimage")
166166+func extractSubjectFromURI(uri string) (did, repo string) {
167167+ trimmed := strings.TrimPrefix(uri, "at://")
168168+ parts := strings.SplitN(trimmed, "/", 3)
169169+ if len(parts) == 0 {
170170+ return "", ""
171171+ }
172172+ did = parts[0]
173173+174174+ // For repo-level summary labels: at://did/io.atcr.repo/reponame
175175+ if len(parts) >= 3 && parts[1] == "io.atcr.repo" {
176176+ repo = parts[2]
177177+ }
178178+ return did, repo
179179+}
180180+181181+// extractDIDFromURL derives a did:web from a labeler URL.
182182+func extractDIDFromURL(labelerURL string) string {
183183+ u, err := url.Parse(labelerURL)
184184+ if err != nil {
185185+ return labelerURL
186186+ }
187187+ host := u.Hostname()
188188+ if port := u.Port(); port != "" {
189189+ host += "%3A" + port
190190+ }
191191+ return "did:web:" + host
192192+}
193193+194194+// toWebSocketURL converts an HTTP URL to a WebSocket URL.
195195+func toWebSocketURL(httpURL string) string {
196196+ u, err := url.Parse(httpURL)
197197+ if err != nil {
198198+ return httpURL
199199+ }
200200+ switch u.Scheme {
201201+ case "https":
202202+ u.Scheme = "wss"
203203+ default:
204204+ u.Scheme = "ws"
205205+ }
206206+ return u.String()
207207+}
208208+209209+// ParseLabelerURL parses a labeler DID or URL into an HTTP URL.
210210+func ParseLabelerURL(labelerDIDOrURL string) string {
211211+ if strings.HasPrefix(labelerDIDOrURL, "http://") || strings.HasPrefix(labelerDIDOrURL, "https://") {
212212+ return labelerDIDOrURL
213213+ }
214214+ if strings.HasPrefix(labelerDIDOrURL, "did:web:") {
215215+ host := strings.TrimPrefix(labelerDIDOrURL, "did:web:")
216216+ host = strings.ReplaceAll(host, "%3A", ":")
217217+ return "https://" + host
218218+ }
219219+ return labelerDIDOrURL
220220+}
221221+222222+// SubscriberFromConfig creates a Subscriber from a labeler DID/URL config value.
223223+// Returns nil if labelerDIDOrURL is empty.
224224+func SubscriberFromConfig(labelerDIDOrURL string, database *sql.DB) *Subscriber {
225225+ if labelerDIDOrURL == "" {
226226+ return nil
227227+ }
228228+ labelerURL := ParseLabelerURL(labelerDIDOrURL)
229229+ return NewSubscriber(labelerURL, database)
230230+}
231231+232232+// DecodeLabelsFromJSON decodes a JSON-encoded labels message.
233233+func DecodeLabelsFromJSON(data []byte) (*LabelsMessage, error) {
234234+ var msg LabelsMessage
235235+ if err := json.Unmarshal(data, &msg); err != nil {
236236+ return nil, err
237237+ }
238238+ return &msg, nil
239239+}
+21
pkg/appview/middleware/registry.go
···169169 return serviceToken, err
170170}
171171172172+// LabelChecker checks whether content has been taken down via ATProto labels.
173173+type LabelChecker interface {
174174+ IsTakenDown(did, repository string) (bool, error)
175175+}
176176+172177// Global variables for initialization only
173178// These are set by main.go during startup and copied into NamespaceResolver instances.
174179// After initialization, request handling uses the NamespaceResolver's instance fields.
···178183 globalAuthorizer auth.HoldAuthorizer
179184 globalWebhookDispatcher storage.PushWebhookDispatcher
180185 globalManifestRefChecker storage.ManifestReferenceChecker
186186+ globalLabelChecker LabelChecker
181187)
182188183189// SetGlobalRefresher sets the OAuth refresher instance during initialization
···195201// SetGlobalManifestRefChecker sets the manifest reference checker during initialization
196202func SetGlobalManifestRefChecker(checker storage.ManifestReferenceChecker) {
197203 globalManifestRefChecker = checker
204204+}
205205+206206+// SetGlobalLabelChecker sets the label checker instance during initialization
207207+func SetGlobalLabelChecker(checker LabelChecker) {
208208+ globalLabelChecker = checker
198209}
199210200211// SetGlobalAuthorizer sets the authorizer instance during initialization
···313324 }
314325315326 slog.Debug("Resolved identity", "component", "registry/middleware", "did", did, "pds", pdsEndpoint, "handle", handle)
327327+328328+ // Check for takedown labels before proceeding
329329+ if globalLabelChecker != nil {
330330+ if taken, _ := globalLabelChecker.IsTakenDown(did, imageName); taken {
331331+ return nil, errcode.Error{
332332+ Code: errcode.ErrorCodeDenied,
333333+ Message: "this repository has been removed due to a policy violation",
334334+ }
335335+ }
336336+ }
316337317338 // Query for hold DID - either user's hold or default hold service
318339 // Also returns the sailor profile so we can read preferences (e.g. AutoRemoveUntagged)
+13
pkg/appview/server.go
···2424 "atcr.io/pkg/appview/db"
2525 "atcr.io/pkg/appview/holdhealth"
2626 "atcr.io/pkg/appview/jetstream"
2727+ appviewlabeler "atcr.io/pkg/appview/labeler"
2728 "atcr.io/pkg/appview/middleware"
2829 "atcr.io/pkg/appview/readme"
2930 "atcr.io/pkg/appview/routes"
···239240 middleware.SetGlobalDatabase(holdDIDDB)
240241 middleware.SetGlobalManifestRefChecker(holdDIDDB)
241242243243+ // Set label checker for takedown filtering
244244+ middleware.SetGlobalLabelChecker(db.NewLabelChecker(s.Database))
245245+242246 // Create RemoteHoldAuthorizer for hold authorization with caching
243247 s.HoldAuthorizer = auth.NewRemoteHoldAuthorizer(s.Database, testMode)
244248 middleware.SetGlobalAuthorizer(s.HoldAuthorizer)
···289293290294 // Initialize Jetstream workers
291295 s.initializeJetstream()
296296+297297+ // Initialize labeler subscriber
298298+ if cfg.Labeler.DID != "" {
299299+ sub := appviewlabeler.SubscriberFromConfig(cfg.Labeler.DID, s.Database)
300300+ if sub != nil {
301301+ sub.Start()
302302+ slog.Info("Labeler subscriber started", "labeler", cfg.Labeler.DID)
303303+ }
304304+ }
292305293306 // Create main chi router
294307 mainRouter := chi.NewRouter()