···120120 webhook_all_triggers: false
121121 supporter_badge: false
122122 - # Tier name. Position in list determines rank (0-based).
123123- name: deckhand
123123+ name: Supporter
124124 # Short description shown on the plan card.
125125 description: Get started with basic storage
126126 # List of features included in this tier.
···128128 # Stripe price ID for monthly billing. Empty = free tier.
129129 stripe_price_monthly: ""
130130 # Stripe price ID for yearly billing.
131131- stripe_price_yearly: ""
131131+ stripe_price_yearly: "price_1SmK1mRROAC4bYmSwhTQ7RY9"
132132 # Maximum webhooks for this tier (-1 = unlimited).
133133 max_webhooks: 1
134134 # Allow all webhook trigger types (not just first-scan).
···141141 # List of features included in this tier.
142142 features: []
143143 # Stripe price ID for monthly billing. Empty = free tier.
144144- stripe_price_monthly: ""
144144+ stripe_price_monthly: "price_1SmK4QRROAC4bYmSxpr35HUl"
145145 # Stripe price ID for yearly billing.
146146- stripe_price_yearly: ""
146146+ stripe_price_yearly: "price_1SmJuLRROAC4bYmSUgVCwZWo"
147147 # Maximum webhooks for this tier (-1 = unlimited).
148148 max_webhooks: 10
149149 # Allow all webhook trigger types (not just first-scan).
+4-4
docker-compose.yml
···77 container_name: atcr-appview
88 ports:
99 - "5000:5000"
1010+ env_file:
1111+ - ../atcr-secrets.env
1012 # Optional: Load from .env.appview file (create from .env.appview.example)
1113 # env_file:
1214 # - .env.appview
···1517 environment:
1618 # ATCR_SERVER_CLIENT_NAME: "Seamark"
1719 # ATCR_SERVER_CLIENT_SHORT_NAME: "Seamark"
2020+ ATCR_SERVER_MANAGED_HOLDS: did:web:172.28.0.3%3A8080
1821 ATCR_SERVER_DEFAULT_HOLD_DID: did:web:172.28.0.3%3A8080
1922 ATCR_SERVER_TEST_MODE: true
2023 ATCR_LOG_LEVEL: debug
2124 LOG_SHIPPER_BACKEND: victoria
2225 LOG_SHIPPER_URL: http://172.28.0.10:9428
2323- # Stripe billing (only used with -tags billing)
2424- STRIPE_SECRET_KEY: sk_test_
2525- STRIPE_PUBLISHABLE_KEY: pk_test_
2626- STRIPE_WEBHOOK_SECRET: whsec_
2726 # Limit local Docker logs - real logs go to Victoria Logs
2827 # Local logs just for live tailing (docker logs -f)
2928 logging:
···5655 # Base config: config-hold.example.yaml (passed via Air entrypoint)
5756 # Env vars below override config file values for local dev
5857 environment:
5858+ HOLD_SERVER_APPVIEW_DID: did:web:172.28.0.2%3A5000
5959 HOLD_SCANNER_SECRET: dev-secret
6060 HOLD_SERVER_PUBLIC_URL: http://172.28.0.3:8080
6161 HOLD_REGISTRATION_OWNER_DID: did:plc:pddp4xt5lgnv2qsegbzzs4xg
+227
docs/WEBHOOKS.md
···11+# Webhooks
22+33+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.
44+55+## Current Events
66+77+### `push` — Image Push
88+99+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.
1010+1111+**Bitmask:** `0x08` — Free tier
1212+1313+```json
1414+{
1515+ "trigger": "push",
1616+ "push_data": {
1717+ "pushed_at": "2026-02-27T15:30:00Z",
1818+ "pusher": "alice.bsky.social",
1919+ "pusher_did": "did:plc:abc123",
2020+ "tag": "latest",
2121+ "digest": "sha256:abc..."
2222+ },
2323+ "repository": {
2424+ "name": "myapp",
2525+ "namespace": "alice.bsky.social",
2626+ "repo_name": "alice.bsky.social/myapp",
2727+ "repo_url": "https://buoy.cr/alice.bsky.social/myapp",
2828+ "media_type": "application/vnd.oci.image.manifest.v1+json",
2929+ "star_count": 42,
3030+ "pull_count": 1337
3131+ },
3232+ "hold": {
3333+ "did": "did:web:hold01.atcr.io",
3434+ "endpoint": "https://hold01.atcr.io"
3535+ }
3636+}
3737+```
3838+3939+`repo_url` uses `registry_domains[0]` (the pull domain) when configured, otherwise falls back to `base_url`.
4040+4141+### `scan:first` — First Scan
4242+4343+Fires the first time an image is scanned (no previous scan record exists).
4444+4545+**Bitmask:** `0x01` — Free tier
4646+4747+### `scan:all` — Every Scan
4848+4949+Fires on every scan completion.
5050+5151+**Bitmask:** `0x02` — Paid tier
5252+5353+### `scan:changed` — Vulnerability Change
5454+5555+Fires when vulnerability counts change from the previous scan. Includes a `previous` field with the old counts.
5656+5757+**Bitmask:** `0x04` — Paid tier
5858+5959+**Scan payload format** (shared by all scan triggers):
6060+6161+```json
6262+{
6363+ "trigger": "scan:first",
6464+ "holdDid": "did:web:hold01.atcr.io",
6565+ "holdEndpoint": "https://hold01.atcr.io",
6666+ "manifest": {
6767+ "digest": "sha256:abc...",
6868+ "repository": "myapp",
6969+ "tag": "latest",
7070+ "userDid": "did:plc:abc123",
7171+ "userHandle": "alice.bsky.social"
7272+ },
7373+ "scan": {
7474+ "scannedAt": "2026-02-27T16:00:00Z",
7575+ "scannerVersion": "atcr-scanner-v1.0.0",
7676+ "vulnerabilities": {
7777+ "critical": 0,
7878+ "high": 2,
7979+ "medium": 5,
8080+ "low": 12,
8181+ "total": 19
8282+ }
8383+ },
8484+ "previous": null
8585+}
8686+```
8787+8888+For `scan:changed`, the `previous` field contains the previous vulnerability counts.
8989+9090+## Billing
9191+9292+| Tier | Max Webhooks | Available Triggers |
9393+|------|-------------|-------------------|
9494+| Free | 1 | `push`, `scan:first` |
9595+| Paid | Per plan | All triggers |
9696+| Captain | Unlimited | All triggers |
9797+9898+Free users can enable both `push` and `scan:first` on their single webhook.
9999+100100+## Security
101101+102102+- **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).
103103+- **Retry:** 4 attempts with exponential backoff (0s, 30s, 2m, 8m).
104104+- **Test delivery:** The settings UI supports sending a test payload to verify connectivity.
105105+106106+## Implementation
107107+108108+- Types: `pkg/appview/webhooks/types.go`
109109+- Dispatch + retry: `pkg/appview/webhooks/dispatch.go`
110110+- Discord/Slack formatting: `pkg/appview/webhooks/format.go`
111111+- UI handlers: `pkg/appview/handlers/webhooks.go`
112112+- Settings page SSR: `pkg/appview/handlers/settings.go`
113113+- Template: `pkg/appview/templates/partials/webhooks_list.html`
114114+- Trigger bitmask stored in `webhooks.triggers` column (integer)
115115+116116+---
117117+118118+## Future Events
119119+120120+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.
121121+122122+### `pull` — Image Pull
123123+124124+**Bitmask:** `0x10` (reserved)
125125+126126+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.
127127+128128+**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.
129129+130130+**Suggested payload:**
131131+132132+```json
133133+{
134134+ "trigger": "pull",
135135+ "pull_data": {
136136+ "pulled_at": "2026-02-27T15:30:00Z",
137137+ "puller": "bob.bsky.social",
138138+ "puller_did": "did:plc:def456",
139139+ "tag": "latest",
140140+ "digest": "sha256:abc..."
141141+ },
142142+ "repository": {
143143+ "name": "myapp",
144144+ "namespace": "alice.bsky.social",
145145+ "repo_name": "alice.bsky.social/myapp",
146146+ "repo_url": "https://buoy.cr/alice.bsky.social/myapp",
147147+ "star_count": 42,
148148+ "pull_count": 1338
149149+ },
150150+ "hold": {
151151+ "did": "did:web:hold01.atcr.io",
152152+ "endpoint": "https://hold01.atcr.io"
153153+ }
154154+}
155155+```
156156+157157+Anonymous pulls would have empty `puller` / `puller_did` fields.
158158+159159+### `delete` — Manifest Delete
160160+161161+**Bitmask:** `0x20` (reserved)
162162+163163+Fires when a manifest is deleted from the user's PDS. Lower priority — deletes are uncommon.
164164+165165+**Suggested payload:**
166166+167167+```json
168168+{
169169+ "trigger": "delete",
170170+ "delete_data": {
171171+ "deleted_at": "2026-02-27T15:30:00Z",
172172+ "deleted_by": "alice.bsky.social",
173173+ "deleted_by_did": "did:plc:abc123",
174174+ "tag": "v1.0.0",
175175+ "digest": "sha256:abc..."
176176+ },
177177+ "repository": {
178178+ "name": "myapp",
179179+ "namespace": "alice.bsky.social",
180180+ "repo_name": "alice.bsky.social/myapp",
181181+ "repo_url": "https://buoy.cr/alice.bsky.social/myapp",
182182+ "star_count": 42,
183183+ "pull_count": 1337
184184+ }
185185+}
186186+```
187187+188188+No `hold` field — deletion removes the manifest record from the PDS; blob cleanup is handled separately by GC.
189189+190190+### `quota:warning` / `quota:exceeded` — Storage Quota
191191+192192+**Bitmask:** `0x40` (warning), `0x80` (exceeded) — reserved
193193+194194+Fires when a hold's storage quota reaches a threshold or is exceeded. Open design questions:
195195+196196+- **Thresholds:** Harbor uses a single warning threshold (85%). Options: fixed 80/90/100%, or configurable per hold.
197197+- **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.
198198+- **Scope:** Per-user quotas (crew member limits) vs per-hold quotas (total storage). Both exist in the quota system.
199199+200200+**Suggested payload:**
201201+202202+```json
203203+{
204204+ "trigger": "quota:warning",
205205+ "quota_data": {
206206+ "timestamp": "2026-02-27T15:30:00Z",
207207+ "usage_bytes": 8589934592,
208208+ "limit_bytes": 10737418240,
209209+ "usage_percent": 80,
210210+ "threshold_percent": 80
211211+ },
212212+ "hold": {
213213+ "did": "did:web:hold01.atcr.io",
214214+ "endpoint": "https://hold01.atcr.io"
215215+ },
216216+ "user": {
217217+ "did": "did:plc:abc123",
218218+ "handle": "alice.bsky.social"
219219+ }
220220+}
221221+```
222222+223223+### Events explicitly not planned
224224+225225+- **Scan failed / scan stopped** — Server-side operational issues, not user-actionable. Belongs in ops monitoring (logs, alerting), not user-facing webhooks.
226226+- **Replication** — No replication feature in ATCR.
227227+- **Tag retention** — No retention policies yet.
···11package handlers
2233import (
44+ "context"
45 "encoding/json"
56 "fmt"
67 "log/slog"
78 "net/http"
99+ "time"
810911 "atcr.io/pkg/appview/middleware"
1012 "atcr.io/pkg/appview/storage"
···3335 return
3436 }
35373838+ // 5-second timeout for the entire operation (DID resolution + quota fetch)
3939+ ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
4040+ defer cancel()
4141+3642 // Use hold_did query param if provided (for previewing other holds),
3743 // otherwise fall back to the user's saved default hold from their profile.
3844 holdDID := r.URL.Query().Get("hold_did")
3945 if holdDID == "" {
4046 client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
4141- profile, err := storage.GetProfile(r.Context(), client)
4747+ profile, err := storage.GetProfile(ctx, client)
4248 if err != nil {
4349 slog.Warn("Failed to get profile for storage quota", "did", user.DID, "error", err)
4450 h.renderError(w, "Failed to load profile")
···5258 }
53595460 // Resolve hold URL from DID
5555- holdURL, err := atproto.ResolveHoldURL(r.Context(), holdDID)
6161+ holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
5662 if err != nil {
5763 slog.Warn("Failed to resolve hold URL", "did", user.DID, "holdDid", holdDID, "error", err)
5864 h.renderError(w, "Failed to resolve hold service")
···61676268 // Call the hold's quota endpoint
6369 quotaURL := fmt.Sprintf("%s%s?userDid=%s", holdURL, atproto.HoldGetQuota, user.DID)
6464- resp, err := http.Get(quotaURL)
7070+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, quotaURL, nil)
7171+ if err != nil {
7272+ slog.Warn("Failed to create quota request", "did", user.DID, "error", err)
7373+ h.renderError(w, "Failed to connect to hold service")
7474+ return
7575+ }
7676+ resp, err := http.DefaultClient.Do(req)
6577 if err != nil {
6678 slog.Warn("Failed to fetch quota from hold", "did", user.DID, "holdURL", holdURL, "error", err)
6779 h.renderError(w, "Failed to connect to hold service")
+38-20
pkg/appview/handlers/webhooks.go
···2222 CreatedAt string
23232424 // Computed fields from bitmask
2525+ HasPush bool
2526 HasFirst bool
2627 HasAll bool
2728 HasChanged bool
2829}
29303031type webhookLimits struct {
3131- Max int
3232- AllTriggers bool
3232+ Max int
3333+ AllTriggers bool
3434+ PaidTierName string // Name of the first tier that enables all triggers
3335}
34363537// WebhooksHandler returns the webhooks list partial via HTMX
···5254 }
53555456 // Get tier limits from billing manager
5555- maxWebhooks, allTriggers := h.getWebhookLimits(user.DID)
5757+ limits := h.getWebhookLimits(user.DID)
56585757- h.renderWebhookList(w, webhookList, webhookLimits{Max: maxWebhooks, AllTriggers: allTriggers})
5959+ h.renderWebhookList(w, webhookList, limits)
5860}
59616062// AddWebhookHandler handles adding a new webhook via form POST
···84868587 // Parse trigger checkboxes
8688 triggers := 0
8989+ if r.FormValue("trigger_push") == "on" {
9090+ triggers |= webhooks.TriggerPush
9191+ }
8792 if r.FormValue("trigger_first") == "on" {
8893 triggers |= webhooks.TriggerFirst
8994 }
···98103 }
99104100105 // Tier enforcement
101101- maxWebhooks, allTriggers := h.getWebhookLimits(user.DID)
106106+ limits := h.getWebhookLimits(user.DID)
102107103108 // Check webhook count limit
104109 count, err := db.CountWebhooks(h.ReadOnlyDB, user.DID)
···106111 h.renderWebhookError(w, "Failed to check webhook count")
107112 return
108113 }
109109- if maxWebhooks >= 0 && count >= maxWebhooks {
114114+ if limits.Max >= 0 && count >= limits.Max {
110115 h.renderWebhookError(w, "Webhook limit reached")
111116 return
112117 }
113118114114- // Trigger bitmask enforcement: free users can only set TriggerFirst
115115- if !allTriggers && triggers & ^webhooks.TriggerFirst != 0 {
119119+ // Trigger bitmask enforcement: free users can only set TriggerFirst and TriggerPush
120120+ freeMask := webhooks.TriggerFirst | webhooks.TriggerPush
121121+ if !limits.AllTriggers && triggers & ^freeMask != 0 {
116122 h.renderWebhookError(w, "Additional trigger types require a paid plan")
117123 return
118124 }
···207213// ---- Shared helpers ----
208214209215// getWebhookLimits returns the webhook limits for a user based on their billing tier.
210210-func (h *BaseUIHandler) getWebhookLimits(userDID string) (maxWebhooks int, allTriggers bool) {
211211- if h.BillingManager != nil && h.BillingManager.Enabled() {
212212- return h.BillingManager.GetWebhookLimits(userDID)
216216+func (h *BaseUIHandler) getWebhookLimits(userDID string) webhookLimits {
217217+ limits := webhookLimits{Max: 1}
218218+ if h.BillingManager != nil {
219219+ if h.BillingManager.Enabled() {
220220+ limits.Max, limits.AllTriggers = h.BillingManager.GetWebhookLimits(userDID)
221221+ }
222222+ limits.PaidTierName = h.BillingManager.GetFirstTierWithAllTriggers()
213223 }
214214- return 1, false
224224+ return limits
215225}
216226217227func (h *BaseUIHandler) refetchAndRender(w http.ResponseWriter, user *db.User) {
···221231 return
222232 }
223233224224- maxWebhooks, allTriggers := h.getWebhookLimits(user.DID)
225225- h.renderWebhookList(w, webhookList, webhookLimits{Max: maxWebhooks, AllTriggers: allTriggers})
234234+ limits := h.getWebhookLimits(user.DID)
235235+ h.renderWebhookList(w, webhookList, limits)
226236}
227237228238func (h *BaseUIHandler) renderWebhookList(w http.ResponseWriter, dbWebhooks []db.Webhook, limits webhookLimits) {
···237247 URL: wh.URL,
238248 HasSecret: wh.HasSecret,
239249 CreatedAt: wh.CreatedAt.Format(time.RFC3339),
250250+ HasPush: wh.Triggers&webhooks.TriggerPush != 0,
240251 HasFirst: wh.Triggers&webhooks.TriggerFirst != 0,
241252 HasAll: wh.Triggers&webhooks.TriggerAll != 0,
242253 HasChanged: wh.Triggers&webhooks.TriggerChanged != 0,
···252263 Webhooks: entries,
253264 Limits: limits,
254265 ContainerID: "webhooks-content",
255255- TriggerInfo: []triggerInfo{
256256- {Name: "scan:first", Bit: webhooks.TriggerFirst, Label: "First scan", Description: "When an image is scanned for the first time", AlwaysAvailable: true},
257257- {Name: "scan:all", Bit: webhooks.TriggerAll, Label: "Every scan", Description: "On every scan completion"},
258258- {Name: "scan:changed", Bit: webhooks.TriggerChanged, Label: "Vulnerability change", Description: "When vulnerability counts change"},
259259- },
266266+ TriggerInfo: webhookTriggerInfo(),
260267 }
261268262269 if err := h.Templates.ExecuteTemplate(w, "webhooks_list", templateData); err != nil {
···270277 Bit int
271278 Label string
272279 Description string
273273- AlwaysAvailable bool
280280+ AlwaysAvailable bool // Available to free-tier users
281281+ DefaultChecked bool // Checked by default in the form
282282+}
283283+284284+// webhookTriggerInfo returns the canonical list of webhook trigger types.
285285+func webhookTriggerInfo() []triggerInfo {
286286+ return []triggerInfo{
287287+ {Name: "push", Bit: webhooks.TriggerPush, Label: "Image push", Description: "When an image is pushed to your repository", AlwaysAvailable: true},
288288+ {Name: "scan:first", Bit: webhooks.TriggerFirst, Label: "First scan", Description: "When an image is scanned for the first time", AlwaysAvailable: true},
289289+ {Name: "scan:all", Bit: webhooks.TriggerAll, Label: "Every scan", Description: "On every scan completion"},
290290+ {Name: "scan:changed", Bit: webhooks.TriggerChanged, Label: "Vulnerability change", Description: "When vulnerability counts change"},
291291+ }
274292}
275293276294func (h *BaseUIHandler) renderWebhookError(w http.ResponseWriter, message string) {
+30-20
pkg/appview/middleware/registry.go
···170170// These are set by main.go during startup and copied into NamespaceResolver instances.
171171// After initialization, request handling uses the NamespaceResolver's instance fields.
172172var (
173173- globalRefresher *oauth.Refresher
174174- globalDatabase storage.HoldDIDLookup
175175- globalAuthorizer auth.HoldAuthorizer
173173+ globalRefresher *oauth.Refresher
174174+ globalDatabase storage.HoldDIDLookup
175175+ globalAuthorizer auth.HoldAuthorizer
176176+ globalWebhookDispatcher storage.PushWebhookDispatcher
176177)
177178178179// SetGlobalRefresher sets the OAuth refresher instance during initialization
···191192// Must be called before the registry starts serving requests
192193func SetGlobalAuthorizer(authorizer auth.HoldAuthorizer) {
193194 globalAuthorizer = authorizer
195195+}
196196+197197+// SetGlobalWebhookDispatcher sets the push webhook dispatcher during initialization
198198+// Must be called before the registry starts serving requests
199199+func SetGlobalWebhookDispatcher(dispatcher storage.PushWebhookDispatcher) {
200200+ globalWebhookDispatcher = dispatcher
194201}
195202196203// GetGlobalAuthorizer returns the global authorizer instance
···209216// NamespaceResolver wraps a namespace and resolves names
210217type NamespaceResolver struct {
211218 distribution.Namespace
212212- defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
213213- baseURL string // Base URL for error messages (e.g., "https://atcr.io")
214214- testMode bool // If true, fallback to default hold when user's hold is unreachable
215215- refresher *oauth.Refresher // OAuth session manager (copied from global on init)
216216- database storage.HoldDIDLookup // Database for hold DID lookups (copied from global on init)
217217- authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init)
218218- validationCache *validationCache // Request-level service token cache
219219- readmeFetcher *readme.Fetcher // README fetcher for repo pages
219219+ defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
220220+ baseURL string // Base URL for error messages (e.g., "https://atcr.io")
221221+ testMode bool // If true, fallback to default hold when user's hold is unreachable
222222+ refresher *oauth.Refresher // OAuth session manager (copied from global on init)
223223+ database storage.HoldDIDLookup // Database for hold DID lookups (copied from global on init)
224224+ authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init)
225225+ webhookDispatcher storage.PushWebhookDispatcher // Push webhook dispatcher (copied from global on init)
226226+ validationCache *validationCache // Request-level service token cache
227227+ readmeFetcher *readme.Fetcher // README fetcher for repo pages
220228}
221229222230// initATProtoResolver initializes the name resolution middleware
···243251 // Copy shared services from globals into the instance
244252 // This avoids accessing globals during request handling
245253 return &NamespaceResolver{
246246- Namespace: ns,
247247- defaultHoldDID: defaultHoldDID,
248248- baseURL: baseURL,
249249- testMode: testMode,
250250- refresher: globalRefresher,
251251- database: globalDatabase,
252252- authorizer: globalAuthorizer,
253253- validationCache: newValidationCache(),
254254- readmeFetcher: readme.NewFetcher(),
254254+ Namespace: ns,
255255+ defaultHoldDID: defaultHoldDID,
256256+ baseURL: baseURL,
257257+ testMode: testMode,
258258+ refresher: globalRefresher,
259259+ database: globalDatabase,
260260+ authorizer: globalAuthorizer,
261261+ webhookDispatcher: globalWebhookDispatcher,
262262+ validationCache: newValidationCache(),
263263+ readmeFetcher: readme.NewFetcher(),
255264 }, nil
256265}
257266···482491 Authorizer: nr.authorizer,
483492 Refresher: nr.refresher,
484493 ReadmeFetcher: nr.readmeFetcher,
494494+ WebhookDispatcher: nr.webhookDispatcher,
485495 }
486496487497 return storage.NewRoutingRepository(repo, registryCtx), nil
···11-// Package webhooks provides webhook dispatch and formatting for scan notifications.
11+// Package webhooks provides webhook dispatch and formatting for push and scan notifications.
22package webhooks
3344// Webhook trigger bitmask constants
···66 TriggerFirst = 0x01 // First-time scan (no previous scan record)
77 TriggerAll = 0x02 // Every scan completion
88 TriggerChanged = 0x04 // Vulnerability counts changed from previous
99+ TriggerPush = 0x08 // Image push (manifest stored)
910)
10111112// WebhookPayload is the JSON body sent to webhook URLs
···4243 Low int `json:"low"`
4344 Total int `json:"total"`
4445}
4646+4747+// PushWebhookPayload is the JSON body sent for push events (Docker Hub-inspired format)
4848+type PushWebhookPayload struct {
4949+ Trigger string `json:"trigger"`
5050+ PushData PushData `json:"push_data"`
5151+ Repository PushRepository `json:"repository"`
5252+ Hold PushHold `json:"hold"`
5353+}
5454+5555+// PushData describes the push event
5656+type PushData struct {
5757+ PushedAt string `json:"pushed_at"`
5858+ Pusher string `json:"pusher"`
5959+ PusherDID string `json:"pusher_did"`
6060+ Tag string `json:"tag,omitempty"`
6161+ Digest string `json:"digest"`
6262+}
6363+6464+// PushRepository describes the repository that was pushed to
6565+type PushRepository struct {
6666+ Name string `json:"name"`
6767+ Namespace string `json:"namespace"`
6868+ RepoName string `json:"repo_name"`
6969+ RepoURL string `json:"repo_url"`
7070+ MediaType string `json:"media_type"`
7171+ StarCount int `json:"star_count"`
7272+ PullCount int `json:"pull_count"`
7373+}
7474+7575+// PushHold describes the hold service where blobs are stored
7676+type PushHold struct {
7777+ DID string `json:"did"`
7878+ Endpoint string `json:"endpoint"`
7979+}
+14
pkg/billing/billing.go
···704704 return fmt.Sprintf("%.1f %s", float64(b)/float64(div), units[exp])
705705}
706706707707+// GetFirstTierWithAllTriggers returns the name of the lowest-rank tier that has
708708+// webhook_all_triggers enabled. Returns empty string if none found.
709709+func (m *Manager) GetFirstTierWithAllTriggers() string {
710710+ if !m.Enabled() {
711711+ return ""
712712+ }
713713+ for _, tier := range m.cfg.Tiers {
714714+ if tier.WebhookAllTriggers {
715715+ return tier.Name
716716+ }
717717+ }
718718+ return ""
719719+}
720720+707721// fetchPrice returns the unit amount in cents for a Stripe price ID, using a cache.
708722func (m *Manager) fetchPrice(priceID string) (int64, error) {
709723 m.priceCacheMu.RLock()
+5
pkg/billing/billing_stub.go
···6565 return ""
6666}
67676868+// GetFirstTierWithAllTriggers returns empty string when billing is not compiled in.
6969+func (m *Manager) GetFirstTierWithAllTriggers() string {
7070+ return ""
7171+}
7272+6873// RegisterRoutes is a no-op when billing is not compiled in.
6974func (m *Manager) RegisterRoutes(_ chi.Router) {}
7075