A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

more billing/settings/webhook tweaks

evan.jarrett.net 7d74e767 08272197

verified
+633 -117
+4 -4
config-appview.example.yaml
··· 120 120 webhook_all_triggers: false 121 121 supporter_badge: false 122 122 - # Tier name. Position in list determines rank (0-based). 123 - name: deckhand 123 + name: Supporter 124 124 # Short description shown on the plan card. 125 125 description: Get started with basic storage 126 126 # List of features included in this tier. ··· 128 128 # Stripe price ID for monthly billing. Empty = free tier. 129 129 stripe_price_monthly: "" 130 130 # Stripe price ID for yearly billing. 131 - stripe_price_yearly: "" 131 + stripe_price_yearly: "price_1SmK1mRROAC4bYmSwhTQ7RY9" 132 132 # Maximum webhooks for this tier (-1 = unlimited). 133 133 max_webhooks: 1 134 134 # Allow all webhook trigger types (not just first-scan). ··· 141 141 # List of features included in this tier. 142 142 features: [] 143 143 # Stripe price ID for monthly billing. Empty = free tier. 144 - stripe_price_monthly: "" 144 + stripe_price_monthly: "price_1SmK4QRROAC4bYmSxpr35HUl" 145 145 # Stripe price ID for yearly billing. 146 - stripe_price_yearly: "" 146 + stripe_price_yearly: "price_1SmJuLRROAC4bYmSUgVCwZWo" 147 147 # Maximum webhooks for this tier (-1 = unlimited). 148 148 max_webhooks: 10 149 149 # Allow all webhook trigger types (not just first-scan).
+4 -4
docker-compose.yml
··· 7 7 container_name: atcr-appview 8 8 ports: 9 9 - "5000:5000" 10 + env_file: 11 + - ../atcr-secrets.env 10 12 # Optional: Load from .env.appview file (create from .env.appview.example) 11 13 # env_file: 12 14 # - .env.appview ··· 15 17 environment: 16 18 # ATCR_SERVER_CLIENT_NAME: "Seamark" 17 19 # ATCR_SERVER_CLIENT_SHORT_NAME: "Seamark" 20 + ATCR_SERVER_MANAGED_HOLDS: did:web:172.28.0.3%3A8080 18 21 ATCR_SERVER_DEFAULT_HOLD_DID: did:web:172.28.0.3%3A8080 19 22 ATCR_SERVER_TEST_MODE: true 20 23 ATCR_LOG_LEVEL: debug 21 24 LOG_SHIPPER_BACKEND: victoria 22 25 LOG_SHIPPER_URL: http://172.28.0.10:9428 23 - # Stripe billing (only used with -tags billing) 24 - STRIPE_SECRET_KEY: sk_test_ 25 - STRIPE_PUBLISHABLE_KEY: pk_test_ 26 - STRIPE_WEBHOOK_SECRET: whsec_ 27 26 # Limit local Docker logs - real logs go to Victoria Logs 28 27 # Local logs just for live tailing (docker logs -f) 29 28 logging: ··· 56 55 # Base config: config-hold.example.yaml (passed via Air entrypoint) 57 56 # Env vars below override config file values for local dev 58 57 environment: 58 + HOLD_SERVER_APPVIEW_DID: did:web:172.28.0.2%3A5000 59 59 HOLD_SCANNER_SECRET: dev-secret 60 60 HOLD_SERVER_PUBLIC_URL: http://172.28.0.3:8080 61 61 HOLD_REGISTRATION_OWNER_DID: did:plc:pddp4xt5lgnv2qsegbzzs4xg
+227
docs/WEBHOOKS.md
··· 1 + # Webhooks 2 + 3 + Webhooks notify external services when events occur in the registry. Payloads are JSON, signed with HMAC-SHA256 (optional), and delivered with retry (exponential backoff: 0s, 30s, 2m, 8m). Discord and Slack URLs are auto-detected and receive platform-native formatting. 4 + 5 + ## Current Events 6 + 7 + ### `push` — Image Push 8 + 9 + Fires when a manifest is stored (the "logical push complete" moment). Tagless pushes (e.g., buildx platform manifests) also fire with an empty `tag` field. 10 + 11 + **Bitmask:** `0x08` — Free tier 12 + 13 + ```json 14 + { 15 + "trigger": "push", 16 + "push_data": { 17 + "pushed_at": "2026-02-27T15:30:00Z", 18 + "pusher": "alice.bsky.social", 19 + "pusher_did": "did:plc:abc123", 20 + "tag": "latest", 21 + "digest": "sha256:abc..." 22 + }, 23 + "repository": { 24 + "name": "myapp", 25 + "namespace": "alice.bsky.social", 26 + "repo_name": "alice.bsky.social/myapp", 27 + "repo_url": "https://buoy.cr/alice.bsky.social/myapp", 28 + "media_type": "application/vnd.oci.image.manifest.v1+json", 29 + "star_count": 42, 30 + "pull_count": 1337 31 + }, 32 + "hold": { 33 + "did": "did:web:hold01.atcr.io", 34 + "endpoint": "https://hold01.atcr.io" 35 + } 36 + } 37 + ``` 38 + 39 + `repo_url` uses `registry_domains[0]` (the pull domain) when configured, otherwise falls back to `base_url`. 40 + 41 + ### `scan:first` — First Scan 42 + 43 + Fires the first time an image is scanned (no previous scan record exists). 44 + 45 + **Bitmask:** `0x01` — Free tier 46 + 47 + ### `scan:all` — Every Scan 48 + 49 + Fires on every scan completion. 50 + 51 + **Bitmask:** `0x02` — Paid tier 52 + 53 + ### `scan:changed` — Vulnerability Change 54 + 55 + Fires when vulnerability counts change from the previous scan. Includes a `previous` field with the old counts. 56 + 57 + **Bitmask:** `0x04` — Paid tier 58 + 59 + **Scan payload format** (shared by all scan triggers): 60 + 61 + ```json 62 + { 63 + "trigger": "scan:first", 64 + "holdDid": "did:web:hold01.atcr.io", 65 + "holdEndpoint": "https://hold01.atcr.io", 66 + "manifest": { 67 + "digest": "sha256:abc...", 68 + "repository": "myapp", 69 + "tag": "latest", 70 + "userDid": "did:plc:abc123", 71 + "userHandle": "alice.bsky.social" 72 + }, 73 + "scan": { 74 + "scannedAt": "2026-02-27T16:00:00Z", 75 + "scannerVersion": "atcr-scanner-v1.0.0", 76 + "vulnerabilities": { 77 + "critical": 0, 78 + "high": 2, 79 + "medium": 5, 80 + "low": 12, 81 + "total": 19 82 + } 83 + }, 84 + "previous": null 85 + } 86 + ``` 87 + 88 + For `scan:changed`, the `previous` field contains the previous vulnerability counts. 89 + 90 + ## Billing 91 + 92 + | Tier | Max Webhooks | Available Triggers | 93 + |------|-------------|-------------------| 94 + | Free | 1 | `push`, `scan:first` | 95 + | Paid | Per plan | All triggers | 96 + | Captain | Unlimited | All triggers | 97 + 98 + Free users can enable both `push` and `scan:first` on their single webhook. 99 + 100 + ## Security 101 + 102 + - **HMAC-SHA256 signing:** If a secret is set, payloads include `X-Webhook-Signature-256: sha256=<hex>`. The signature covers the delivered payload (including platform-specific formatting for Discord/Slack). 103 + - **Retry:** 4 attempts with exponential backoff (0s, 30s, 2m, 8m). 104 + - **Test delivery:** The settings UI supports sending a test payload to verify connectivity. 105 + 106 + ## Implementation 107 + 108 + - Types: `pkg/appview/webhooks/types.go` 109 + - Dispatch + retry: `pkg/appview/webhooks/dispatch.go` 110 + - Discord/Slack formatting: `pkg/appview/webhooks/format.go` 111 + - UI handlers: `pkg/appview/handlers/webhooks.go` 112 + - Settings page SSR: `pkg/appview/handlers/settings.go` 113 + - Template: `pkg/appview/templates/partials/webhooks_list.html` 114 + - Trigger bitmask stored in `webhooks.triggers` column (integer) 115 + 116 + --- 117 + 118 + ## Future Events 119 + 120 + Inspired by [Harbor's webhook model](https://goharbor.io/docs/working-with-projects/project-configuration/configure-webhooks/). These are not yet implemented but document the intended direction. 121 + 122 + ### `pull` — Image Pull 123 + 124 + **Bitmask:** `0x10` (reserved) 125 + 126 + Fires when a manifest is pulled. This is tricky because pulls go through presigned S3 URLs — the appview issues a redirect and never sees the actual blob download. Manifest fetches *are* visible to the appview, so a pull event would fire on manifest GET, not blob download. 127 + 128 + **Scalability concern:** Public repos with high pull volume would generate excessive webhook traffic. Would need rate limiting or batching (e.g., "5 pulls in the last minute" digest). Not suitable for free tier without throttling. 129 + 130 + **Suggested payload:** 131 + 132 + ```json 133 + { 134 + "trigger": "pull", 135 + "pull_data": { 136 + "pulled_at": "2026-02-27T15:30:00Z", 137 + "puller": "bob.bsky.social", 138 + "puller_did": "did:plc:def456", 139 + "tag": "latest", 140 + "digest": "sha256:abc..." 141 + }, 142 + "repository": { 143 + "name": "myapp", 144 + "namespace": "alice.bsky.social", 145 + "repo_name": "alice.bsky.social/myapp", 146 + "repo_url": "https://buoy.cr/alice.bsky.social/myapp", 147 + "star_count": 42, 148 + "pull_count": 1338 149 + }, 150 + "hold": { 151 + "did": "did:web:hold01.atcr.io", 152 + "endpoint": "https://hold01.atcr.io" 153 + } 154 + } 155 + ``` 156 + 157 + Anonymous pulls would have empty `puller` / `puller_did` fields. 158 + 159 + ### `delete` — Manifest Delete 160 + 161 + **Bitmask:** `0x20` (reserved) 162 + 163 + Fires when a manifest is deleted from the user's PDS. Lower priority — deletes are uncommon. 164 + 165 + **Suggested payload:** 166 + 167 + ```json 168 + { 169 + "trigger": "delete", 170 + "delete_data": { 171 + "deleted_at": "2026-02-27T15:30:00Z", 172 + "deleted_by": "alice.bsky.social", 173 + "deleted_by_did": "did:plc:abc123", 174 + "tag": "v1.0.0", 175 + "digest": "sha256:abc..." 176 + }, 177 + "repository": { 178 + "name": "myapp", 179 + "namespace": "alice.bsky.social", 180 + "repo_name": "alice.bsky.social/myapp", 181 + "repo_url": "https://buoy.cr/alice.bsky.social/myapp", 182 + "star_count": 42, 183 + "pull_count": 1337 184 + } 185 + } 186 + ``` 187 + 188 + No `hold` field — deletion removes the manifest record from the PDS; blob cleanup is handled separately by GC. 189 + 190 + ### `quota:warning` / `quota:exceeded` — Storage Quota 191 + 192 + **Bitmask:** `0x40` (warning), `0x80` (exceeded) — reserved 193 + 194 + Fires when a hold's storage quota reaches a threshold or is exceeded. Open design questions: 195 + 196 + - **Thresholds:** Harbor uses a single warning threshold (85%). Options: fixed 80/90/100%, or configurable per hold. 197 + - **Recipient:** Who gets the webhook — the user who pushed (triggering the quota check), the hold captain, or both? Likely the captain, since they own the storage. 198 + - **Scope:** Per-user quotas (crew member limits) vs per-hold quotas (total storage). Both exist in the quota system. 199 + 200 + **Suggested payload:** 201 + 202 + ```json 203 + { 204 + "trigger": "quota:warning", 205 + "quota_data": { 206 + "timestamp": "2026-02-27T15:30:00Z", 207 + "usage_bytes": 8589934592, 208 + "limit_bytes": 10737418240, 209 + "usage_percent": 80, 210 + "threshold_percent": 80 211 + }, 212 + "hold": { 213 + "did": "did:web:hold01.atcr.io", 214 + "endpoint": "https://hold01.atcr.io" 215 + }, 216 + "user": { 217 + "did": "did:plc:abc123", 218 + "handle": "alice.bsky.social" 219 + } 220 + } 221 + ``` 222 + 223 + ### Events explicitly not planned 224 + 225 + - **Scan failed / scan stopped** — Server-side operational issues, not user-actionable. Belongs in ops monitoring (logs, alerting), not user-facing webhooks. 226 + - **Replication** — No replication feature in ATCR. 227 + - **Tag retention** — No retention policies yet.
+2 -7
pkg/appview/handlers/settings.go
··· 175 175 func (h *SettingsHandler) buildWebhooksData(userDID string) webhooksTemplateData { 176 176 data := webhooksTemplateData{ 177 177 ContainerID: "webhooks-content", 178 - TriggerInfo: []triggerInfo{ 179 - {Name: "scan:first", Bit: webhooks.TriggerFirst, Label: "First scan", Description: "When an image is scanned for the first time", AlwaysAvailable: true}, 180 - {Name: "scan:all", Bit: webhooks.TriggerAll, Label: "Every scan", Description: "On every scan completion"}, 181 - {Name: "scan:changed", Bit: webhooks.TriggerChanged, Label: "Vulnerability change", Description: "When vulnerability counts change"}, 182 - }, 178 + TriggerInfo: webhookTriggerInfo(), 183 179 } 184 180 185 - maxWebhooks, allTriggers := h.getWebhookLimits(userDID) 186 - data.Limits = webhookLimits{Max: maxWebhooks, AllTriggers: allTriggers} 181 + data.Limits = h.getWebhookLimits(userDID) 187 182 188 183 webhookList, err := db.ListWebhooks(h.ReadOnlyDB, userDID) 189 184 if err != nil {
+15 -3
pkg/appview/handlers/storage.go
··· 1 1 package handlers 2 2 3 3 import ( 4 + "context" 4 5 "encoding/json" 5 6 "fmt" 6 7 "log/slog" 7 8 "net/http" 9 + "time" 8 10 9 11 "atcr.io/pkg/appview/middleware" 10 12 "atcr.io/pkg/appview/storage" ··· 33 35 return 34 36 } 35 37 38 + // 5-second timeout for the entire operation (DID resolution + quota fetch) 39 + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) 40 + defer cancel() 41 + 36 42 // Use hold_did query param if provided (for previewing other holds), 37 43 // otherwise fall back to the user's saved default hold from their profile. 38 44 holdDID := r.URL.Query().Get("hold_did") 39 45 if holdDID == "" { 40 46 client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) 41 - profile, err := storage.GetProfile(r.Context(), client) 47 + profile, err := storage.GetProfile(ctx, client) 42 48 if err != nil { 43 49 slog.Warn("Failed to get profile for storage quota", "did", user.DID, "error", err) 44 50 h.renderError(w, "Failed to load profile") ··· 52 58 } 53 59 54 60 // Resolve hold URL from DID 55 - holdURL, err := atproto.ResolveHoldURL(r.Context(), holdDID) 61 + holdURL, err := atproto.ResolveHoldURL(ctx, holdDID) 56 62 if err != nil { 57 63 slog.Warn("Failed to resolve hold URL", "did", user.DID, "holdDid", holdDID, "error", err) 58 64 h.renderError(w, "Failed to resolve hold service") ··· 61 67 62 68 // Call the hold's quota endpoint 63 69 quotaURL := fmt.Sprintf("%s%s?userDid=%s", holdURL, atproto.HoldGetQuota, user.DID) 64 - resp, err := http.Get(quotaURL) 70 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, quotaURL, nil) 71 + if err != nil { 72 + slog.Warn("Failed to create quota request", "did", user.DID, "error", err) 73 + h.renderError(w, "Failed to connect to hold service") 74 + return 75 + } 76 + resp, err := http.DefaultClient.Do(req) 65 77 if err != nil { 66 78 slog.Warn("Failed to fetch quota from hold", "did", user.DID, "holdURL", holdURL, "error", err) 67 79 h.renderError(w, "Failed to connect to hold service")
+38 -20
pkg/appview/handlers/webhooks.go
··· 22 22 CreatedAt string 23 23 24 24 // Computed fields from bitmask 25 + HasPush bool 25 26 HasFirst bool 26 27 HasAll bool 27 28 HasChanged bool 28 29 } 29 30 30 31 type webhookLimits struct { 31 - Max int 32 - AllTriggers bool 32 + Max int 33 + AllTriggers bool 34 + PaidTierName string // Name of the first tier that enables all triggers 33 35 } 34 36 35 37 // WebhooksHandler returns the webhooks list partial via HTMX ··· 52 54 } 53 55 54 56 // Get tier limits from billing manager 55 - maxWebhooks, allTriggers := h.getWebhookLimits(user.DID) 57 + limits := h.getWebhookLimits(user.DID) 56 58 57 - h.renderWebhookList(w, webhookList, webhookLimits{Max: maxWebhooks, AllTriggers: allTriggers}) 59 + h.renderWebhookList(w, webhookList, limits) 58 60 } 59 61 60 62 // AddWebhookHandler handles adding a new webhook via form POST ··· 84 86 85 87 // Parse trigger checkboxes 86 88 triggers := 0 89 + if r.FormValue("trigger_push") == "on" { 90 + triggers |= webhooks.TriggerPush 91 + } 87 92 if r.FormValue("trigger_first") == "on" { 88 93 triggers |= webhooks.TriggerFirst 89 94 } ··· 98 103 } 99 104 100 105 // Tier enforcement 101 - maxWebhooks, allTriggers := h.getWebhookLimits(user.DID) 106 + limits := h.getWebhookLimits(user.DID) 102 107 103 108 // Check webhook count limit 104 109 count, err := db.CountWebhooks(h.ReadOnlyDB, user.DID) ··· 106 111 h.renderWebhookError(w, "Failed to check webhook count") 107 112 return 108 113 } 109 - if maxWebhooks >= 0 && count >= maxWebhooks { 114 + if limits.Max >= 0 && count >= limits.Max { 110 115 h.renderWebhookError(w, "Webhook limit reached") 111 116 return 112 117 } 113 118 114 - // Trigger bitmask enforcement: free users can only set TriggerFirst 115 - if !allTriggers && triggers & ^webhooks.TriggerFirst != 0 { 119 + // Trigger bitmask enforcement: free users can only set TriggerFirst and TriggerPush 120 + freeMask := webhooks.TriggerFirst | webhooks.TriggerPush 121 + if !limits.AllTriggers && triggers & ^freeMask != 0 { 116 122 h.renderWebhookError(w, "Additional trigger types require a paid plan") 117 123 return 118 124 } ··· 207 213 // ---- Shared helpers ---- 208 214 209 215 // getWebhookLimits returns the webhook limits for a user based on their billing tier. 210 - func (h *BaseUIHandler) getWebhookLimits(userDID string) (maxWebhooks int, allTriggers bool) { 211 - if h.BillingManager != nil && h.BillingManager.Enabled() { 212 - return h.BillingManager.GetWebhookLimits(userDID) 216 + func (h *BaseUIHandler) getWebhookLimits(userDID string) webhookLimits { 217 + limits := webhookLimits{Max: 1} 218 + if h.BillingManager != nil { 219 + if h.BillingManager.Enabled() { 220 + limits.Max, limits.AllTriggers = h.BillingManager.GetWebhookLimits(userDID) 221 + } 222 + limits.PaidTierName = h.BillingManager.GetFirstTierWithAllTriggers() 213 223 } 214 - return 1, false 224 + return limits 215 225 } 216 226 217 227 func (h *BaseUIHandler) refetchAndRender(w http.ResponseWriter, user *db.User) { ··· 221 231 return 222 232 } 223 233 224 - maxWebhooks, allTriggers := h.getWebhookLimits(user.DID) 225 - h.renderWebhookList(w, webhookList, webhookLimits{Max: maxWebhooks, AllTriggers: allTriggers}) 234 + limits := h.getWebhookLimits(user.DID) 235 + h.renderWebhookList(w, webhookList, limits) 226 236 } 227 237 228 238 func (h *BaseUIHandler) renderWebhookList(w http.ResponseWriter, dbWebhooks []db.Webhook, limits webhookLimits) { ··· 237 247 URL: wh.URL, 238 248 HasSecret: wh.HasSecret, 239 249 CreatedAt: wh.CreatedAt.Format(time.RFC3339), 250 + HasPush: wh.Triggers&webhooks.TriggerPush != 0, 240 251 HasFirst: wh.Triggers&webhooks.TriggerFirst != 0, 241 252 HasAll: wh.Triggers&webhooks.TriggerAll != 0, 242 253 HasChanged: wh.Triggers&webhooks.TriggerChanged != 0, ··· 252 263 Webhooks: entries, 253 264 Limits: limits, 254 265 ContainerID: "webhooks-content", 255 - TriggerInfo: []triggerInfo{ 256 - {Name: "scan:first", Bit: webhooks.TriggerFirst, Label: "First scan", Description: "When an image is scanned for the first time", AlwaysAvailable: true}, 257 - {Name: "scan:all", Bit: webhooks.TriggerAll, Label: "Every scan", Description: "On every scan completion"}, 258 - {Name: "scan:changed", Bit: webhooks.TriggerChanged, Label: "Vulnerability change", Description: "When vulnerability counts change"}, 259 - }, 266 + TriggerInfo: webhookTriggerInfo(), 260 267 } 261 268 262 269 if err := h.Templates.ExecuteTemplate(w, "webhooks_list", templateData); err != nil { ··· 270 277 Bit int 271 278 Label string 272 279 Description string 273 - AlwaysAvailable bool 280 + AlwaysAvailable bool // Available to free-tier users 281 + DefaultChecked bool // Checked by default in the form 282 + } 283 + 284 + // webhookTriggerInfo returns the canonical list of webhook trigger types. 285 + func webhookTriggerInfo() []triggerInfo { 286 + return []triggerInfo{ 287 + {Name: "push", Bit: webhooks.TriggerPush, Label: "Image push", Description: "When an image is pushed to your repository", AlwaysAvailable: true}, 288 + {Name: "scan:first", Bit: webhooks.TriggerFirst, Label: "First scan", Description: "When an image is scanned for the first time", AlwaysAvailable: true}, 289 + {Name: "scan:all", Bit: webhooks.TriggerAll, Label: "Every scan", Description: "On every scan completion"}, 290 + {Name: "scan:changed", Bit: webhooks.TriggerChanged, Label: "Vulnerability change", Description: "When vulnerability counts change"}, 291 + } 274 292 } 275 293 276 294 func (h *BaseUIHandler) renderWebhookError(w http.ResponseWriter, message string) {
+30 -20
pkg/appview/middleware/registry.go
··· 170 170 // These are set by main.go during startup and copied into NamespaceResolver instances. 171 171 // After initialization, request handling uses the NamespaceResolver's instance fields. 172 172 var ( 173 - globalRefresher *oauth.Refresher 174 - globalDatabase storage.HoldDIDLookup 175 - globalAuthorizer auth.HoldAuthorizer 173 + globalRefresher *oauth.Refresher 174 + globalDatabase storage.HoldDIDLookup 175 + globalAuthorizer auth.HoldAuthorizer 176 + globalWebhookDispatcher storage.PushWebhookDispatcher 176 177 ) 177 178 178 179 // SetGlobalRefresher sets the OAuth refresher instance during initialization ··· 191 192 // Must be called before the registry starts serving requests 192 193 func SetGlobalAuthorizer(authorizer auth.HoldAuthorizer) { 193 194 globalAuthorizer = authorizer 195 + } 196 + 197 + // SetGlobalWebhookDispatcher sets the push webhook dispatcher during initialization 198 + // Must be called before the registry starts serving requests 199 + func SetGlobalWebhookDispatcher(dispatcher storage.PushWebhookDispatcher) { 200 + globalWebhookDispatcher = dispatcher 194 201 } 195 202 196 203 // GetGlobalAuthorizer returns the global authorizer instance ··· 209 216 // NamespaceResolver wraps a namespace and resolves names 210 217 type NamespaceResolver struct { 211 218 distribution.Namespace 212 - defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io") 213 - baseURL string // Base URL for error messages (e.g., "https://atcr.io") 214 - testMode bool // If true, fallback to default hold when user's hold is unreachable 215 - refresher *oauth.Refresher // OAuth session manager (copied from global on init) 216 - database storage.HoldDIDLookup // Database for hold DID lookups (copied from global on init) 217 - authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init) 218 - validationCache *validationCache // Request-level service token cache 219 - readmeFetcher *readme.Fetcher // README fetcher for repo pages 219 + defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io") 220 + baseURL string // Base URL for error messages (e.g., "https://atcr.io") 221 + testMode bool // If true, fallback to default hold when user's hold is unreachable 222 + refresher *oauth.Refresher // OAuth session manager (copied from global on init) 223 + database storage.HoldDIDLookup // Database for hold DID lookups (copied from global on init) 224 + authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init) 225 + webhookDispatcher storage.PushWebhookDispatcher // Push webhook dispatcher (copied from global on init) 226 + validationCache *validationCache // Request-level service token cache 227 + readmeFetcher *readme.Fetcher // README fetcher for repo pages 220 228 } 221 229 222 230 // initATProtoResolver initializes the name resolution middleware ··· 243 251 // Copy shared services from globals into the instance 244 252 // This avoids accessing globals during request handling 245 253 return &NamespaceResolver{ 246 - Namespace: ns, 247 - defaultHoldDID: defaultHoldDID, 248 - baseURL: baseURL, 249 - testMode: testMode, 250 - refresher: globalRefresher, 251 - database: globalDatabase, 252 - authorizer: globalAuthorizer, 253 - validationCache: newValidationCache(), 254 - readmeFetcher: readme.NewFetcher(), 254 + Namespace: ns, 255 + defaultHoldDID: defaultHoldDID, 256 + baseURL: baseURL, 257 + testMode: testMode, 258 + refresher: globalRefresher, 259 + database: globalDatabase, 260 + authorizer: globalAuthorizer, 261 + webhookDispatcher: globalWebhookDispatcher, 262 + validationCache: newValidationCache(), 263 + readmeFetcher: readme.NewFetcher(), 255 264 }, nil 256 265 } 257 266 ··· 482 491 Authorizer: nr.authorizer, 483 492 Refresher: nr.refresher, 484 493 ReadmeFetcher: nr.readmeFetcher, 494 + WebhookDispatcher: nr.webhookDispatcher, 485 495 } 486 496 487 497 return storage.NewRoutingRepository(repo, registryCtx), nil
+1
pkg/appview/server.go
··· 281 281 RegistryDomains: cfg.Server.RegistryDomains, 282 282 } 283 283 s.WebhookDispatcher = webhooks.NewDispatcher(s.Database, appviewMeta) 284 + middleware.SetGlobalWebhookDispatcher(s.WebhookDispatcher) 284 285 285 286 // Initialize Jetstream workers 286 287 s.initializeJetstream()
+2 -27
pkg/appview/src/css/main.css
··· 280 280 /* ---------------------------------------- 281 281 TIER BADGE COLORS 282 282 ---------------------------------------- */ 283 - .badge-owner { 284 - @apply badge-primary; 285 - } 286 283 287 - .badge-deckhand { 288 - @apply badge-ghost; 289 - } 290 - 291 - .badge-bosun { 292 - @apply badge-secondary; 293 - } 294 - 295 - .badge-quartermaster { 296 - @apply badge-accent; 297 - } 298 - 299 - .supporter-badge-deckhand { 300 - @apply badge-ghost; 301 - } 302 - 303 - .supporter-badge-bosun { 304 - @apply badge-secondary; 305 - } 306 - 307 - .supporter-badge-quartermaster { 284 + .supporter-badge { 308 285 @apply badge-accent; 309 286 } 310 287 ··· 435 412 Unlayered — wins over DaisyUI's layered 436 413 .badge base class (utilities layer) 437 414 ======================================== */ 438 - .supporter-badge-deckhand { color: var(--color-base-content); } 439 - .supporter-badge-bosun { color: var(--color-secondary-content); } 440 - .supporter-badge-quartermaster { color: var(--color-accent-content); } 415 + .supporter-badge { color: var(--color-accent-content); } 441 416 .supporter-badge-owner { color: var(--color-primary-content); }
+27 -4
pkg/appview/storage/context.go
··· 1 1 package storage 2 2 3 3 import ( 4 + "context" 5 + 4 6 "atcr.io/pkg/appview/readme" 5 7 "atcr.io/pkg/atproto" 6 8 "atcr.io/pkg/auth" 7 9 "atcr.io/pkg/auth/oauth" 8 10 ) 9 11 12 + // PushWebhookDispatcher dispatches push event webhooks. 13 + // Defined here (in storage) to avoid import cycles with the webhooks package. 14 + type PushWebhookDispatcher interface { 15 + DispatchForPush(ctx context.Context, event PushWebhookEvent) 16 + } 17 + 18 + // PushWebhookEvent contains the data needed to dispatch a push webhook. 19 + type PushWebhookEvent struct { 20 + OwnerDID string 21 + OwnerHandle string 22 + PusherDID string 23 + PusherHandle string 24 + Repository string 25 + Tag string 26 + Digest string 27 + MediaType string 28 + HoldDID string 29 + HoldEndpoint string 30 + } 31 + 10 32 // HoldDIDLookup interface for querying and updating hold DIDs in manifests 11 33 type HoldDIDLookup interface { 12 34 GetLatestHoldDIDForRepo(did, repository string) (string, error) ··· 32 54 PullerPDSEndpoint string // Puller's PDS endpoint URL 33 55 34 56 // Shared services (same for all requests) 35 - Database HoldDIDLookup // Database for hold DID lookups 36 - Authorizer auth.HoldAuthorizer // Hold access authorization 37 - Refresher *oauth.Refresher // OAuth session manager 38 - ReadmeFetcher *readme.Fetcher // README fetcher for repo pages 57 + Database HoldDIDLookup // Database for hold DID lookups 58 + Authorizer auth.HoldAuthorizer // Hold access authorization 59 + Refresher *oauth.Refresher // OAuth session manager 60 + ReadmeFetcher *readme.Fetcher // README fetcher for repo pages 61 + WebhookDispatcher PushWebhookDispatcher // Push webhook dispatcher (nil if not configured) 39 62 }
+34
pkg/appview/storage/manifest_store.go
··· 241 241 }() 242 242 } 243 243 244 + // Dispatch push webhooks asynchronously 245 + if s.ctx.WebhookDispatcher != nil { 246 + pusherDID := s.ctx.PullerDID 247 + pusherHandle := s.ctx.Handle // Default to owner handle 248 + if pusherDID == "" { 249 + pusherDID = s.ctx.DID 250 + } 251 + if pusherDID != s.ctx.DID { 252 + // Crew push: resolve the pusher's handle 253 + if _, resolvedHandle, _, resolveErr := atproto.ResolveIdentity(ctx, pusherDID); resolveErr == nil { 254 + pusherHandle = resolvedHandle 255 + } 256 + } 257 + go func() { 258 + defer func() { 259 + if r := recover(); r != nil { 260 + slog.Error("Panic in push webhook dispatch", "panic", r) 261 + } 262 + }() 263 + s.ctx.WebhookDispatcher.DispatchForPush(context.Background(), PushWebhookEvent{ 264 + OwnerDID: s.ctx.DID, 265 + OwnerHandle: s.ctx.Handle, 266 + PusherDID: pusherDID, 267 + PusherHandle: pusherHandle, 268 + Repository: s.ctx.Repository, 269 + Tag: tag, 270 + Digest: dgst.String(), 271 + MediaType: mediaType, 272 + HoldDID: s.ctx.HoldDID, 273 + HoldEndpoint: s.ctx.HoldURL, 274 + }) 275 + }() 276 + } 277 + 244 278 // Create or update repo page asynchronously if manifest has relevant annotations 245 279 // This ensures repository metadata is synced to user's PDS 246 280 go func() {
+5 -7
pkg/appview/templates/pages/settings.html
··· 52 52 <div class="flex-1 min-w-0"> 53 53 54 54 <!-- STORAGE TAB --> 55 - <div id="tab-storage" class="settings-panel space-y-4"> 55 + <div id="tab-storage" class="settings-panel hidden space-y-4"> 56 56 <!-- Available Plans --> 57 57 {{ template "subscription_plans" .Subscription }} 58 58 ··· 146 146 <div id="tab-webhooks" class="settings-panel hidden space-y-6"> 147 147 <section class="card bg-base-100 shadow-sm p-6 space-y-4"> 148 148 <div> 149 - <h2 class="text-xl font-semibold">Scan Webhooks</h2> 150 - <p class="text-base-content/70 mt-1">Get notified when vulnerability scans complete on any of your images.</p> 149 + <h2 class="text-xl font-semibold">Webhooks</h2> 150 + <p class="text-base-content/70 mt-1">Get notified when images are pushed or vulnerability scans complete.</p> 151 151 </div> 152 152 <div id="webhooks-content"> 153 153 {{ template "webhooks_list" .WebhooksData }} ··· 284 284 }); 285 285 }); 286 286 287 - // Activate initial tab (use requestAnimationFrame to ensure HTMX has initialized) 288 - requestAnimationFrame(function() { 289 - switchSettingsTab(hash); 290 - }); 287 + // Activate initial tab 288 + switchSettingsTab(hash); 291 289 }); 292 290 293 291 // Handle browser back/forward
+4 -2
pkg/appview/templates/pages/user.html
··· 33 33 {{ end }} 34 34 <div class="flex items-center gap-2"> 35 35 <h1 class="text-2xl font-bold">{{ .ViewedUser.Handle }}</h1> 36 - {{ if .SupporterBadge }} 37 - <span class="badge badge-sm supporter-badge-{{ .SupporterBadge }}">{{ .SupporterBadge }}</span> 36 + {{ if or (eq .SupporterBadge "Captain") (eq .SupporterBadge "owner") }} 37 + <span class="badge badge-sm supporter-badge-owner">{{ .SupporterBadge }}</span> 38 + {{ else if .SupporterBadge }} 39 + <span class="badge badge-sm supporter-badge">{{ .SupporterBadge }}</span> 38 40 {{ end }} 39 41 </div> 40 42 </div>
+1 -1
pkg/appview/templates/partials/hold_card.html
··· 23 23 <div class="px-4 pb-4"> 24 24 <div id="storage-stats-active" 25 25 hx-get="/api/storage?hold_did={{ .DID | urlquery }}" 26 - hx-trigger="tab:storage from:body once" 26 + hx-trigger="load, tab:storage from:body once" 27 27 hx-swap="innerHTML"> 28 28 <p class="flex items-center gap-2 text-sm text-base-content/50">{{ icon "loader-2" "size-4 animate-spin" }} Loading storage...</p> 29 29 </div>
+1 -1
pkg/appview/templates/partials/other_holds_table.html
··· 32 32 <td class="text-right"> 33 33 <span id="storage-compact-{{ sanitizeID .DID }}" 34 34 hx-get="/api/storage?hold_did={{ .DID | urlquery }}&compact=true" 35 - hx-trigger="tab:storage from:body once" 35 + hx-trigger="load, tab:storage from:body once" 36 36 hx-swap="innerHTML" 37 37 class="text-sm font-mono"> 38 38 ...
+6 -3
pkg/appview/templates/partials/webhooks_list.html
··· 29 29 <div class="space-y-2 mt-1"> 30 30 {{ range .TriggerInfo }} 31 31 <label class="flex items-start gap-3{{ if and (not .AlwaysAvailable) (not $.Limits.AllTriggers) }} opacity-50 cursor-not-allowed{{ else }} cursor-pointer{{ end }}"> 32 - <input type="checkbox" name="trigger_{{ if eq .Name "scan:first" }}first{{ else if eq .Name "scan:all" }}all{{ else }}changed{{ end }}" 32 + <input type="checkbox" name="trigger_{{ if eq .Name "push" }}push{{ else if eq .Name "scan:first" }}first{{ else if eq .Name "scan:all" }}all{{ else }}changed{{ end }}" 33 33 class="checkbox checkbox-sm mt-0.5" 34 - {{ if .AlwaysAvailable }}checked{{ end }} 34 + {{ if .DefaultChecked }}checked{{ end }} 35 35 {{ if and (not .AlwaysAvailable) (not $.Limits.AllTriggers) }}disabled{{ end }}> 36 36 <span> 37 37 <span class="text-sm font-medium">{{ .Label }}</span> 38 - {{ if and (not .AlwaysAvailable) (not $.Limits.AllTriggers) }}<span class="badge badge-xs badge-outline ml-1">Paid</span>{{ end }} 38 + {{ if and (not .AlwaysAvailable) (not $.Limits.AllTriggers) }}<span class="badge badge-xs badge-outline ml-1">{{ if $.Limits.PaidTierName }}{{ $.Limits.PaidTierName }}{{ else }}Paid{{ end }}</span>{{ end }} 39 39 <br><span class="text-xs text-base-content/60">{{ .Description }}</span> 40 40 </span> 41 41 </label> ··· 63 63 <div class="min-w-0 flex-1"> 64 64 <code class="text-sm break-all">{{ .URL }}</code> 65 65 <div class="flex flex-wrap gap-1 mt-2"> 66 + {{ if .HasPush }} 67 + <span class="badge badge-sm badge-info">push</span> 68 + {{ end }} 66 69 {{ if .HasFirst }} 67 70 <span class="badge badge-sm badge-primary">scan:first</span> 68 71 {{ end }}
+65 -13
pkg/appview/webhooks/dispatch.go
··· 15 15 "time" 16 16 17 17 "atcr.io/pkg/appview/db" 18 + "atcr.io/pkg/appview/storage" 18 19 "atcr.io/pkg/atproto" 19 20 ) 20 21 21 - // Dispatcher handles webhook delivery for scan notifications. 22 + // Dispatcher handles webhook delivery for push and scan notifications. 22 23 // It reads webhooks from the appview DB and delivers payloads 23 24 // with Discord/Slack formatting and HMAC signing. 24 25 type Dispatcher struct { ··· 109 110 } 110 111 } 111 112 113 + // DispatchForPush fires matching webhooks after a manifest is pushed. 114 + func (d *Dispatcher) DispatchForPush(ctx context.Context, event storage.PushWebhookEvent) { 115 + webhooks, err := db.GetWebhooksForUser(d.db, event.OwnerDID) 116 + if err != nil || len(webhooks) == 0 { 117 + return 118 + } 119 + 120 + // Fetch star/pull counts for payload enrichment 121 + var starCount, pullCount int 122 + stats, err := db.GetRepositoryStats(d.db, event.OwnerDID, event.Repository) 123 + if err == nil && stats != nil { 124 + starCount = stats.StarCount 125 + pullCount = stats.PullCount 126 + } 127 + 128 + // Build repo URL using the primary registry domain (pull domain) if available 129 + baseURL := d.meta.BaseURL 130 + if len(d.meta.RegistryDomains) > 0 { 131 + baseURL = "https://" + d.meta.RegistryDomains[0] 132 + } 133 + repoURL := fmt.Sprintf("%s/%s/%s", baseURL, event.OwnerHandle, event.Repository) 134 + 135 + payload := PushWebhookPayload{ 136 + Trigger: "push", 137 + PushData: PushData{ 138 + PushedAt: time.Now().Format(time.RFC3339), 139 + Pusher: event.PusherHandle, 140 + PusherDID: event.PusherDID, 141 + Tag: event.Tag, 142 + Digest: event.Digest, 143 + }, 144 + Repository: PushRepository{ 145 + Name: event.Repository, 146 + Namespace: event.OwnerHandle, 147 + RepoName: event.OwnerHandle + "/" + event.Repository, 148 + RepoURL: repoURL, 149 + MediaType: event.MediaType, 150 + StarCount: starCount, 151 + PullCount: pullCount, 152 + }, 153 + Hold: PushHold{ 154 + DID: event.HoldDID, 155 + Endpoint: event.HoldEndpoint, 156 + }, 157 + } 158 + 159 + payloadBytes, err := json.Marshal(payload) 160 + if err != nil { 161 + slog.Error("Failed to marshal push webhook payload", "error", err) 162 + return 163 + } 164 + 165 + for _, wh := range webhooks { 166 + if wh.Triggers&TriggerPush == 0 { 167 + continue 168 + } 169 + go d.deliverWithRetry(wh.URL, wh.Secret, payloadBytes) 170 + } 171 + } 172 + 112 173 // DeliverTest sends a test payload to a specific webhook (synchronous, single attempt) 113 174 func (d *Dispatcher) DeliverTest(ctx context.Context, webhookID, userDID, userHandle string) (bool, error) { 114 175 wh, err := db.GetWebhookByID(d.db, webhookID) ··· 171 232 // Reformat payload for platform-specific webhook APIs 172 233 sendPayload := payload 173 234 if isDiscordWebhook(webhookURL) || isSlackWebhook(webhookURL) { 174 - var p WebhookPayload 175 - if err := json.Unmarshal(payload, &p); err == nil { 176 - var formatted []byte 177 - var fmtErr error 178 - if isDiscordWebhook(webhookURL) { 179 - formatted, fmtErr = formatDiscordPayload(p, d.meta) 180 - } else { 181 - formatted, fmtErr = formatSlackPayload(p, d.meta) 182 - } 183 - if fmtErr == nil { 184 - sendPayload = formatted 185 - } 235 + formatted, fmtErr := formatPlatformPayload(payload, webhookURL, d.meta) 236 + if fmtErr == nil { 237 + sendPayload = formatted 186 238 } 187 239 } 188 240
+112
pkg/appview/webhooks/format.go
··· 92 92 return strings.Join(lines, "\n") 93 93 } 94 94 95 + // formatPlatformPayload detects the payload type and formats for Discord or Slack 96 + func formatPlatformPayload(payload []byte, webhookURL string, meta atproto.AppviewMetadata) ([]byte, error) { 97 + // Detect push vs scan payload by checking for push_data key 98 + var probe struct { 99 + PushData json.RawMessage `json:"push_data"` 100 + } 101 + if err := json.Unmarshal(payload, &probe); err != nil { 102 + return nil, err 103 + } 104 + 105 + if probe.PushData != nil { 106 + var p PushWebhookPayload 107 + if err := json.Unmarshal(payload, &p); err != nil { 108 + return nil, err 109 + } 110 + if isDiscordWebhook(webhookURL) { 111 + return formatDiscordPushPayload(p, meta) 112 + } 113 + return formatSlackPushPayload(p, meta) 114 + } 115 + 116 + var p WebhookPayload 117 + if err := json.Unmarshal(payload, &p); err != nil { 118 + return nil, err 119 + } 120 + if isDiscordWebhook(webhookURL) { 121 + return formatDiscordPayload(p, meta) 122 + } 123 + return formatSlackPayload(p, meta) 124 + } 125 + 126 + // formatDiscordPushPayload wraps a push webhook payload in Discord's embed format 127 + func formatDiscordPushPayload(p PushWebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) { 128 + appviewURL := meta.BaseURL 129 + 130 + title := p.Repository.Name 131 + if p.PushData.Tag != "" { 132 + title += ":" + p.PushData.Tag 133 + } 134 + 135 + digest := p.PushData.Digest 136 + if len(digest) > 19 { 137 + digest = digest[:19] + "..." 138 + } 139 + description := fmt.Sprintf("Pushed by **%s**\nDigest: `%s`", p.PushData.Pusher, digest) 140 + 141 + embed := map[string]any{ 142 + "title": title, 143 + "url": p.Repository.RepoURL, 144 + "description": description, 145 + "color": 0x5865F2, // blurple 146 + "footer": map[string]string{ 147 + "text": meta.ClientShortName, 148 + "icon_url": meta.FaviconURL, 149 + }, 150 + "timestamp": p.PushData.PushedAt, 151 + } 152 + 153 + embed["author"] = map[string]string{ 154 + "name": p.PushData.Pusher, 155 + "url": appviewURL + "/u/" + p.PushData.Pusher, 156 + } 157 + embed["image"] = map[string]string{ 158 + "url": fmt.Sprintf("%s/og/r/%s/%s", appviewURL, p.Repository.Namespace, p.Repository.Name), 159 + } 160 + 161 + payload := map[string]any{ 162 + "username": meta.ClientShortName, 163 + "avatar_url": meta.FaviconURL, 164 + "embeds": []any{embed}, 165 + } 166 + return json.Marshal(payload) 167 + } 168 + 169 + // formatSlackPushPayload wraps a push webhook payload in Slack's message format 170 + func formatSlackPushPayload(p PushWebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) { 171 + appviewURL := meta.BaseURL 172 + 173 + title := p.Repository.Name 174 + if p.PushData.Tag != "" { 175 + title += ":" + p.PushData.Tag 176 + } 177 + 178 + fallback := fmt.Sprintf("%s pushed %s", p.PushData.Pusher, title) 179 + 180 + digest := p.PushData.Digest 181 + if len(digest) > 19 { 182 + digest = digest[:19] + "..." 183 + } 184 + description := fmt.Sprintf("Pushed by *%s*\nDigest: `%s`", p.PushData.Pusher, digest) 185 + 186 + attachment := map[string]any{ 187 + "fallback": fallback, 188 + "color": "#5865F2", 189 + "title": title, 190 + "title_link": p.Repository.RepoURL, 191 + "text": description, 192 + "footer": meta.ClientShortName, 193 + "footer_icon": meta.FaviconURL, 194 + "ts": p.PushData.PushedAt, 195 + "author_name": p.PushData.Pusher, 196 + "author_link": appviewURL + "/u/" + p.PushData.Pusher, 197 + "image_url": fmt.Sprintf("%s/og/r/%s/%s", appviewURL, p.Repository.Namespace, p.Repository.Name), 198 + } 199 + 200 + payload := map[string]any{ 201 + "text": fallback, 202 + "attachments": []any{attachment}, 203 + } 204 + return json.Marshal(payload) 205 + } 206 + 95 207 // formatDiscordPayload wraps an ATCR webhook payload in Discord's embed format 96 208 func formatDiscordPayload(p WebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) { 97 209 appviewURL := meta.BaseURL
+36 -1
pkg/appview/webhooks/types.go
··· 1 - // Package webhooks provides webhook dispatch and formatting for scan notifications. 1 + // Package webhooks provides webhook dispatch and formatting for push and scan notifications. 2 2 package webhooks 3 3 4 4 // Webhook trigger bitmask constants ··· 6 6 TriggerFirst = 0x01 // First-time scan (no previous scan record) 7 7 TriggerAll = 0x02 // Every scan completion 8 8 TriggerChanged = 0x04 // Vulnerability counts changed from previous 9 + TriggerPush = 0x08 // Image push (manifest stored) 9 10 ) 10 11 11 12 // WebhookPayload is the JSON body sent to webhook URLs ··· 42 43 Low int `json:"low"` 43 44 Total int `json:"total"` 44 45 } 46 + 47 + // PushWebhookPayload is the JSON body sent for push events (Docker Hub-inspired format) 48 + type PushWebhookPayload struct { 49 + Trigger string `json:"trigger"` 50 + PushData PushData `json:"push_data"` 51 + Repository PushRepository `json:"repository"` 52 + Hold PushHold `json:"hold"` 53 + } 54 + 55 + // PushData describes the push event 56 + type PushData struct { 57 + PushedAt string `json:"pushed_at"` 58 + Pusher string `json:"pusher"` 59 + PusherDID string `json:"pusher_did"` 60 + Tag string `json:"tag,omitempty"` 61 + Digest string `json:"digest"` 62 + } 63 + 64 + // PushRepository describes the repository that was pushed to 65 + type PushRepository struct { 66 + Name string `json:"name"` 67 + Namespace string `json:"namespace"` 68 + RepoName string `json:"repo_name"` 69 + RepoURL string `json:"repo_url"` 70 + MediaType string `json:"media_type"` 71 + StarCount int `json:"star_count"` 72 + PullCount int `json:"pull_count"` 73 + } 74 + 75 + // PushHold describes the hold service where blobs are stored 76 + type PushHold struct { 77 + DID string `json:"did"` 78 + Endpoint string `json:"endpoint"` 79 + }
+14
pkg/billing/billing.go
··· 704 704 return fmt.Sprintf("%.1f %s", float64(b)/float64(div), units[exp]) 705 705 } 706 706 707 + // GetFirstTierWithAllTriggers returns the name of the lowest-rank tier that has 708 + // webhook_all_triggers enabled. Returns empty string if none found. 709 + func (m *Manager) GetFirstTierWithAllTriggers() string { 710 + if !m.Enabled() { 711 + return "" 712 + } 713 + for _, tier := range m.cfg.Tiers { 714 + if tier.WebhookAllTriggers { 715 + return tier.Name 716 + } 717 + } 718 + return "" 719 + } 720 + 707 721 // fetchPrice returns the unit amount in cents for a Stripe price ID, using a cache. 708 722 func (m *Manager) fetchPrice(priceID string) (int64, error) { 709 723 m.priceCacheMu.RLock()
+5
pkg/billing/billing_stub.go
··· 65 65 return "" 66 66 } 67 67 68 + // GetFirstTierWithAllTriggers returns empty string when billing is not compiled in. 69 + func (m *Manager) GetFirstTierWithAllTriggers() string { 70 + return "" 71 + } 72 + 68 73 // RegisterRoutes is a no-op when billing is not compiled in. 69 74 func (m *Manager) RegisterRoutes(_ chi.Router) {} 70 75