···102102 bosun:
103103 # Storage quota limit (e.g. "5GB", "50GB", "1TB").
104104 quota: 50GB
105105+ # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
106106+ scan_on_push: true
107107+ # Maximum webhook URLs (0=none, -1=unlimited). Default: 1.
108108+ max_webhooks: 5
109109+ # Allow all webhook trigger types. Free tiers only get scan:first.
110110+ webhook_all_triggers: true
111111+ # Show supporter badge on user profiles for members at this tier.
112112+ supporter_badge: true
105113 deckhand:
106114 # Storage quota limit (e.g. "5GB", "50GB", "1TB").
107115 quota: 5GB
116116+ # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
117117+ scan_on_push: false
118118+ # Maximum webhook URLs (0=none, -1=unlimited). Default: 1.
119119+ max_webhooks: 1
120120+ # Allow all webhook trigger types. Free tiers only get scan:first.
121121+ webhook_all_triggers: false
122122+ # Show supporter badge on user profiles for members at this tier.
123123+ supporter_badge: true
108124 quartermaster:
109125 # Storage quota limit (e.g. "5GB", "50GB", "1TB").
110126 quota: 100GB
127127+ # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
128128+ scan_on_push: true
129129+ # Maximum webhook URLs (0=none, -1=unlimited). Default: 1.
130130+ max_webhooks: -1
131131+ # Allow all webhook trigger types. Free tiers only get scan:first.
132132+ webhook_all_triggers: true
133133+ # Show supporter badge on user profiles for members at this tier.
134134+ supporter_badge: true
111135 # Default tier assignment for new crew members.
112136 defaults:
113137 # Tier assigned to new crew members who don't have an explicit tier.
114138 new_crew_tier: deckhand
139139+ # Show supporter badge on the hold owner's profile.
140140+ owner_badge: false
115141# Vulnerability scanner settings. Empty disables scanning.
116142scanner:
117143 # Shared secret for scanner WebSocket auth. Empty disables scanning.
118144 secret: ""
145145+ # Minimum interval between re-scans of the same manifest. When set, the hold proactively scans manifests when the scanner is idle. Default: 168h (7 days). Set to 0 to disable.
146146+ rescan_interval: 168h0m0s
···11+{
22+ "lexicon": 1,
33+ "id": "io.atcr.hold.subscribeScanJobs",
44+ "defs": {
55+ "main": {
66+ "type": "subscription",
77+ "description": "Subscribe to vulnerability scan jobs via WebSocket. Scanners connect to receive pending scan jobs and send back results. Authenticated via shared secret (query parameter or X-Scanner-Secret header).",
88+ "parameters": {
99+ "type": "params",
1010+ "properties": {
1111+ "cursor": {
1212+ "type": "integer",
1313+ "description": "Sequence number to resume from. If omitted, starts from latest. Use -1 to receive only new jobs."
1414+ }
1515+ }
1616+ },
1717+ "message": {
1818+ "schema": {
1919+ "type": "union",
2020+ "refs": ["#scanJob", "#scanResult"]
2121+ }
2222+ },
2323+ "errors": [
2424+ { "name": "InvalidSecret", "description": "Scanner shared secret is invalid" }
2525+ ]
2626+ },
2727+ "scanJob": {
2828+ "type": "object",
2929+ "description": "A scan job dispatched from hold to scanner. Sent as a JSON WebSocket message.",
3030+ "required": ["type", "seq", "digest", "repository", "userDid", "holdDid", "holdEndpoint"],
3131+ "properties": {
3232+ "type": {
3333+ "type": "string",
3434+ "const": "scan_job",
3535+ "maxLength": 32,
3636+ "description": "Message type discriminator"
3737+ },
3838+ "seq": {
3939+ "type": "integer",
4040+ "description": "Monotonic sequence number for cursor-based resumption"
4141+ },
4242+ "digest": {
4343+ "type": "string",
4444+ "description": "Manifest digest (e.g., sha256:abc123...)",
4545+ "maxLength": 128
4646+ },
4747+ "repository": {
4848+ "type": "string",
4949+ "description": "Repository name (e.g., myapp)",
5050+ "maxLength": 256
5151+ },
5252+ "tag": {
5353+ "type": "string",
5454+ "description": "Optional tag that triggered the scan",
5555+ "maxLength": 256
5656+ },
5757+ "userDid": {
5858+ "type": "string",
5959+ "format": "did",
6060+ "description": "DID of the image owner"
6161+ },
6262+ "holdDid": {
6363+ "type": "string",
6464+ "format": "did",
6565+ "description": "DID of the hold where the image is stored"
6666+ },
6767+ "holdEndpoint": {
6868+ "type": "string",
6969+ "format": "uri",
7070+ "description": "HTTP endpoint of the hold for blob downloads"
7171+ },
7272+ "priority": {
7373+ "type": "integer",
7474+ "description": "Scan priority (lower = higher priority). Tier-based scheduling."
7575+ }
7676+ }
7777+ },
7878+ "scanResult": {
7979+ "type": "object",
8080+ "description": "A scan result sent from scanner back to hold. Sent as a JSON WebSocket message.",
8181+ "required": ["type", "digest", "summary"],
8282+ "properties": {
8383+ "type": {
8484+ "type": "string",
8585+ "const": "scan_result",
8686+ "maxLength": 32,
8787+ "description": "Message type discriminator"
8888+ },
8989+ "digest": {
9090+ "type": "string",
9191+ "description": "Manifest digest that was scanned",
9292+ "maxLength": 128
9393+ },
9494+ "summary": {
9595+ "type": "ref",
9696+ "ref": "#vulnSummary",
9797+ "description": "Vulnerability count summary"
9898+ },
9999+ "sbom": {
100100+ "type": "bytes",
101101+ "maxLength": 104857600,
102102+ "description": "SBOM blob (SPDX JSON format, max 100MB)"
103103+ },
104104+ "vulnReport": {
105105+ "type": "bytes",
106106+ "maxLength": 104857600,
107107+ "description": "Grype vulnerability report blob (JSON, max 100MB)"
108108+ },
109109+ "scannerVersion": {
110110+ "type": "string",
111111+ "description": "Scanner version string",
112112+ "maxLength": 64
113113+ },
114114+ "error": {
115115+ "type": "string",
116116+ "maxLength": 1024,
117117+ "description": "Error message if scan failed"
118118+ }
119119+ }
120120+ },
121121+ "vulnSummary": {
122122+ "type": "object",
123123+ "required": ["critical", "high", "medium", "low", "total"],
124124+ "properties": {
125125+ "critical": {
126126+ "type": "integer",
127127+ "minimum": 0,
128128+ "description": "Count of critical severity vulnerabilities"
129129+ },
130130+ "high": {
131131+ "type": "integer",
132132+ "minimum": 0,
133133+ "description": "Count of high severity vulnerabilities"
134134+ },
135135+ "medium": {
136136+ "type": "integer",
137137+ "minimum": 0,
138138+ "description": "Count of medium severity vulnerabilities"
139139+ },
140140+ "low": {
141141+ "type": "integer",
142142+ "minimum": 0,
143143+ "description": "Count of low severity vulnerabilities"
144144+ },
145145+ "total": {
146146+ "type": "integer",
147147+ "minimum": 0,
148148+ "description": "Total vulnerability count"
149149+ }
150150+ }
151151+ }
152152+ }
153153+}
+41
lexicons/io/atcr/hold/testWebhook.json
···11+{
22+ "lexicon": 1,
33+ "id": "io.atcr.hold.testWebhook",
44+ "defs": {
55+ "main": {
66+ "type": "procedure",
77+ "description": "Send a test payload to a webhook URL. Delivers a synthetic scan result synchronously and reports whether delivery succeeded (2xx response). Only the webhook owner can test their own webhooks. Requires service token authentication.",
88+ "input": {
99+ "encoding": "application/json",
1010+ "schema": {
1111+ "type": "object",
1212+ "required": ["rkey"],
1313+ "properties": {
1414+ "rkey": {
1515+ "type": "string",
1616+ "maxLength": 64,
1717+ "description": "Record key of the io.atcr.hold.webhook to test"
1818+ }
1919+ }
2020+ }
2121+ },
2222+ "output": {
2323+ "encoding": "application/json",
2424+ "schema": {
2525+ "type": "object",
2626+ "required": ["success"],
2727+ "properties": {
2828+ "success": {
2929+ "type": "boolean",
3030+ "description": "Whether the test delivery received a 2xx response"
3131+ }
3232+ }
3333+ }
3434+ },
3535+ "errors": [
3636+ { "name": "WebhookNotFound", "description": "No webhook found with the given rkey" },
3737+ { "name": "Unauthorized", "description": "Webhook belongs to a different user" }
3838+ ]
3939+ }
4040+ }
4141+}
+32
lexicons/io/atcr/hold/webhook.json
···11+{
22+ "lexicon": 1,
33+ "id": "io.atcr.hold.webhook",
44+ "defs": {
55+ "main": {
66+ "type": "record",
77+ "key": "any",
88+ "description": "Webhook configuration stored in the hold's embedded PDS. The public portion of a two-record split: URL and HMAC secret are stored only in the hold's SQLite database (never in ATProto records). Record key is deterministic from user DID + sequence number.",
99+ "record": {
1010+ "type": "object",
1111+ "required": ["userDid", "triggers", "createdAt"],
1212+ "properties": {
1313+ "userDid": {
1414+ "type": "string",
1515+ "format": "did",
1616+ "description": "DID of the webhook owner"
1717+ },
1818+ "triggers": {
1919+ "type": "integer",
2020+ "minimum": 0,
2121+ "description": "Bitmask of trigger events: 0x01=scan:first, 0x02=scan:all, 0x04=scan:changed"
2222+ },
2323+ "createdAt": {
2424+ "type": "string",
2525+ "format": "datetime",
2626+ "description": "RFC3339 timestamp of when the webhook was created"
2727+ }
2828+ }
2929+ }
3030+ }
3131+ }
3232+}
+42
lexicons/io/atcr/sailor/webhook.json
···11+{
22+ "lexicon": 1,
33+ "id": "io.atcr.sailor.webhook",
44+ "defs": {
55+ "main": {
66+ "type": "record",
77+ "key": "tid",
88+ "description": "Public webhook metadata stored in the user's PDS. Links to a private io.atcr.hold.webhook record on the hold where URL and secret are stored. Part of a two-record split: this record is visible via ATProto (Jetstream), the hold record is not.",
99+ "record": {
1010+ "type": "object",
1111+ "required": ["holdDid", "triggers", "privateCid", "createdAt"],
1212+ "properties": {
1313+ "holdDid": {
1414+ "type": "string",
1515+ "format": "did",
1616+ "description": "DID of the hold where the webhook is configured"
1717+ },
1818+ "triggers": {
1919+ "type": "integer",
2020+ "minimum": 0,
2121+ "description": "Bitmask of trigger events: 0x01=scan:first, 0x02=scan:all, 0x04=scan:changed"
2222+ },
2323+ "privateCid": {
2424+ "type": "string",
2525+ "maxLength": 128,
2626+ "description": "CID of the corresponding io.atcr.hold.webhook record on the hold"
2727+ },
2828+ "createdAt": {
2929+ "type": "string",
3030+ "format": "datetime",
3131+ "description": "RFC3339 timestamp of when the webhook was created"
3232+ },
3333+ "updatedAt": {
3434+ "type": "string",
3535+ "format": "datetime",
3636+ "description": "RFC3339 timestamp of when the webhook was last updated"
3737+ }
3838+ }
3939+ }
4040+ }
4141+ }
4242+}
+76-12
pkg/appview/db/hold_store.go
···2233import (
44 "database/sql"
55+ "encoding/json"
56 "fmt"
67 "strings"
78 "time"
···19202021// HoldCaptainRecord represents a cached captain record from a hold's PDS
2122type HoldCaptainRecord struct {
2222- HoldDID string `json:"-"` // Set manually, not from JSON
2323- OwnerDID string `json:"owner"`
2424- Public bool `json:"public"`
2525- AllowAllCrew bool `json:"allowAllCrew"`
2626- DeployedAt string `json:"deployedAt"`
2727- Region string `json:"region"`
2828- Successor string `json:"successor"` // DID of successor hold (migration redirect)
2929- UpdatedAt time.Time `json:"-"` // Set manually, not from JSON
2323+ HoldDID string `json:"-"` // Set manually, not from JSON
2424+ OwnerDID string `json:"owner"`
2525+ Public bool `json:"public"`
2626+ AllowAllCrew bool `json:"allowAllCrew"`
2727+ DeployedAt string `json:"deployedAt"`
2828+ Region string `json:"region"`
2929+ Successor string `json:"successor"` // DID of successor hold (migration redirect)
3030+ SupporterBadgeTiers string `json:"-"` // JSON array of tier names, e.g. '["bosun","quartermaster"]'
3131+ UpdatedAt time.Time `json:"-"` // Set manually, not from JSON
3032}
31333234// GetCaptainRecord retrieves a captain record from the cache
···3436func GetCaptainRecord(db DBTX, holdDID string) (*HoldCaptainRecord, error) {
3537 query := `
3638 SELECT hold_did, owner_did, public, allow_all_crew,
3737- deployed_at, region, successor, updated_at
3939+ deployed_at, region, successor, supporter_badge_tiers, updated_at
3840 FROM hold_captain_records
3941 WHERE hold_did = ?
4042 `
41434244 var record HoldCaptainRecord
4343- var deployedAt, region, successor sql.NullString
4545+ var deployedAt, region, successor, supporterBadgeTiers sql.NullString
44464547 err := db.QueryRow(query, holdDID).Scan(
4648 &record.HoldDID,
···5052 &deployedAt,
5153 ®ion,
5254 &successor,
5555+ &supporterBadgeTiers,
5356 &record.UpdatedAt,
5457 )
5558···7174 if successor.Valid {
7275 record.Successor = successor.String
7376 }
7777+ if supporterBadgeTiers.Valid {
7878+ record.SupporterBadgeTiers = supporterBadgeTiers.String
7979+ }
74807581 return &record, nil
7682}
···8086 query := `
8187 INSERT INTO hold_captain_records (
8288 hold_did, owner_did, public, allow_all_crew,
8383- deployed_at, region, successor, updated_at
8484- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
8989+ deployed_at, region, successor, supporter_badge_tiers, updated_at
9090+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
8591 ON CONFLICT(hold_did) DO UPDATE SET
8692 owner_did = excluded.owner_did,
8793 public = excluded.public,
···8995 deployed_at = excluded.deployed_at,
9096 region = excluded.region,
9197 successor = excluded.successor,
9898+ supporter_badge_tiers = excluded.supporter_badge_tiers,
9299 updated_at = excluded.updated_at
93100 `
94101···100107 nullString(record.DeployedAt),
101108 nullString(record.Region),
102109 nullString(record.Successor),
110110+ nullString(record.SupporterBadgeTiers),
103111 record.UpdatedAt,
104112 )
105113···108116 }
109117110118 return nil
119119+}
120120+121121+// HasSupporterBadge checks if a given tier is in the hold's supporter badge tiers list.
122122+func (r *HoldCaptainRecord) HasSupporterBadge(tier string) bool {
123123+ if r.SupporterBadgeTiers == "" || tier == "" {
124124+ return false
125125+ }
126126+ var tiers []string
127127+ if err := json.Unmarshal([]byte(r.SupporterBadgeTiers), &tiers); err != nil {
128128+ return false
129129+ }
130130+ for _, t := range tiers {
131131+ if t == tier {
132132+ return true
133133+ }
134134+ }
135135+ return false
136136+}
137137+138138+// GetSupporterBadge returns the supporter badge tier name for a user on a specific hold.
139139+// Returns empty string if the hold doesn't have badges, the user's tier isn't badge-eligible,
140140+// or the user isn't a member of the hold.
141141+func GetSupporterBadge(dbConn DBTX, userDID, holdDID string) string {
142142+ if holdDID == "" || userDID == "" {
143143+ return ""
144144+ }
145145+146146+ captain, err := GetCaptainRecord(dbConn, holdDID)
147147+ if err != nil || captain == nil || captain.SupporterBadgeTiers == "" {
148148+ return ""
149149+ }
150150+151151+ // Check if user is the captain (owner)
152152+ if captain.OwnerDID == userDID {
153153+ if captain.HasSupporterBadge("owner") {
154154+ return "owner"
155155+ }
156156+ return ""
157157+ }
158158+159159+ // Look up crew membership for this user on this hold
160160+ memberships, err := GetCrewMemberships(dbConn, userDID)
161161+ if err != nil {
162162+ return ""
163163+ }
164164+165165+ for _, m := range memberships {
166166+ if m.HoldDID == holdDID && m.Tier != "" {
167167+ if captain.HasSupporterBadge(m.Tier) {
168168+ return m.Tier
169169+ }
170170+ return ""
171171+ }
172172+ }
173173+174174+ return ""
111175}
112176113177// ListHoldDIDs returns all known hold DIDs from the cache
···11+description: Add default_hold_did to users and supporter_badge_tiers to hold_captain_records
22+query: |
33+ ALTER TABLE users ADD COLUMN default_hold_did TEXT;
44+ ALTER TABLE hold_captain_records ADD COLUMN supporter_badge_tiers TEXT;
+6-5
pkg/appview/db/models.go
···4455// User represents a user in the system
66type User struct {
77- DID string
88- Handle string
99- PDSEndpoint string
1010- Avatar string
1111- LastSeen time.Time
77+ DID string
88+ Handle string
99+ PDSEndpoint string
1010+ Avatar string
1111+ DefaultHoldDID string
1212+ LastSeen time.Time
1213}
13141415// Manifest represents an OCI manifest stored in the cache
+45-8
pkg/appview/db/queries.go
···318318// GetUserByDID retrieves a user by DID
319319func GetUserByDID(db DBTX, did string) (*User, error) {
320320 var user User
321321- var avatar sql.NullString
321321+ var avatar, defaultHoldDID sql.NullString
322322 err := db.QueryRow(`
323323- SELECT did, handle, pds_endpoint, avatar, last_seen
323323+ SELECT did, handle, pds_endpoint, avatar, default_hold_did, last_seen
324324 FROM users
325325 WHERE did = ?
326326- `, did).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &avatar, &user.LastSeen)
326326+ `, did).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &avatar, &defaultHoldDID, &user.LastSeen)
327327328328 if err == sql.ErrNoRows {
329329 return nil, nil
···332332 return nil, err
333333 }
334334335335- // Handle NULL avatar
336335 if avatar.Valid {
337336 user.Avatar = avatar.String
338337 }
338338+ if defaultHoldDID.Valid {
339339+ user.DefaultHoldDID = defaultHoldDID.String
340340+ }
339341340342 return &user, nil
341343}
···343345// GetUserByHandle retrieves a user by handle
344346func GetUserByHandle(db DBTX, handle string) (*User, error) {
345347 var user User
346346- var avatar sql.NullString
348348+ var avatar, defaultHoldDID sql.NullString
347349 err := db.QueryRow(`
348348- SELECT did, handle, pds_endpoint, avatar, last_seen
350350+ SELECT did, handle, pds_endpoint, avatar, default_hold_did, last_seen
349351 FROM users
350352 WHERE handle = ?
351351- `, handle).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &avatar, &user.LastSeen)
353353+ `, handle).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &avatar, &defaultHoldDID, &user.LastSeen)
352354353355 if err == sql.ErrNoRows {
354356 return nil, nil
···357359 return nil, err
358360 }
359361360360- // Handle NULL avatar
361362 if avatar.Valid {
362363 user.Avatar = avatar.String
364364+ }
365365+ if defaultHoldDID.Valid {
366366+ user.DefaultHoldDID = defaultHoldDID.String
363367 }
364368365369 return &user, nil
···409413 UPDATE users SET handle = ?, last_seen = ? WHERE did = ?
410414 `, newHandle, time.Now(), did)
411415 return err
416416+}
417417+418418+// UpdateUserDefaultHold updates a user's cached default hold DID
419419+// This is called when Jetstream receives a sailor profile update
420420+func UpdateUserDefaultHold(db DBTX, did string, holdDID string) error {
421421+ _, err := db.Exec(`
422422+ UPDATE users SET default_hold_did = ? WHERE did = ?
423423+ `, holdDID, did)
424424+ return err
425425+}
426426+427427+// GetUserHoldDID returns the hold DID for a user. Uses cached default_hold_did
428428+// if available, otherwise falls back to the most recent manifest's hold_endpoint.
429429+func GetUserHoldDID(db DBTX, did string) string {
430430+ // Try cached default hold first
431431+ var holdDID sql.NullString
432432+ _ = db.QueryRow(`SELECT default_hold_did FROM users WHERE did = ?`, did).Scan(&holdDID)
433433+ if holdDID.Valid && holdDID.String != "" {
434434+ return holdDID.String
435435+ }
436436+437437+ // Fallback: most recent manifest's hold
438438+ var manifestHold string
439439+ err := db.QueryRow(`
440440+ SELECT hold_endpoint FROM manifests
441441+ WHERE did = ?
442442+ ORDER BY created_at DESC
443443+ LIMIT 1
444444+ `, did).Scan(&manifestHold)
445445+ if err != nil {
446446+ return ""
447447+ }
448448+ return manifestHold
412449}
413450414451// UpdateUserAvatar updates a user's avatar URL when a profile change is detected
+2
pkg/appview/db/schema.sql
···1212 handle TEXT NOT NULL,
1313 pds_endpoint TEXT NOT NULL,
1414 avatar TEXT,
1515+ default_hold_did TEXT,
1516 last_seen TIMESTAMP NOT NULL,
1617 UNIQUE(handle)
1718);
···185186 deployed_at TEXT,
186187 region TEXT,
187188 successor TEXT,
189189+ supporter_badge_tiers TEXT,
188190 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
189191);
190192CREATE INDEX IF NOT EXISTS idx_hold_captain_updated ON hold_captain_records(updated_at);
+58
pkg/appview/handlers/settings.go
···2233import (
44 "context"
55+ "database/sql"
56 "encoding/json"
67 "html/template"
78 "log/slog"
···268269 return
269270 }
270271272272+ // Cache default hold DID locally (don't wait for Jetstream roundtrip)
273273+ if h.DB != nil {
274274+ _ = db.UpdateUserDefaultHold(h.DB, user.DID, holdDID)
275275+276276+ // Refresh captain record for the selected hold so badge tiers are available immediately
277277+ if holdDID != "" {
278278+ go refreshCaptainRecord(holdDID, h.DB)
279279+ }
280280+ }
281281+271282 w.Header().Set("Content-Type", "text/html")
272283 if err := h.Templates.ExecuteTemplate(w, "alert", map[string]string{
273284 "Type": "success",
···276287 slog.Warn("Failed to render alert", "error", err)
277288 }
278289}
290290+291291+// refreshCaptainRecord fetches a hold's captain record via XRPC and caches it locally.
292292+// This ensures badge tiers and other captain metadata are available immediately
293293+// without waiting for Jetstream or the next backfill cycle.
294294+func refreshCaptainRecord(holdDID string, dbConn *sql.DB) {
295295+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
296296+ defer cancel()
297297+298298+ holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
299299+ if err != nil {
300300+ slog.Debug("Failed to resolve hold URL for captain refresh", "hold_did", holdDID, "error", err)
301301+ return
302302+ }
303303+304304+ holdClient := atproto.NewClient(holdURL, holdDID, "")
305305+ record, err := holdClient.GetRecord(ctx, "io.atcr.hold.captain", "self")
306306+ if err != nil {
307307+ slog.Debug("Failed to fetch captain record for refresh", "hold_did", holdDID, "error", err)
308308+ return
309309+ }
310310+311311+ var captainRecord db.HoldCaptainRecord
312312+ if err := json.Unmarshal(record.Value, &captainRecord); err != nil {
313313+ slog.Debug("Failed to parse captain record for refresh", "hold_did", holdDID, "error", err)
314314+ return
315315+ }
316316+317317+ captainRecord.HoldDID = holdDID
318318+ captainRecord.UpdatedAt = time.Now()
319319+320320+ // Extract supporterBadgeTiers from raw JSON (db struct uses json:"-")
321321+ var raw struct {
322322+ SupporterBadgeTiers []string `json:"supporterBadgeTiers"`
323323+ }
324324+ if err := json.Unmarshal(record.Value, &raw); err == nil && len(raw.SupporterBadgeTiers) > 0 {
325325+ if jsonBytes, err := json.Marshal(raw.SupporterBadgeTiers); err == nil {
326326+ captainRecord.SupporterBadgeTiers = string(jsonBytes)
327327+ }
328328+ }
329329+330330+ if err := db.UpsertCaptainRecord(dbConn, &captainRecord); err != nil {
331331+ slog.Debug("Failed to cache captain record on refresh", "hold_did", holdDID, "error", err)
332332+ return
333333+ }
334334+335335+ slog.Info("Refreshed captain record for hold", "hold_did", holdDID, "badge_tiers", captainRecord.SupporterBadgeTiers)
336336+}
···433433 captainRecord.HoldDID = holdDID
434434 captainRecord.UpdatedAt = time.Now()
435435436436+ // Extract supporterBadgeTiers from raw JSON (db struct uses json:"-" so unmarshal skips it)
437437+ var raw struct {
438438+ SupporterBadgeTiers []string `json:"supporterBadgeTiers"`
439439+ }
440440+ if err := json.Unmarshal(record.Value, &raw); err == nil && len(raw.SupporterBadgeTiers) > 0 {
441441+ if jsonBytes, err := json.Marshal(raw.SupporterBadgeTiers); err == nil {
442442+ captainRecord.SupporterBadgeTiers = string(jsonBytes)
443443+ }
444444+ }
445445+436446 if err := db.UpsertCaptainRecord(b.db, &captainRecord); err != nil {
437447 return fmt.Errorf("failed to cache captain record: %w", err)
438448 }
+22-8
pkg/appview/jetstream/processor.go
···455455 return nil
456456 }
457457458458+ // Cache default hold DID on the user record
459459+ if err := db.UpdateUserDefaultHold(p.db, did, holdDID); err != nil {
460460+ slog.Warn("Failed to cache default hold DID", "component", "processor", "did", did, "holdDid", holdDID, "error", err)
461461+ }
462462+458463 // Query and cache the captain record using provided function
459464 // This allows backfill-specific logic (retries, test mode handling) without duplicating it here
460465 if queryCaptainFn != nil {
···674679 return fmt.Errorf("failed to unmarshal captain record: %w", err)
675680 }
676681682682+ // Marshal supporter badge tiers to JSON string for storage
683683+ badgeTiersJSON := ""
684684+ if len(captainRecord.SupporterBadgeTiers) > 0 {
685685+ if jsonBytes, err := json.Marshal(captainRecord.SupporterBadgeTiers); err == nil {
686686+ badgeTiersJSON = string(jsonBytes)
687687+ }
688688+ }
689689+677690 // Convert to db struct and upsert
678691 record := &db.HoldCaptainRecord{
679679- HoldDID: holdDID,
680680- OwnerDID: captainRecord.Owner,
681681- Public: captainRecord.Public,
682682- AllowAllCrew: captainRecord.AllowAllCrew,
683683- DeployedAt: captainRecord.DeployedAt,
684684- Region: captainRecord.Region,
685685- Successor: captainRecord.Successor,
686686- UpdatedAt: time.Now(),
692692+ HoldDID: holdDID,
693693+ OwnerDID: captainRecord.Owner,
694694+ Public: captainRecord.Public,
695695+ AllowAllCrew: captainRecord.AllowAllCrew,
696696+ DeployedAt: captainRecord.DeployedAt,
697697+ Region: captainRecord.Region,
698698+ Successor: captainRecord.Successor,
699699+ SupporterBadgeTiers: badgeTiersJSON,
700700+ UpdatedAt: time.Now(),
687701 }
688702689703 if err := db.UpsertCaptainRecord(p.db, record); err != nil {
+1
pkg/appview/jetstream/processor_test.go
···4141 handle TEXT NOT NULL,
4242 pds_endpoint TEXT NOT NULL,
4343 avatar TEXT,
4444+ default_hold_did TEXT,
4445 last_seen TIMESTAMP NOT NULL
4546 );
4647
···183183 slog.Debug("Base URL for OAuth", "base_url", baseURL)
184184 if testMode {
185185 slog.Info("TEST_MODE enabled - will use HTTP for local DID resolution")
186186+ atproto.SetTestMode(true)
186187 }
187188188189 // Load crypto keys from database (with file fallback and migration)
···6464 // RepoPageCollection is the collection name for repository page metadata
6565 // Stored in user's PDS with rkey = repository name
6666 RepoPageCollection = "io.atcr.repo.page"
6767+6868+ // SailorWebhookCollection is the collection name for webhook configs in user's PDS
6969+ SailorWebhookCollection = "io.atcr.sailor.webhook"
7070+7171+ // WebhookCollection is the collection name for webhook records in hold's embedded PDS
7272+ WebhookCollection = "io.atcr.hold.webhook"
6773)
68746975// ManifestRecord represents a container image manifest stored in ATProto
···667673 AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
668674 EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
669675 DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
670670- Region string `json:"region,omitempty" cborgen:"region,omitempty"` // Deployment region (optional)
671671- Successor string `json:"successor,omitempty" cborgen:"successor,omitempty"` // DID of successor hold (migration redirect)
676676+ Region string `json:"region,omitempty" cborgen:"region,omitempty"` // Deployment region (optional)
677677+ Successor string `json:"successor,omitempty" cborgen:"successor,omitempty"` // DID of successor hold (migration redirect)
678678+ SupporterBadgeTiers []string `json:"supporterBadgeTiers,omitempty" cborgen:"supporterBadgeTiers,omitempty"` // Tier names that earn a supporter badge on profiles
672679}
673680674681// CrewRecord represents a crew member in the hold
···813820func ScanRecordKey(manifestDigest string) string {
814821 // Remove the "sha256:" prefix - the hex digest is already a valid rkey
815822 return strings.TrimPrefix(manifestDigest, "sha256:")
823823+}
824824+825825+// Webhook trigger bitmask constants
826826+const (
827827+ TriggerFirst = 0x01 // First-time scan (no previous scan record)
828828+ TriggerAll = 0x02 // Every scan completion
829829+ TriggerChanged = 0x04 // Vulnerability counts changed from previous
830830+)
831831+832832+// SailorWebhookRecord represents a webhook config in the user's PDS
833833+// Links to a private HoldWebhookRecord via privateCid
834834+type SailorWebhookRecord struct {
835835+ Type string `json:"$type"`
836836+ HoldDID string `json:"holdDid"`
837837+ Triggers int `json:"triggers"`
838838+ PrivateCID string `json:"privateCid"`
839839+ CreatedAt string `json:"createdAt"`
840840+ UpdatedAt string `json:"updatedAt"`
841841+}
842842+843843+// NewSailorWebhookRecord creates a new sailor webhook record
844844+func NewSailorWebhookRecord(holdDID string, triggers int, privateCID string) *SailorWebhookRecord {
845845+ now := time.Now().Format(time.RFC3339)
846846+ return &SailorWebhookRecord{
847847+ Type: SailorWebhookCollection,
848848+ HoldDID: holdDID,
849849+ Triggers: triggers,
850850+ PrivateCID: privateCID,
851851+ CreatedAt: now,
852852+ UpdatedAt: now,
853853+ }
854854+}
855855+856856+// HoldWebhookRecord represents a webhook record in the hold's embedded PDS
857857+// The actual URL and secret are stored in SQLite (never in ATProto records)
858858+type HoldWebhookRecord struct {
859859+ Type string `json:"$type" cborgen:"$type"`
860860+ UserDID string `json:"userDid" cborgen:"userDid"`
861861+ Triggers int64 `json:"triggers" cborgen:"triggers"`
862862+ CreatedAt string `json:"createdAt" cborgen:"createdAt"`
863863+}
864864+865865+// NewHoldWebhookRecord creates a new hold webhook record
866866+func NewHoldWebhookRecord(userDID string, triggers int) *HoldWebhookRecord {
867867+ return &HoldWebhookRecord{
868868+ Type: WebhookCollection,
869869+ UserDID: userDID,
870870+ Triggers: int64(triggers),
871871+ CreatedAt: time.Now().Format(time.RFC3339),
872872+ }
873873+}
874874+875875+// WebhookRecordKey generates a deterministic rkey for a webhook record
876876+// Uses hash of userDID + sequence number to support multiple webhooks per user
877877+func WebhookRecordKey(userDID string, seq int) string {
878878+ combined := fmt.Sprintf("%s/webhook/%d", userDID, seq)
879879+ hash := sha256.Sum256([]byte(combined))
880880+ return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(hash[:16]))
816881}
817882818883// TangledProfileRecord represents a Tangled profile for the hold
+19
pkg/atproto/resolver.go
···96969797 ident, err := directory.LookupDID(ctx, didParsed)
9898 if err != nil {
9999+ // In test mode, fall back to deriving URL directly from did:web.
100100+ // The indigo directory hardcodes HTTPS and rejects IPs/ports,
101101+ // so local dev (HTTP, IP:port) always needs this fallback.
102102+ if testMode && strings.HasPrefix(did, "did:web:") {
103103+ return didWebToURL(did), nil
104104+ }
99105 return "", fmt.Errorf("failed to resolve hold DID %s: %w", did, err)
100106 }
101107···110116 }
111117112118 return "", fmt.Errorf("no hold or PDS service endpoint found for DID %s", did)
119119+}
120120+121121+// didWebToURL converts a did:web DID to its base URL.
122122+// did:web:example.com → https://example.com
123123+// did:web:172.28.0.3%3A8080 → http://172.28.0.3:8080
124124+func didWebToURL(did string) string {
125125+ host := strings.TrimPrefix(did, "did:web:")
126126+ host = strings.ReplaceAll(host, "%3A", ":")
127127+ scheme := "https"
128128+ if strings.Contains(host, ":") {
129129+ scheme = "http"
130130+ }
131131+ return scheme + "://" + host
113132}
114133115134// ResolveDIDToPDS resolves a DID to its PDS endpoint.
···405405 return did, nil
406406}
407407408408-// GenerateDIDFromURL creates a did:web identifier from a public URL
409409-// Example: "http://hold1.example.com:8080" -> "did:web:hold1.example.com:8080"
410410-// Note: Per did:web spec, non-standard ports (not 80/443) are included in the DID
408408+// GenerateDIDFromURL creates a did:web identifier from a public URL.
409409+// Per the did:web spec, ports are percent-encoded: the colon becomes %3A.
410410+// Example: "http://hold1.example.com:8080" -> "did:web:hold1.example.com%3A8080"
411411func GenerateDIDFromURL(publicURL string) string {
412412 // Parse URL
413413 u, err := url.Parse(publicURL)
···426426 port := u.Port()
427427428428 // Include port in DID if it's non-standard (not 80 for http, not 443 for https)
429429+ // Per did:web spec, the colon is percent-encoded as %3A
429430 if port != "" && port != "80" && port != "443" {
430430- return fmt.Sprintf("did:web:%s:%s", hostname, port)
431431+ return fmt.Sprintf("did:web:%s%%3A%s", hostname, port)
431432 }
432433433434 return fmt.Sprintf("did:web:%s", hostname)
+7-7
pkg/hold/pds/did_test.go
···2727 {
2828 name: "HTTP with non-standard port",
2929 publicURL: "http://hold.example.com:8080",
3030- expectedDID: "did:web:hold.example.com:8080",
3030+ expectedDID: "did:web:hold.example.com%3A8080",
3131 },
3232 {
3333 name: "HTTPS with non-standard port",
3434 publicURL: "https://hold.example.com:8443",
3535- expectedDID: "did:web:hold.example.com:8443",
3535+ expectedDID: "did:web:hold.example.com%3A8443",
3636 },
3737 {
3838 name: "localhost with port",
3939 publicURL: "http://localhost:8080",
4040- expectedDID: "did:web:localhost:8080",
4040+ expectedDID: "did:web:localhost%3A8080",
4141 },
4242 {
4343 name: "HTTP with explicit port 80",
···183183 keyPath := filepath.Join(tmpDir, "signing-key")
184184 publicURL := "https://hold.example.com:8443"
185185186186- pds, err := NewHoldPDS(ctx, "did:web:hold.example.com:8443", publicURL, dbPath, keyPath, false)
186186+ pds, err := NewHoldPDS(ctx, "did:web:hold.example.com%3A8443", publicURL, dbPath, keyPath, false)
187187 if err != nil {
188188 t.Fatalf("Failed to create PDS: %v", err)
189189 }
···193193 t.Fatalf("Failed to generate DID document: %v", err)
194194 }
195195196196- // Verify DID includes port
197197- if doc.ID != "did:web:hold.example.com:8443" {
198198- t.Errorf("Expected DID did:web:hold.example.com:8443, got %s", doc.ID)
196196+ // Verify DID includes percent-encoded port
197197+ if doc.ID != "did:web:hold.example.com%3A8443" {
198198+ t.Errorf("Expected DID did:web:hold.example.com%%3A8443, got %s", doc.ID)
199199 }
200200201201 // Verify alsoKnownAs includes port
+17
pkg/hold/pds/scan_broadcaster.go
···145145 db.Close()
146146 return nil, fmt.Errorf("failed to initialize scan_jobs schema: %w", err)
147147 }
148148+ if err := sb.initWebhookSchema(); err != nil {
149149+ db.Close()
150150+ return nil, fmt.Errorf("failed to initialize webhook schema: %w", err)
151151+ }
148152149153 // Start re-dispatch loop for timed-out jobs
150154 sb.wg.Add(1)
···186190187191 if err := sb.initSchema(); err != nil {
188192 return nil, fmt.Errorf("failed to initialize scan_jobs schema: %w", err)
193193+ }
194194+ if err := sb.initWebhookSchema(); err != nil {
195195+ return nil, fmt.Errorf("failed to initialize webhook schema: %w", err)
189196 }
190197191198 sb.wg.Add(1)
···509516510517 // Store scan result as a record in the hold's embedded PDS
511518 if msg.Summary != nil {
519519+ // Check for existing scan record before creating new one (for webhook dispatch)
520520+ var previousScan *atproto.ScanRecord
521521+ _, prevScan, err := sb.pds.GetScanRecord(ctx, manifestDigest)
522522+ if err == nil {
523523+ previousScan = prevScan
524524+ }
525525+512526 scanRecord := atproto.NewScanRecord(
513527 manifestDigest, repository, userDID,
514528 sbomBlob, vulnReportBlob,
···529543 "high", msg.Summary.High,
530544 "total", msg.Summary.Total)
531545 }
546546+547547+ // Dispatch webhooks after scan record is stored
548548+ go sb.dispatchWebhooks(manifestDigest, repository, tag, userDID, msg.Summary, previousScan)
532549 }
533550534551 // Mark job as completed
+1
pkg/hold/pds/server.go
···3232 lexutil.RegisterType(atproto.TangledProfileCollection, &atproto.TangledProfileRecord{})
3333 lexutil.RegisterType(atproto.StatsCollection, &atproto.StatsRecord{})
3434 lexutil.RegisterType(atproto.ScanCollection, &atproto.ScanRecord{})
3535+ lexutil.RegisterType(atproto.WebhookCollection, &atproto.HoldWebhookRecord{})
3536}
36373738// HoldPDS is a minimal ATProto PDS implementation for a hold service
+614
pkg/hold/pds/webhooks.go
···11+package pds
22+33+import (
44+ "context"
55+ "crypto/hmac"
66+ "crypto/sha256"
77+ "encoding/hex"
88+ "encoding/json"
99+ "fmt"
1010+ "log/slog"
1111+ "net/http"
1212+ "net/url"
1313+ "strings"
1414+ "time"
1515+1616+ "atcr.io/pkg/atproto"
1717+ "github.com/go-chi/chi/v5"
1818+ "github.com/go-chi/render"
1919+ "github.com/ipfs/go-cid"
2020+)
2121+2222+// webhookConfig represents a webhook for list/display (masked URL, no secret)
2323+type webhookConfig struct {
2424+ Rkey string `json:"rkey"`
2525+ Triggers int `json:"triggers"`
2626+ URL string `json:"url"` // masked
2727+ HasSecret bool `json:"hasSecret"`
2828+ CreatedAt string `json:"createdAt"`
2929+}
3030+3131+// activeWebhook is the internal representation with secret for dispatch
3232+type activeWebhook struct {
3333+ Rkey string
3434+ URL string
3535+ Secret string
3636+ Triggers int
3737+}
3838+3939+// WebhookPayload is the JSON body sent to webhook URLs
4040+type WebhookPayload struct {
4141+ Trigger string `json:"trigger"`
4242+ HoldDID string `json:"holdDid"`
4343+ HoldEndpoint string `json:"holdEndpoint"`
4444+ Manifest WebhookManifestInfo `json:"manifest"`
4545+ Scan WebhookScanInfo `json:"scan"`
4646+ Previous *WebhookVulnCounts `json:"previous"`
4747+}
4848+4949+// WebhookManifestInfo describes the scanned manifest
5050+type WebhookManifestInfo struct {
5151+ Digest string `json:"digest"`
5252+ Repository string `json:"repository"`
5353+ Tag string `json:"tag"`
5454+ UserDID string `json:"userDid"`
5555+}
5656+5757+// WebhookScanInfo describes the scan results
5858+type WebhookScanInfo struct {
5959+ ScannedAt string `json:"scannedAt"`
6060+ ScannerVersion string `json:"scannerVersion"`
6161+ Vulnerabilities WebhookVulnCounts `json:"vulnerabilities"`
6262+}
6363+6464+// WebhookVulnCounts contains vulnerability counts by severity
6565+type WebhookVulnCounts struct {
6666+ Critical int `json:"critical"`
6767+ High int `json:"high"`
6868+ Medium int `json:"medium"`
6969+ Low int `json:"low"`
7070+ Total int `json:"total"`
7171+}
7272+7373+// initWebhookSchema creates the webhook_secrets table.
7474+// Called from ScanBroadcaster init alongside scan_jobs table.
7575+func (sb *ScanBroadcaster) initWebhookSchema() error {
7676+ stmts := []string{
7777+ `CREATE TABLE IF NOT EXISTS webhook_secrets (
7878+ rkey TEXT PRIMARY KEY,
7979+ user_did TEXT NOT NULL,
8080+ url TEXT NOT NULL,
8181+ secret TEXT
8282+ )`,
8383+ `CREATE INDEX IF NOT EXISTS idx_webhook_secrets_user ON webhook_secrets(user_did)`,
8484+ }
8585+ for _, stmt := range stmts {
8686+ if _, err := sb.db.Exec(stmt); err != nil {
8787+ return fmt.Errorf("failed to create webhook_secrets table: %w", err)
8888+ }
8989+ }
9090+ return nil
9191+}
9292+9393+// CountWebhooks returns the number of webhooks configured for a user
9494+func (sb *ScanBroadcaster) CountWebhooks(userDID string) (int, error) {
9595+ var count int
9696+ err := sb.db.QueryRow(`SELECT COUNT(*) FROM webhook_secrets WHERE user_did = ?`, userDID).Scan(&count)
9797+ return count, err
9898+}
9999+100100+// ListWebhookConfigs returns webhook configurations for display (masked URLs)
101101+func (sb *ScanBroadcaster) ListWebhookConfigs(userDID string) ([]webhookConfig, error) {
102102+ rows, err := sb.db.Query(`
103103+ SELECT rkey, url, secret FROM webhook_secrets WHERE user_did = ?
104104+ `, userDID)
105105+ if err != nil {
106106+ return nil, err
107107+ }
108108+ defer rows.Close()
109109+110110+ var configs []webhookConfig
111111+ for rows.Next() {
112112+ var rkey, rawURL, secret string
113113+ if err := rows.Scan(&rkey, &rawURL, &secret); err != nil {
114114+ continue
115115+ }
116116+117117+ // Get triggers from PDS record
118118+ triggers := 0
119119+ _, val, err := sb.pds.repomgr.GetRecord(context.Background(), sb.pds.uid, atproto.WebhookCollection, rkey, cid.Undef)
120120+ if err == nil {
121121+ if rec, ok := val.(*atproto.HoldWebhookRecord); ok {
122122+ triggers = int(rec.Triggers)
123123+ }
124124+ }
125125+126126+ // Get createdAt from PDS record
127127+ createdAt := ""
128128+ if val != nil {
129129+ if rec, ok := val.(*atproto.HoldWebhookRecord); ok {
130130+ createdAt = rec.CreatedAt
131131+ }
132132+ }
133133+134134+ configs = append(configs, webhookConfig{
135135+ Rkey: rkey,
136136+ Triggers: triggers,
137137+ URL: maskURL(rawURL),
138138+ HasSecret: secret != "",
139139+ CreatedAt: createdAt,
140140+ })
141141+ }
142142+ if configs == nil {
143143+ configs = []webhookConfig{}
144144+ }
145145+ return configs, nil
146146+}
147147+148148+// AddWebhookConfig creates a new webhook: stores secret in SQLite, record in PDS
149149+func (sb *ScanBroadcaster) AddWebhookConfig(userDID, webhookURL, secret string, triggers int) (string, cid.Cid, error) {
150150+ ctx := context.Background()
151151+152152+ // Find next available sequence number for this user
153153+ var maxSeq int
154154+ err := sb.db.QueryRow(`
155155+ SELECT COUNT(*) FROM webhook_secrets WHERE user_did = ?
156156+ `, userDID).Scan(&maxSeq)
157157+ if err != nil {
158158+ return "", cid.Undef, fmt.Errorf("failed to count existing webhooks: %w", err)
159159+ }
160160+161161+ rkey := atproto.WebhookRecordKey(userDID, maxSeq)
162162+163163+ // Create PDS record
164164+ record := atproto.NewHoldWebhookRecord(userDID, triggers)
165165+ _, recordCID, err := sb.pds.repomgr.PutRecord(ctx, sb.pds.uid, atproto.WebhookCollection, rkey, record)
166166+ if err != nil {
167167+ return "", cid.Undef, fmt.Errorf("failed to create webhook PDS record: %w", err)
168168+ }
169169+170170+ // Store secret in SQLite
171171+ _, err = sb.db.Exec(`
172172+ INSERT INTO webhook_secrets (rkey, user_did, url, secret) VALUES (?, ?, ?, ?)
173173+ `, rkey, userDID, webhookURL, secret)
174174+ if err != nil {
175175+ // Try to clean up PDS record on SQLite failure
176176+ _ = sb.pds.repomgr.DeleteRecord(ctx, sb.pds.uid, atproto.WebhookCollection, rkey)
177177+ return "", cid.Undef, fmt.Errorf("failed to store webhook secret: %w", err)
178178+ }
179179+180180+ return rkey, recordCID, nil
181181+}
182182+183183+// DeleteWebhookConfig deletes a webhook by rkey (validates ownership)
184184+func (sb *ScanBroadcaster) DeleteWebhookConfig(userDID, rkey string) error {
185185+ ctx := context.Background()
186186+187187+ // Validate ownership
188188+ var owner string
189189+ err := sb.db.QueryRow(`SELECT user_did FROM webhook_secrets WHERE rkey = ?`, rkey).Scan(&owner)
190190+ if err != nil {
191191+ return fmt.Errorf("webhook not found")
192192+ }
193193+ if owner != userDID {
194194+ return fmt.Errorf("unauthorized: webhook belongs to a different user")
195195+ }
196196+197197+ // Delete SQLite row
198198+ if _, err := sb.db.Exec(`DELETE FROM webhook_secrets WHERE rkey = ?`, rkey); err != nil {
199199+ return fmt.Errorf("failed to delete webhook secret: %w", err)
200200+ }
201201+202202+ // Delete PDS record
203203+ if err := sb.pds.repomgr.DeleteRecord(ctx, sb.pds.uid, atproto.WebhookCollection, rkey); err != nil {
204204+ slog.Warn("Failed to delete webhook PDS record (secret already removed)", "rkey", rkey, "error", err)
205205+ }
206206+207207+ return nil
208208+}
209209+210210+// GetWebhooksForUser returns all active webhooks with secrets for dispatch
211211+func (sb *ScanBroadcaster) GetWebhooksForUser(userDID string) ([]activeWebhook, error) {
212212+ rows, err := sb.db.Query(`
213213+ SELECT rkey, url, secret FROM webhook_secrets WHERE user_did = ?
214214+ `, userDID)
215215+ if err != nil {
216216+ return nil, err
217217+ }
218218+ defer rows.Close()
219219+220220+ var webhooks []activeWebhook
221221+ for rows.Next() {
222222+ var w activeWebhook
223223+ if err := rows.Scan(&w.Rkey, &w.URL, &w.Secret); err != nil {
224224+ continue
225225+ }
226226+227227+ // Get triggers from PDS record
228228+ _, val, err := sb.pds.repomgr.GetRecord(context.Background(), sb.pds.uid, atproto.WebhookCollection, w.Rkey, cid.Undef)
229229+ if err == nil {
230230+ if rec, ok := val.(*atproto.HoldWebhookRecord); ok {
231231+ w.Triggers = int(rec.Triggers)
232232+ }
233233+ }
234234+235235+ webhooks = append(webhooks, w)
236236+ }
237237+ return webhooks, nil
238238+}
239239+240240+// dispatchWebhooks fires matching webhooks after a scan completes
241241+func (sb *ScanBroadcaster) dispatchWebhooks(manifestDigest, repository, tag, userDID string, summary *VulnerabilitySummary, previousScan *atproto.ScanRecord) {
242242+ webhooks, err := sb.GetWebhooksForUser(userDID)
243243+ if err != nil || len(webhooks) == 0 {
244244+ return
245245+ }
246246+247247+ isFirst := previousScan == nil
248248+ isChanged := previousScan != nil && vulnCountsChanged(summary, previousScan)
249249+250250+ scanInfo := WebhookScanInfo{
251251+ ScannedAt: time.Now().Format(time.RFC3339),
252252+ ScannerVersion: "atcr-scanner-v1.0.0",
253253+ Vulnerabilities: WebhookVulnCounts{
254254+ Critical: summary.Critical,
255255+ High: summary.High,
256256+ Medium: summary.Medium,
257257+ Low: summary.Low,
258258+ Total: summary.Total,
259259+ },
260260+ }
261261+262262+ manifestInfo := WebhookManifestInfo{
263263+ Digest: manifestDigest,
264264+ Repository: repository,
265265+ Tag: tag,
266266+ UserDID: userDID,
267267+ }
268268+269269+ for _, wh := range webhooks {
270270+ // Check each trigger condition
271271+ triggers := []string{}
272272+ if wh.Triggers&atproto.TriggerFirst != 0 && isFirst {
273273+ triggers = append(triggers, "scan:first")
274274+ }
275275+ if wh.Triggers&atproto.TriggerAll != 0 {
276276+ triggers = append(triggers, "scan:all")
277277+ }
278278+ if wh.Triggers&atproto.TriggerChanged != 0 && isChanged {
279279+ triggers = append(triggers, "scan:changed")
280280+ }
281281+282282+ for _, trigger := range triggers {
283283+ payload := WebhookPayload{
284284+ Trigger: trigger,
285285+ HoldDID: sb.holdDID,
286286+ HoldEndpoint: sb.holdEndpoint,
287287+ Manifest: manifestInfo,
288288+ Scan: scanInfo,
289289+ }
290290+291291+ // Include previous counts for scan:changed
292292+ if trigger == "scan:changed" && previousScan != nil {
293293+ payload.Previous = &WebhookVulnCounts{
294294+ Critical: int(previousScan.Critical),
295295+ High: int(previousScan.High),
296296+ Medium: int(previousScan.Medium),
297297+ Low: int(previousScan.Low),
298298+ Total: int(previousScan.Total),
299299+ }
300300+ }
301301+302302+ payloadBytes, err := json.Marshal(payload)
303303+ if err != nil {
304304+ slog.Error("Failed to marshal webhook payload", "error", err)
305305+ continue
306306+ }
307307+308308+ go sb.deliverWithRetry(wh.URL, wh.Secret, payloadBytes)
309309+ }
310310+ }
311311+}
312312+313313+// deliverWithRetry attempts to deliver a webhook with exponential backoff
314314+func (sb *ScanBroadcaster) deliverWithRetry(webhookURL, secret string, payload []byte) {
315315+ delays := []time.Duration{0, 30 * time.Second, 2 * time.Minute, 8 * time.Minute}
316316+ for attempt, delay := range delays {
317317+ if attempt > 0 {
318318+ time.Sleep(delay)
319319+ }
320320+ if sb.attemptDelivery(webhookURL, secret, payload) {
321321+ return
322322+ }
323323+ }
324324+ slog.Warn("Webhook delivery failed after retries", "url", maskURL(webhookURL))
325325+}
326326+327327+// attemptDelivery sends a single webhook HTTP POST
328328+func (sb *ScanBroadcaster) attemptDelivery(webhookURL, secret string, payload []byte) bool {
329329+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
330330+ defer cancel()
331331+332332+ req, err := http.NewRequestWithContext(ctx, "POST", webhookURL, strings.NewReader(string(payload)))
333333+ if err != nil {
334334+ slog.Warn("Failed to create webhook request", "error", err)
335335+ return false
336336+ }
337337+338338+ req.Header.Set("Content-Type", "application/json")
339339+ req.Header.Set("User-Agent", "ATCR-Webhook/1.0")
340340+341341+ // HMAC signing if secret is set
342342+ if secret != "" {
343343+ mac := hmac.New(sha256.New, []byte(secret))
344344+ mac.Write(payload)
345345+ sig := hex.EncodeToString(mac.Sum(nil))
346346+ req.Header.Set("X-Webhook-Signature-256", "sha256="+sig)
347347+ }
348348+349349+ client := &http.Client{Timeout: 10 * time.Second}
350350+ resp, err := client.Do(req)
351351+ if err != nil {
352352+ slog.Debug("Webhook delivery attempt failed", "url", maskURL(webhookURL), "error", err)
353353+ return false
354354+ }
355355+ defer resp.Body.Close()
356356+357357+ if resp.StatusCode >= 200 && resp.StatusCode < 300 {
358358+ slog.Info("Webhook delivered successfully", "url", maskURL(webhookURL), "status", resp.StatusCode)
359359+ return true
360360+ }
361361+362362+ slog.Debug("Webhook delivery got non-2xx response", "url", maskURL(webhookURL), "status", resp.StatusCode)
363363+ return false
364364+}
365365+366366+// vulnCountsChanged checks if vulnerability counts differ between current scan and previous
367367+func vulnCountsChanged(current *VulnerabilitySummary, previous *atproto.ScanRecord) bool {
368368+ return current.Critical != int(previous.Critical) ||
369369+ current.High != int(previous.High) ||
370370+ current.Medium != int(previous.Medium) ||
371371+ current.Low != int(previous.Low)
372372+}
373373+374374+// maskURL masks a URL for display (shows scheme + host, hides path/query)
375375+func maskURL(rawURL string) string {
376376+ u, err := url.Parse(rawURL)
377377+ if err != nil {
378378+ if len(rawURL) > 30 {
379379+ return rawURL[:30] + "***"
380380+ }
381381+ return rawURL
382382+ }
383383+ masked := u.Scheme + "://" + u.Host
384384+ if u.Path != "" && u.Path != "/" {
385385+ masked += "/***"
386386+ }
387387+ return masked
388388+}
389389+390390+// ---- XRPC Handlers ----
391391+392392+// HandleListWebhooks returns webhook configs for a user
393393+func (h *XRPCHandler) HandleListWebhooks(w http.ResponseWriter, r *http.Request) {
394394+ user := getUserFromContext(r)
395395+ if user == nil {
396396+ http.Error(w, "authentication required", http.StatusUnauthorized)
397397+ return
398398+ }
399399+400400+ if h.scanBroadcaster == nil {
401401+ render.JSON(w, r, map[string]any{
402402+ "webhooks": []any{},
403403+ "limits": map[string]any{"max": 0, "allTriggers": false},
404404+ })
405405+ return
406406+ }
407407+408408+ configs, err := h.scanBroadcaster.ListWebhookConfigs(user.DID)
409409+ if err != nil {
410410+ http.Error(w, fmt.Sprintf("failed to list webhooks: %v", err), http.StatusInternalServerError)
411411+ return
412412+ }
413413+414414+ // Get tier limits
415415+ maxWebhooks, allTriggers := 1, false
416416+ if h.quotaMgr != nil {
417417+ _, crew, _ := h.pds.GetCrewMemberByDID(r.Context(), user.DID)
418418+ tierKey := ""
419419+ if crew != nil {
420420+ tierKey = crew.Tier
421421+ }
422422+ maxWebhooks, allTriggers = h.quotaMgr.WebhookLimits(tierKey)
423423+ }
424424+425425+ render.JSON(w, r, map[string]any{
426426+ "webhooks": configs,
427427+ "limits": map[string]any{
428428+ "max": maxWebhooks,
429429+ "allTriggers": allTriggers,
430430+ },
431431+ })
432432+}
433433+434434+// HandleAddWebhook creates a new webhook configuration
435435+func (h *XRPCHandler) HandleAddWebhook(w http.ResponseWriter, r *http.Request) {
436436+ user := getUserFromContext(r)
437437+ if user == nil {
438438+ http.Error(w, "authentication required", http.StatusUnauthorized)
439439+ return
440440+ }
441441+442442+ if h.scanBroadcaster == nil {
443443+ http.Error(w, "scanning not enabled", http.StatusNotImplemented)
444444+ return
445445+ }
446446+447447+ var req struct {
448448+ URL string `json:"url"`
449449+ Secret string `json:"secret"`
450450+ Triggers int `json:"triggers"`
451451+ }
452452+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
453453+ http.Error(w, "invalid request body", http.StatusBadRequest)
454454+ return
455455+ }
456456+457457+ // Validate HTTPS URL
458458+ u, err := url.Parse(req.URL)
459459+ if err != nil || (u.Scheme != "https" && u.Scheme != "http") {
460460+ http.Error(w, "invalid webhook URL: must be https", http.StatusBadRequest)
461461+ return
462462+ }
463463+464464+ // Tier enforcement
465465+ tierKey := ""
466466+ _, crew, _ := h.pds.GetCrewMemberByDID(r.Context(), user.DID)
467467+ if crew != nil {
468468+ tierKey = crew.Tier
469469+ }
470470+471471+ maxWebhooks, allTriggers := 1, false
472472+ if h.quotaMgr != nil {
473473+ maxWebhooks, allTriggers = h.quotaMgr.WebhookLimits(tierKey)
474474+ }
475475+476476+ // Check webhook count limit
477477+ count, err := h.scanBroadcaster.CountWebhooks(user.DID)
478478+ if err != nil {
479479+ http.Error(w, "failed to check webhook count", http.StatusInternalServerError)
480480+ return
481481+ }
482482+ if maxWebhooks >= 0 && count >= maxWebhooks {
483483+ http.Error(w, fmt.Sprintf("webhook limit reached (%d/%d)", count, maxWebhooks), http.StatusForbidden)
484484+ return
485485+ }
486486+487487+ // Trigger bitmask enforcement: free users can only set TriggerFirst
488488+ if !allTriggers && req.Triggers & ^atproto.TriggerFirst != 0 {
489489+ http.Error(w, "trigger types beyond scan:first require a paid tier", http.StatusForbidden)
490490+ return
491491+ }
492492+493493+ rkey, recordCID, err := h.scanBroadcaster.AddWebhookConfig(user.DID, req.URL, req.Secret, req.Triggers)
494494+ if err != nil {
495495+ http.Error(w, fmt.Sprintf("failed to add webhook: %v", err), http.StatusInternalServerError)
496496+ return
497497+ }
498498+499499+ render.Status(r, http.StatusCreated)
500500+ render.JSON(w, r, map[string]any{
501501+ "rkey": rkey,
502502+ "cid": recordCID.String(),
503503+ })
504504+}
505505+506506+// HandleDeleteWebhook deletes a webhook configuration
507507+func (h *XRPCHandler) HandleDeleteWebhook(w http.ResponseWriter, r *http.Request) {
508508+ user := getUserFromContext(r)
509509+ if user == nil {
510510+ http.Error(w, "authentication required", http.StatusUnauthorized)
511511+ return
512512+ }
513513+514514+ if h.scanBroadcaster == nil {
515515+ http.Error(w, "scanning not enabled", http.StatusNotImplemented)
516516+ return
517517+ }
518518+519519+ var req struct {
520520+ Rkey string `json:"rkey"`
521521+ }
522522+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
523523+ http.Error(w, "invalid request body", http.StatusBadRequest)
524524+ return
525525+ }
526526+527527+ if err := h.scanBroadcaster.DeleteWebhookConfig(user.DID, req.Rkey); err != nil {
528528+ if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "unauthorized") {
529529+ http.Error(w, err.Error(), http.StatusForbidden)
530530+ } else {
531531+ http.Error(w, fmt.Sprintf("failed to delete webhook: %v", err), http.StatusInternalServerError)
532532+ }
533533+ return
534534+ }
535535+536536+ render.JSON(w, r, map[string]any{"success": true})
537537+}
538538+539539+// HandleTestWebhook sends a test payload to a webhook
540540+func (h *XRPCHandler) HandleTestWebhook(w http.ResponseWriter, r *http.Request) {
541541+ user := getUserFromContext(r)
542542+ if user == nil {
543543+ http.Error(w, "authentication required", http.StatusUnauthorized)
544544+ return
545545+ }
546546+547547+ if h.scanBroadcaster == nil {
548548+ http.Error(w, "scanning not enabled", http.StatusNotImplemented)
549549+ return
550550+ }
551551+552552+ var req struct {
553553+ Rkey string `json:"rkey"`
554554+ }
555555+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
556556+ http.Error(w, "invalid request body", http.StatusBadRequest)
557557+ return
558558+ }
559559+560560+ // Look up webhook URL and secret
561561+ var webhookURL, secret, owner string
562562+ err := h.scanBroadcaster.db.QueryRow(`
563563+ SELECT url, secret, user_did FROM webhook_secrets WHERE rkey = ?
564564+ `, req.Rkey).Scan(&webhookURL, &secret, &owner)
565565+ if err != nil {
566566+ http.Error(w, "webhook not found", http.StatusNotFound)
567567+ return
568568+ }
569569+ if owner != user.DID {
570570+ http.Error(w, "unauthorized", http.StatusForbidden)
571571+ return
572572+ }
573573+574574+ // Build test payload
575575+ payload := WebhookPayload{
576576+ Trigger: "test",
577577+ HoldDID: h.scanBroadcaster.holdDID,
578578+ HoldEndpoint: h.scanBroadcaster.holdEndpoint,
579579+ Manifest: WebhookManifestInfo{
580580+ Digest: "sha256:0000000000000000000000000000000000000000000000000000000000000000",
581581+ Repository: "test-repo",
582582+ Tag: "latest",
583583+ UserDID: user.DID,
584584+ },
585585+ Scan: WebhookScanInfo{
586586+ ScannedAt: time.Now().Format(time.RFC3339),
587587+ ScannerVersion: "atcr-scanner-v1.0.0",
588588+ Vulnerabilities: WebhookVulnCounts{
589589+ Critical: 0, High: 1, Medium: 3, Low: 5, Total: 9,
590590+ },
591591+ },
592592+ }
593593+594594+ payloadBytes, _ := json.Marshal(payload)
595595+596596+ // Deliver test payload synchronously
597597+ success := h.scanBroadcaster.attemptDelivery(webhookURL, secret, payloadBytes)
598598+599599+ render.JSON(w, r, map[string]any{
600600+ "success": success,
601601+ })
602602+}
603603+604604+// registerWebhookHandlers registers webhook XRPC handlers on the router.
605605+// Called from RegisterHandlers.
606606+func (h *XRPCHandler) registerWebhookHandlers(r chi.Router) {
607607+ r.Group(func(r chi.Router) {
608608+ r.Use(h.requireAuth)
609609+ r.Get(atproto.HoldListWebhooks, h.HandleListWebhooks)
610610+ r.Post(atproto.HoldAddWebhook, h.HandleAddWebhook)
611611+ r.Post(atproto.HoldDeleteWebhook, h.HandleDeleteWebhook)
612612+ r.Post(atproto.HoldTestWebhook, h.HandleTestWebhook)
613613+ })
614614+}
+16-6
pkg/hold/pds/xrpc.go
···201201 r.Use(h.requireAuth)
202202 r.Post(atproto.HoldRequestCrew, h.HandleRequestCrew)
203203 // GDPR data export endpoint
204204- r.Get("/xrpc/io.atcr.hold.exportUserData", h.HandleExportUserData)
204204+ r.Get(atproto.HoldExportUserData, h.HandleExportUserData)
205205 // GDPR data deletion endpoint
206206- r.Delete("/xrpc/io.atcr.hold.deleteUserData", h.HandleDeleteUserData)
206206+ r.Delete(atproto.HoldDeleteUserData, h.HandleDeleteUserData)
207207 })
208208209209 // Public quota endpoint (no auth - quota is per-user, just needs userDid param)
···211211212212 // Scanner WebSocket endpoint (shared secret auth)
213213 r.Get(atproto.HoldSubscribeScanJobs, h.HandleSubscribeScanJobs)
214214+215215+ // Webhook management endpoints (service token auth)
216216+ h.registerWebhookHandlers(r)
214217}
215218216219// HandleHealth returns health check information
···248251249252 // For this hold PDS, the handle is the domain part of the DID
250253 // e.g., "hold01.atcr.io did:web:hold01.atcr.io"
251251- expectedHandle := strings.TrimPrefix(h.pds.DID(), "did:web:")
254254+ expectedHandle := didWebHandle(h.pds.DID())
252255253256 // Check if the handle matches
254257 if handle != expectedHandle {
···275278 actorDID := actor
276279 if !atproto.IsDID(actor) {
277280 // It's a handle, resolve to DID
278278- expectedHandle := strings.TrimPrefix(h.pds.DID(), "did:web:")
281281+ expectedHandle := didWebHandle(h.pds.DID())
279282 if actor == expectedHandle {
280283 actorDID = h.pds.DID()
281284 } else {
···307310 profiles := []map[string]any{}
308311309312 // Expected handle for this hold
310310- expectedHandle := strings.TrimPrefix(h.pds.DID(), "did:web:")
313313+ expectedHandle := didWebHandle(h.pds.DID())
311314312315 // Check each actor to see if it matches this hold's DID
313316 for _, actor := range actors {
···356359 // Base response with minimal info
357360 response := map[string]any{
358361 "did": h.pds.DID(),
359359- "handle": strings.TrimPrefix(h.pds.DID(), "did:web:"),
362362+ "handle": didWebHandle(h.pds.DID()),
360363 "postsCount": 0,
361364 "followersCount": 0,
362365 "followsCount": 0,
···18341837 StatsDeleted: result.StatsDeleted,
18351838 })
18361839}
18401840+18411841+// didWebHandle extracts the hostname (handle) from a did:web DID,
18421842+// decoding percent-encoded ports (e.g. did:web:host%3A8080 → host:8080).
18431843+func didWebHandle(did string) string {
18441844+ host := strings.TrimPrefix(did, "did:web:")
18451845+ return strings.ReplaceAll(host, "%3A", ":")
18461846+}
+65
pkg/hold/quota/config.go
···55 "fmt"
66 "os"
77 "regexp"
88+ "sort"
89 "strconv"
910 "strings"
1011···27282829 // Whether pushing triggers an immediate vulnerability scan.
2930 ScanOnPush bool `yaml:"scan_on_push" comment:"Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling."`
3131+3232+ // Maximum number of webhook URLs a user can configure. 0 = none, -1 = unlimited.
3333+ MaxWebhooks int `yaml:"max_webhooks" comment:"Maximum webhook URLs (0=none, -1=unlimited). Default: 1."`
3434+3535+ // Whether all trigger types are allowed. Free tiers only get scan:first.
3636+ WebhookAllTriggers bool `yaml:"webhook_all_triggers" comment:"Allow all webhook trigger types. Free tiers only get scan:first."`
3737+3838+ // Whether this tier earns a supporter badge on user profiles.
3939+ SupporterBadge bool `yaml:"supporter_badge" comment:"Show supporter badge on user profiles for members at this tier."`
3040}
31413242// DefaultsConfig represents default settings.
3343type DefaultsConfig struct {
3444 // Name of the tier assigned to new crew members.
3545 NewCrewTier string `yaml:"new_crew_tier" comment:"Tier assigned to new crew members who don't have an explicit tier."`
4646+4747+ // Whether the hold owner (captain) gets a supporter badge on their profile.
4848+ OwnerBadge bool `yaml:"owner_badge" comment:"Show supporter badge on the hold owner's profile."`
3649}
37503851// Manager manages quota configuration and tier resolution
···193206 }
194207195208 return false
209209+}
210210+211211+// WebhookLimits returns the webhook limits for a tier.
212212+// Returns (maxWebhooks, allTriggers). Default when no config: (1, false).
213213+// Follows the same fallback logic as GetTierLimit.
214214+func (m *Manager) WebhookLimits(tierKey string) (maxWebhooks int, allTriggers bool) {
215215+ if !m.IsEnabled() {
216216+ return 1, false
217217+ }
218218+219219+ if tierKey != "" {
220220+ if tier, ok := m.config.Tiers[tierKey]; ok {
221221+ max := tier.MaxWebhooks
222222+ if max == 0 {
223223+ max = 1 // default
224224+ }
225225+ return max, tier.WebhookAllTriggers
226226+ }
227227+ }
228228+229229+ // Fall back to default tier
230230+ if m.config.Defaults.NewCrewTier != "" {
231231+ if tier, ok := m.config.Tiers[m.config.Defaults.NewCrewTier]; ok {
232232+ max := tier.MaxWebhooks
233233+ if max == 0 {
234234+ max = 1
235235+ }
236236+ return max, tier.WebhookAllTriggers
237237+ }
238238+ }
239239+240240+ return 1, false
241241+}
242242+243243+// BadgeTiers returns the names of tiers that have supporter badges enabled.
244244+// Includes "owner" if defaults.owner_badge is true.
245245+// Returns nil if quotas are disabled or no tiers have badges.
246246+func (m *Manager) BadgeTiers() []string {
247247+ if !m.IsEnabled() {
248248+ return nil
249249+ }
250250+ var tiers []string
251251+ if m.config.Defaults.OwnerBadge {
252252+ tiers = append(tiers, "owner")
253253+ }
254254+ for name, tier := range m.config.Tiers {
255255+ if tier.SupporterBadge {
256256+ tiers = append(tiers, name)
257257+ }
258258+ }
259259+ sort.Strings(tiers)
260260+ return tiers
196261}
197262198263// TierCount returns the number of configured tiers
+33
pkg/hold/server.go
···6767 Config: cfg,
6868 }
69697070+ if cfg.Server.TestMode {
7171+ atproto.SetTestMode(true)
7272+ }
7373+7074 // Initialize embedded PDS if database path is configured
7175 var xrpcHandler *pds.XRPCHandler
7276 var s3Service *s3.S3Service
···182186 slog.Info("Quota enforcement enabled", "tiers", s.QuotaManager.TierCount(), "defaultTier", s.QuotaManager.GetDefaultTier())
183187 } else {
184188 slog.Info("Quota enforcement disabled (no quota tiers configured)")
189189+ }
190190+191191+ // Sync supporter badge tiers from quota config into captain record
192192+ if s.PDS != nil {
193193+ badgeTiers := s.QuotaManager.BadgeTiers()
194194+ badgeCtx := context.Background()
195195+ if _, captain, err := s.PDS.GetCaptainRecord(badgeCtx); err == nil {
196196+ if !stringSlicesEqual(captain.SupporterBadgeTiers, badgeTiers) {
197197+ captain.SupporterBadgeTiers = badgeTiers
198198+ if _, err := s.PDS.UpdateCaptainRecord(badgeCtx, captain); err != nil {
199199+ slog.Warn("Failed to sync supporter badge tiers", "error", err)
200200+ } else {
201201+ slog.Info("Synced supporter badge tiers from quota config", "tiers", badgeTiers)
202202+ }
203203+ }
204204+ }
185205 }
186206187207 // Create XRPC handlers
···408428409429 logging.Shutdown()
410430}
431431+432432+// stringSlicesEqual returns true if two string slices have the same elements.
433433+func stringSlicesEqual(a, b []string) bool {
434434+ if len(a) != len(b) {
435435+ return false
436436+ }
437437+ for i := range a {
438438+ if a[i] != b[i] {
439439+ return false
440440+ }
441441+ }
442442+ return true
443443+}