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

begin implement supporter badges, clean up lexicons, various other changes

evan.jarrett.net f90a46e0 33548ecf

verified
+2575 -90
+4 -1
Makefile
··· 2 2 # Build targets for the ATProto Container Registry 3 3 4 4 .PHONY: all build build-appview build-hold build-credential-helper build-oauth-helper \ 5 - generate test test-race test-verbose lint clean help install-credential-helper \ 5 + generate test test-race test-verbose lint lex-lint clean help install-credential-helper \ 6 6 develop develop-detached develop-down dev \ 7 7 docker docker-appview docker-hold docker-scanner 8 8 ··· 74 74 lint: check-golangci-lint ## Run golangci-lint 75 75 @echo "→ Running golangci-lint..." 76 76 golangci-lint run ./... 77 + 78 + lex-lint: ## Lint ATProto lexicon schemas 79 + goat lex lint ./lexicons/ 77 80 78 81 ##@ Install Targets 79 82
+6 -1
cmd/record-query/main.go
··· 535 535 func resolveDIDToPDS(did string) (string, error) { 536 536 if strings.HasPrefix(did, "did:web:") { 537 537 domain := strings.TrimPrefix(did, "did:web:") 538 - return "https://" + domain, nil 538 + domain = strings.ReplaceAll(domain, "%3A", ":") 539 + scheme := "https" 540 + if strings.Contains(domain, ":") { 541 + scheme = "http" 542 + } 543 + return scheme + "://" + domain, nil 539 544 } 540 545 541 546 if strings.HasPrefix(did, "did:plc:") {
+7 -1
cmd/usage-report/main.go
··· 690 690 func resolveDIDToPDS(did string) (string, error) { 691 691 if strings.HasPrefix(did, "did:web:") { 692 692 // did:web:example.com -> https://example.com 693 + // did:web:host%3A8080 -> http://host:8080 693 694 domain := strings.TrimPrefix(did, "did:web:") 694 - return "https://" + domain, nil 695 + domain = strings.ReplaceAll(domain, "%3A", ":") 696 + scheme := "https" 697 + if strings.Contains(domain, ":") { 698 + scheme = "http" 699 + } 700 + return scheme + "://" + domain, nil 695 701 } 696 702 697 703 if strings.HasPrefix(did, "did:plc:") {
+28
config-hold.example.yaml
··· 102 102 bosun: 103 103 # Storage quota limit (e.g. "5GB", "50GB", "1TB"). 104 104 quota: 50GB 105 + # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling. 106 + scan_on_push: true 107 + # Maximum webhook URLs (0=none, -1=unlimited). Default: 1. 108 + max_webhooks: 5 109 + # Allow all webhook trigger types. Free tiers only get scan:first. 110 + webhook_all_triggers: true 111 + # Show supporter badge on user profiles for members at this tier. 112 + supporter_badge: true 105 113 deckhand: 106 114 # Storage quota limit (e.g. "5GB", "50GB", "1TB"). 107 115 quota: 5GB 116 + # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling. 117 + scan_on_push: false 118 + # Maximum webhook URLs (0=none, -1=unlimited). Default: 1. 119 + max_webhooks: 1 120 + # Allow all webhook trigger types. Free tiers only get scan:first. 121 + webhook_all_triggers: false 122 + # Show supporter badge on user profiles for members at this tier. 123 + supporter_badge: true 108 124 quartermaster: 109 125 # Storage quota limit (e.g. "5GB", "50GB", "1TB"). 110 126 quota: 100GB 127 + # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling. 128 + scan_on_push: true 129 + # Maximum webhook URLs (0=none, -1=unlimited). Default: 1. 130 + max_webhooks: -1 131 + # Allow all webhook trigger types. Free tiers only get scan:first. 132 + webhook_all_triggers: true 133 + # Show supporter badge on user profiles for members at this tier. 134 + supporter_badge: true 111 135 # Default tier assignment for new crew members. 112 136 defaults: 113 137 # Tier assigned to new crew members who don't have an explicit tier. 114 138 new_crew_tier: deckhand 139 + # Show supporter badge on the hold owner's profile. 140 + owner_badge: false 115 141 # Vulnerability scanner settings. Empty disables scanning. 116 142 scanner: 117 143 # Shared secret for scanner WebSocket auth. Empty disables scanning. 118 144 secret: "" 145 + # 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. 146 + rescan_interval: 168h0m0s
+11
deploy/upcloud/configs/hold.yaml.tmpl
··· 49 49 tiers: 50 50 deckhand: 51 51 quota: 5GB 52 + max_webhooks: 1 52 53 bosun: 53 54 quota: 50GB 55 + scan_on_push: true 56 + max_webhooks: 5 57 + webhook_all_triggers: true 58 + supporter_badge: true 54 59 quartermaster: 55 60 quota: 100GB 61 + scan_on_push: true 62 + max_webhooks: -1 63 + webhook_all_triggers: true 64 + supporter_badge: true 56 65 defaults: 57 66 new_crew_tier: deckhand 67 + owner_badge: true 58 68 scanner: 59 69 secret: "{{.ScannerSecret}}" 70 + rescan_interval: 168h0m0s 60 71
+2 -2
docker-compose.yml
··· 15 15 environment: 16 16 # ATCR_SERVER_CLIENT_NAME: "Seamark" 17 17 # ATCR_SERVER_CLIENT_SHORT_NAME: "Seamark" 18 - ATCR_SERVER_DEFAULT_HOLD_DID: did:web:172.28.0.3:8080 19 - ATCR_SERVER_TEST_MODE: "true" 18 + ATCR_SERVER_DEFAULT_HOLD_DID: did:web:172.28.0.3%3A8080 19 + ATCR_SERVER_TEST_MODE: true 20 20 ATCR_LOG_LEVEL: debug 21 21 LOG_SHIPPER_BACKEND: victoria 22 22 LOG_SHIPPER_URL: http://172.28.0.10:9428
+8
docs/HOLD_XRPC_ENDPOINTS.md
··· 43 43 |----------|--------|-------------| 44 44 | `/xrpc/io.atcr.hold.requestCrew` | POST | Request crew membership | 45 45 | `/xrpc/io.atcr.hold.exportUserData` | GET | GDPR data export (returns user's records) | 46 + | `/xrpc/io.atcr.hold.listWebhooks` | GET | List user's webhook configurations | 47 + | `/xrpc/io.atcr.hold.addWebhook` | POST | Add a webhook (tier-gated) | 48 + | `/xrpc/io.atcr.hold.deleteWebhook` | POST | Delete a webhook | 49 + | `/xrpc/io.atcr.hold.testWebhook` | POST | Send test payload to a webhook | 46 50 47 51 --- 48 52 ··· 74 78 | `/xrpc/io.atcr.hold.requestCrew` | POST | auth | Request crew membership | 75 79 | `/xrpc/io.atcr.hold.exportUserData` | GET | auth | GDPR data export | 76 80 | `/xrpc/io.atcr.hold.getQuota` | GET | none | Get user quota info | 81 + | `/xrpc/io.atcr.hold.listWebhooks` | GET | auth | List user's webhook configs | 82 + | `/xrpc/io.atcr.hold.addWebhook` | POST | auth | Add webhook (tier-gated) | 83 + | `/xrpc/io.atcr.hold.deleteWebhook` | POST | auth | Delete a webhook | 84 + | `/xrpc/io.atcr.hold.testWebhook` | POST | auth | Send test payload to webhook | 77 85 78 86 --- 79 87
+59
lexicons/io/atcr/hold/addWebhook.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "io.atcr.hold.addWebhook", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Add a new webhook configuration. Stores URL and optional HMAC secret in hold SQLite, creates an io.atcr.hold.webhook record in the embedded PDS. Enforces tier-based limits on webhook count and trigger types. Requires service token authentication.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["url", "triggers"], 13 + "properties": { 14 + "url": { 15 + "type": "string", 16 + "format": "uri", 17 + "maxLength": 2048, 18 + "description": "HTTPS URL to receive webhook payloads" 19 + }, 20 + "secret": { 21 + "type": "string", 22 + "description": "Optional HMAC-SHA256 signing secret. When set, payloads include an X-Webhook-Signature-256 header.", 23 + "maxLength": 256 24 + }, 25 + "triggers": { 26 + "type": "integer", 27 + "minimum": 1, 28 + "description": "Bitmask of trigger events: 0x01=scan:first, 0x02=scan:all, 0x04=scan:changed" 29 + } 30 + } 31 + } 32 + }, 33 + "output": { 34 + "encoding": "application/json", 35 + "schema": { 36 + "type": "object", 37 + "required": ["rkey", "cid"], 38 + "properties": { 39 + "rkey": { 40 + "type": "string", 41 + "maxLength": 64, 42 + "description": "Record key of the created io.atcr.hold.webhook record" 43 + }, 44 + "cid": { 45 + "type": "string", 46 + "maxLength": 128, 47 + "description": "CID of the created record (used as privateCid in the sailor webhook record)" 48 + } 49 + } 50 + } 51 + }, 52 + "errors": [ 53 + { "name": "InvalidUrl", "description": "URL is not a valid HTTPS endpoint" }, 54 + { "name": "WebhookLimitReached", "description": "User has reached the maximum number of webhooks for their tier" }, 55 + { "name": "TriggerNotAllowed", "description": "Trigger types beyond scan:first require a paid tier" } 56 + ] 57 + } 58 + } 59 + }
+13
lexicons/io/atcr/hold/captain.json
··· 36 36 "type": "string", 37 37 "description": "S3 region where blobs are stored", 38 38 "maxLength": 64 39 + }, 40 + "successor": { 41 + "type": "string", 42 + "format": "did", 43 + "description": "DID of successor hold for migration redirect" 44 + }, 45 + "supporterBadgeTiers": { 46 + "type": "array", 47 + "description": "Tier names that earn a supporter badge on user profiles", 48 + "items": { 49 + "type": "string", 50 + "maxLength": 64 51 + } 39 52 } 40 53 } 41 54 }
+41
lexicons/io/atcr/hold/deleteWebhook.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "io.atcr.hold.deleteWebhook", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Delete a webhook configuration. Removes URL and secret from hold SQLite and deletes the io.atcr.hold.webhook record from the embedded PDS. Only the webhook owner can delete their own webhooks. Requires service token authentication.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["rkey"], 13 + "properties": { 14 + "rkey": { 15 + "type": "string", 16 + "maxLength": 64, 17 + "description": "Record key of the io.atcr.hold.webhook record to delete" 18 + } 19 + } 20 + } 21 + }, 22 + "output": { 23 + "encoding": "application/json", 24 + "schema": { 25 + "type": "object", 26 + "required": ["success"], 27 + "properties": { 28 + "success": { 29 + "type": "boolean", 30 + "description": "Whether the webhook was successfully deleted" 31 + } 32 + } 33 + } 34 + }, 35 + "errors": [ 36 + { "name": "WebhookNotFound", "description": "No webhook found with the given rkey" }, 37 + { "name": "Unauthorized", "description": "Webhook belongs to a different user" } 38 + ] 39 + } 40 + } 41 + }
+86
lexicons/io/atcr/hold/listWebhooks.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "io.atcr.hold.listWebhooks", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "List webhook configurations for a user. Returns masked URLs (never full URLs), trigger settings, and tier-based limits. Requires service token authentication.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["userDid"], 11 + "properties": { 12 + "userDid": { 13 + "type": "string", 14 + "format": "did", 15 + "description": "DID of the user to list webhooks for" 16 + } 17 + } 18 + }, 19 + "output": { 20 + "encoding": "application/json", 21 + "schema": { 22 + "type": "object", 23 + "required": ["webhooks", "limits"], 24 + "properties": { 25 + "webhooks": { 26 + "type": "array", 27 + "description": "List of configured webhooks", 28 + "items": { 29 + "type": "ref", 30 + "ref": "#webhookEntry" 31 + } 32 + }, 33 + "limits": { 34 + "type": "ref", 35 + "ref": "#webhookLimits" 36 + } 37 + } 38 + } 39 + } 40 + }, 41 + "webhookEntry": { 42 + "type": "object", 43 + "required": ["rkey", "triggers", "url", "hasSecret", "createdAt"], 44 + "properties": { 45 + "rkey": { 46 + "type": "string", 47 + "maxLength": 64, 48 + "description": "Record key of the io.atcr.hold.webhook record" 49 + }, 50 + "triggers": { 51 + "type": "integer", 52 + "minimum": 0, 53 + "description": "Bitmask of trigger events" 54 + }, 55 + "url": { 56 + "type": "string", 57 + "maxLength": 2048, 58 + "description": "Masked webhook URL (e.g., https://exam***le.com/web***)" 59 + }, 60 + "hasSecret": { 61 + "type": "boolean", 62 + "description": "Whether the webhook has an HMAC signing secret configured" 63 + }, 64 + "createdAt": { 65 + "type": "string", 66 + "format": "datetime", 67 + "description": "RFC3339 timestamp of when the webhook was created" 68 + } 69 + } 70 + }, 71 + "webhookLimits": { 72 + "type": "object", 73 + "required": ["max", "allTriggers"], 74 + "properties": { 75 + "max": { 76 + "type": "integer", 77 + "description": "Maximum number of webhooks allowed for this user's tier (-1 for unlimited)" 78 + }, 79 + "allTriggers": { 80 + "type": "boolean", 81 + "description": "Whether the user's tier allows all trigger types (scan:all, scan:changed). Free tiers only get scan:first." 82 + } 83 + } 84 + } 85 + } 86 + }
+153
lexicons/io/atcr/hold/subscribeScanJobs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "io.atcr.hold.subscribeScanJobs", 4 + "defs": { 5 + "main": { 6 + "type": "subscription", 7 + "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).", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "cursor": { 12 + "type": "integer", 13 + "description": "Sequence number to resume from. If omitted, starts from latest. Use -1 to receive only new jobs." 14 + } 15 + } 16 + }, 17 + "message": { 18 + "schema": { 19 + "type": "union", 20 + "refs": ["#scanJob", "#scanResult"] 21 + } 22 + }, 23 + "errors": [ 24 + { "name": "InvalidSecret", "description": "Scanner shared secret is invalid" } 25 + ] 26 + }, 27 + "scanJob": { 28 + "type": "object", 29 + "description": "A scan job dispatched from hold to scanner. Sent as a JSON WebSocket message.", 30 + "required": ["type", "seq", "digest", "repository", "userDid", "holdDid", "holdEndpoint"], 31 + "properties": { 32 + "type": { 33 + "type": "string", 34 + "const": "scan_job", 35 + "maxLength": 32, 36 + "description": "Message type discriminator" 37 + }, 38 + "seq": { 39 + "type": "integer", 40 + "description": "Monotonic sequence number for cursor-based resumption" 41 + }, 42 + "digest": { 43 + "type": "string", 44 + "description": "Manifest digest (e.g., sha256:abc123...)", 45 + "maxLength": 128 46 + }, 47 + "repository": { 48 + "type": "string", 49 + "description": "Repository name (e.g., myapp)", 50 + "maxLength": 256 51 + }, 52 + "tag": { 53 + "type": "string", 54 + "description": "Optional tag that triggered the scan", 55 + "maxLength": 256 56 + }, 57 + "userDid": { 58 + "type": "string", 59 + "format": "did", 60 + "description": "DID of the image owner" 61 + }, 62 + "holdDid": { 63 + "type": "string", 64 + "format": "did", 65 + "description": "DID of the hold where the image is stored" 66 + }, 67 + "holdEndpoint": { 68 + "type": "string", 69 + "format": "uri", 70 + "description": "HTTP endpoint of the hold for blob downloads" 71 + }, 72 + "priority": { 73 + "type": "integer", 74 + "description": "Scan priority (lower = higher priority). Tier-based scheduling." 75 + } 76 + } 77 + }, 78 + "scanResult": { 79 + "type": "object", 80 + "description": "A scan result sent from scanner back to hold. Sent as a JSON WebSocket message.", 81 + "required": ["type", "digest", "summary"], 82 + "properties": { 83 + "type": { 84 + "type": "string", 85 + "const": "scan_result", 86 + "maxLength": 32, 87 + "description": "Message type discriminator" 88 + }, 89 + "digest": { 90 + "type": "string", 91 + "description": "Manifest digest that was scanned", 92 + "maxLength": 128 93 + }, 94 + "summary": { 95 + "type": "ref", 96 + "ref": "#vulnSummary", 97 + "description": "Vulnerability count summary" 98 + }, 99 + "sbom": { 100 + "type": "bytes", 101 + "maxLength": 104857600, 102 + "description": "SBOM blob (SPDX JSON format, max 100MB)" 103 + }, 104 + "vulnReport": { 105 + "type": "bytes", 106 + "maxLength": 104857600, 107 + "description": "Grype vulnerability report blob (JSON, max 100MB)" 108 + }, 109 + "scannerVersion": { 110 + "type": "string", 111 + "description": "Scanner version string", 112 + "maxLength": 64 113 + }, 114 + "error": { 115 + "type": "string", 116 + "maxLength": 1024, 117 + "description": "Error message if scan failed" 118 + } 119 + } 120 + }, 121 + "vulnSummary": { 122 + "type": "object", 123 + "required": ["critical", "high", "medium", "low", "total"], 124 + "properties": { 125 + "critical": { 126 + "type": "integer", 127 + "minimum": 0, 128 + "description": "Count of critical severity vulnerabilities" 129 + }, 130 + "high": { 131 + "type": "integer", 132 + "minimum": 0, 133 + "description": "Count of high severity vulnerabilities" 134 + }, 135 + "medium": { 136 + "type": "integer", 137 + "minimum": 0, 138 + "description": "Count of medium severity vulnerabilities" 139 + }, 140 + "low": { 141 + "type": "integer", 142 + "minimum": 0, 143 + "description": "Count of low severity vulnerabilities" 144 + }, 145 + "total": { 146 + "type": "integer", 147 + "minimum": 0, 148 + "description": "Total vulnerability count" 149 + } 150 + } 151 + } 152 + } 153 + }
+41
lexicons/io/atcr/hold/testWebhook.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "io.atcr.hold.testWebhook", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "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.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["rkey"], 13 + "properties": { 14 + "rkey": { 15 + "type": "string", 16 + "maxLength": 64, 17 + "description": "Record key of the io.atcr.hold.webhook to test" 18 + } 19 + } 20 + } 21 + }, 22 + "output": { 23 + "encoding": "application/json", 24 + "schema": { 25 + "type": "object", 26 + "required": ["success"], 27 + "properties": { 28 + "success": { 29 + "type": "boolean", 30 + "description": "Whether the test delivery received a 2xx response" 31 + } 32 + } 33 + } 34 + }, 35 + "errors": [ 36 + { "name": "WebhookNotFound", "description": "No webhook found with the given rkey" }, 37 + { "name": "Unauthorized", "description": "Webhook belongs to a different user" } 38 + ] 39 + } 40 + } 41 + }
+32
lexicons/io/atcr/hold/webhook.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "io.atcr.hold.webhook", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "any", 8 + "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.", 9 + "record": { 10 + "type": "object", 11 + "required": ["userDid", "triggers", "createdAt"], 12 + "properties": { 13 + "userDid": { 14 + "type": "string", 15 + "format": "did", 16 + "description": "DID of the webhook owner" 17 + }, 18 + "triggers": { 19 + "type": "integer", 20 + "minimum": 0, 21 + "description": "Bitmask of trigger events: 0x01=scan:first, 0x02=scan:all, 0x04=scan:changed" 22 + }, 23 + "createdAt": { 24 + "type": "string", 25 + "format": "datetime", 26 + "description": "RFC3339 timestamp of when the webhook was created" 27 + } 28 + } 29 + } 30 + } 31 + } 32 + }
+42
lexicons/io/atcr/sailor/webhook.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "io.atcr.sailor.webhook", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "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.", 9 + "record": { 10 + "type": "object", 11 + "required": ["holdDid", "triggers", "privateCid", "createdAt"], 12 + "properties": { 13 + "holdDid": { 14 + "type": "string", 15 + "format": "did", 16 + "description": "DID of the hold where the webhook is configured" 17 + }, 18 + "triggers": { 19 + "type": "integer", 20 + "minimum": 0, 21 + "description": "Bitmask of trigger events: 0x01=scan:first, 0x02=scan:all, 0x04=scan:changed" 22 + }, 23 + "privateCid": { 24 + "type": "string", 25 + "maxLength": 128, 26 + "description": "CID of the corresponding io.atcr.hold.webhook record on the hold" 27 + }, 28 + "createdAt": { 29 + "type": "string", 30 + "format": "datetime", 31 + "description": "RFC3339 timestamp of when the webhook was created" 32 + }, 33 + "updatedAt": { 34 + "type": "string", 35 + "format": "datetime", 36 + "description": "RFC3339 timestamp of when the webhook was last updated" 37 + } 38 + } 39 + } 40 + } 41 + } 42 + }
+76 -12
pkg/appview/db/hold_store.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "encoding/json" 5 6 "fmt" 6 7 "strings" 7 8 "time" ··· 19 20 20 21 // HoldCaptainRecord represents a cached captain record from a hold's PDS 21 22 type HoldCaptainRecord struct { 22 - HoldDID string `json:"-"` // Set manually, not from JSON 23 - OwnerDID string `json:"owner"` 24 - Public bool `json:"public"` 25 - AllowAllCrew bool `json:"allowAllCrew"` 26 - DeployedAt string `json:"deployedAt"` 27 - Region string `json:"region"` 28 - Successor string `json:"successor"` // DID of successor hold (migration redirect) 29 - UpdatedAt time.Time `json:"-"` // Set manually, not from JSON 23 + HoldDID string `json:"-"` // Set manually, not from JSON 24 + OwnerDID string `json:"owner"` 25 + Public bool `json:"public"` 26 + AllowAllCrew bool `json:"allowAllCrew"` 27 + DeployedAt string `json:"deployedAt"` 28 + Region string `json:"region"` 29 + Successor string `json:"successor"` // DID of successor hold (migration redirect) 30 + SupporterBadgeTiers string `json:"-"` // JSON array of tier names, e.g. '["bosun","quartermaster"]' 31 + UpdatedAt time.Time `json:"-"` // Set manually, not from JSON 30 32 } 31 33 32 34 // GetCaptainRecord retrieves a captain record from the cache ··· 34 36 func GetCaptainRecord(db DBTX, holdDID string) (*HoldCaptainRecord, error) { 35 37 query := ` 36 38 SELECT hold_did, owner_did, public, allow_all_crew, 37 - deployed_at, region, successor, updated_at 39 + deployed_at, region, successor, supporter_badge_tiers, updated_at 38 40 FROM hold_captain_records 39 41 WHERE hold_did = ? 40 42 ` 41 43 42 44 var record HoldCaptainRecord 43 - var deployedAt, region, successor sql.NullString 45 + var deployedAt, region, successor, supporterBadgeTiers sql.NullString 44 46 45 47 err := db.QueryRow(query, holdDID).Scan( 46 48 &record.HoldDID, ··· 50 52 &deployedAt, 51 53 &region, 52 54 &successor, 55 + &supporterBadgeTiers, 53 56 &record.UpdatedAt, 54 57 ) 55 58 ··· 71 74 if successor.Valid { 72 75 record.Successor = successor.String 73 76 } 77 + if supporterBadgeTiers.Valid { 78 + record.SupporterBadgeTiers = supporterBadgeTiers.String 79 + } 74 80 75 81 return &record, nil 76 82 } ··· 80 86 query := ` 81 87 INSERT INTO hold_captain_records ( 82 88 hold_did, owner_did, public, allow_all_crew, 83 - deployed_at, region, successor, updated_at 84 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) 89 + deployed_at, region, successor, supporter_badge_tiers, updated_at 90 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 85 91 ON CONFLICT(hold_did) DO UPDATE SET 86 92 owner_did = excluded.owner_did, 87 93 public = excluded.public, ··· 89 95 deployed_at = excluded.deployed_at, 90 96 region = excluded.region, 91 97 successor = excluded.successor, 98 + supporter_badge_tiers = excluded.supporter_badge_tiers, 92 99 updated_at = excluded.updated_at 93 100 ` 94 101 ··· 100 107 nullString(record.DeployedAt), 101 108 nullString(record.Region), 102 109 nullString(record.Successor), 110 + nullString(record.SupporterBadgeTiers), 103 111 record.UpdatedAt, 104 112 ) 105 113 ··· 108 116 } 109 117 110 118 return nil 119 + } 120 + 121 + // HasSupporterBadge checks if a given tier is in the hold's supporter badge tiers list. 122 + func (r *HoldCaptainRecord) HasSupporterBadge(tier string) bool { 123 + if r.SupporterBadgeTiers == "" || tier == "" { 124 + return false 125 + } 126 + var tiers []string 127 + if err := json.Unmarshal([]byte(r.SupporterBadgeTiers), &tiers); err != nil { 128 + return false 129 + } 130 + for _, t := range tiers { 131 + if t == tier { 132 + return true 133 + } 134 + } 135 + return false 136 + } 137 + 138 + // GetSupporterBadge returns the supporter badge tier name for a user on a specific hold. 139 + // Returns empty string if the hold doesn't have badges, the user's tier isn't badge-eligible, 140 + // or the user isn't a member of the hold. 141 + func GetSupporterBadge(dbConn DBTX, userDID, holdDID string) string { 142 + if holdDID == "" || userDID == "" { 143 + return "" 144 + } 145 + 146 + captain, err := GetCaptainRecord(dbConn, holdDID) 147 + if err != nil || captain == nil || captain.SupporterBadgeTiers == "" { 148 + return "" 149 + } 150 + 151 + // Check if user is the captain (owner) 152 + if captain.OwnerDID == userDID { 153 + if captain.HasSupporterBadge("owner") { 154 + return "owner" 155 + } 156 + return "" 157 + } 158 + 159 + // Look up crew membership for this user on this hold 160 + memberships, err := GetCrewMemberships(dbConn, userDID) 161 + if err != nil { 162 + return "" 163 + } 164 + 165 + for _, m := range memberships { 166 + if m.HoldDID == holdDID && m.Tier != "" { 167 + if captain.HasSupporterBadge(m.Tier) { 168 + return m.Tier 169 + } 170 + return "" 171 + } 172 + } 173 + 174 + return "" 111 175 } 112 176 113 177 // ListHoldDIDs returns all known hold DIDs from the cache
+4
pkg/appview/db/migrations/0014_add_supporter_badge_and_default_hold.yaml
··· 1 + description: Add default_hold_did to users and supporter_badge_tiers to hold_captain_records 2 + query: | 3 + ALTER TABLE users ADD COLUMN default_hold_did TEXT; 4 + ALTER TABLE hold_captain_records ADD COLUMN supporter_badge_tiers TEXT;
+6 -5
pkg/appview/db/models.go
··· 4 4 5 5 // User represents a user in the system 6 6 type User struct { 7 - DID string 8 - Handle string 9 - PDSEndpoint string 10 - Avatar string 11 - LastSeen time.Time 7 + DID string 8 + Handle string 9 + PDSEndpoint string 10 + Avatar string 11 + DefaultHoldDID string 12 + LastSeen time.Time 12 13 } 13 14 14 15 // Manifest represents an OCI manifest stored in the cache
+45 -8
pkg/appview/db/queries.go
··· 318 318 // GetUserByDID retrieves a user by DID 319 319 func GetUserByDID(db DBTX, did string) (*User, error) { 320 320 var user User 321 - var avatar sql.NullString 321 + var avatar, defaultHoldDID sql.NullString 322 322 err := db.QueryRow(` 323 - SELECT did, handle, pds_endpoint, avatar, last_seen 323 + SELECT did, handle, pds_endpoint, avatar, default_hold_did, last_seen 324 324 FROM users 325 325 WHERE did = ? 326 - `, did).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &avatar, &user.LastSeen) 326 + `, did).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &avatar, &defaultHoldDID, &user.LastSeen) 327 327 328 328 if err == sql.ErrNoRows { 329 329 return nil, nil ··· 332 332 return nil, err 333 333 } 334 334 335 - // Handle NULL avatar 336 335 if avatar.Valid { 337 336 user.Avatar = avatar.String 338 337 } 338 + if defaultHoldDID.Valid { 339 + user.DefaultHoldDID = defaultHoldDID.String 340 + } 339 341 340 342 return &user, nil 341 343 } ··· 343 345 // GetUserByHandle retrieves a user by handle 344 346 func GetUserByHandle(db DBTX, handle string) (*User, error) { 345 347 var user User 346 - var avatar sql.NullString 348 + var avatar, defaultHoldDID sql.NullString 347 349 err := db.QueryRow(` 348 - SELECT did, handle, pds_endpoint, avatar, last_seen 350 + SELECT did, handle, pds_endpoint, avatar, default_hold_did, last_seen 349 351 FROM users 350 352 WHERE handle = ? 351 - `, handle).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &avatar, &user.LastSeen) 353 + `, handle).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &avatar, &defaultHoldDID, &user.LastSeen) 352 354 353 355 if err == sql.ErrNoRows { 354 356 return nil, nil ··· 357 359 return nil, err 358 360 } 359 361 360 - // Handle NULL avatar 361 362 if avatar.Valid { 362 363 user.Avatar = avatar.String 364 + } 365 + if defaultHoldDID.Valid { 366 + user.DefaultHoldDID = defaultHoldDID.String 363 367 } 364 368 365 369 return &user, nil ··· 409 413 UPDATE users SET handle = ?, last_seen = ? WHERE did = ? 410 414 `, newHandle, time.Now(), did) 411 415 return err 416 + } 417 + 418 + // UpdateUserDefaultHold updates a user's cached default hold DID 419 + // This is called when Jetstream receives a sailor profile update 420 + func UpdateUserDefaultHold(db DBTX, did string, holdDID string) error { 421 + _, err := db.Exec(` 422 + UPDATE users SET default_hold_did = ? WHERE did = ? 423 + `, holdDID, did) 424 + return err 425 + } 426 + 427 + // GetUserHoldDID returns the hold DID for a user. Uses cached default_hold_did 428 + // if available, otherwise falls back to the most recent manifest's hold_endpoint. 429 + func GetUserHoldDID(db DBTX, did string) string { 430 + // Try cached default hold first 431 + var holdDID sql.NullString 432 + _ = db.QueryRow(`SELECT default_hold_did FROM users WHERE did = ?`, did).Scan(&holdDID) 433 + if holdDID.Valid && holdDID.String != "" { 434 + return holdDID.String 435 + } 436 + 437 + // Fallback: most recent manifest's hold 438 + var manifestHold string 439 + err := db.QueryRow(` 440 + SELECT hold_endpoint FROM manifests 441 + WHERE did = ? 442 + ORDER BY created_at DESC 443 + LIMIT 1 444 + `, did).Scan(&manifestHold) 445 + if err != nil { 446 + return "" 447 + } 448 + return manifestHold 412 449 } 413 450 414 451 // UpdateUserAvatar updates a user's avatar URL when a profile change is detected
+2
pkg/appview/db/schema.sql
··· 12 12 handle TEXT NOT NULL, 13 13 pds_endpoint TEXT NOT NULL, 14 14 avatar TEXT, 15 + default_hold_did TEXT, 15 16 last_seen TIMESTAMP NOT NULL, 16 17 UNIQUE(handle) 17 18 ); ··· 185 186 deployed_at TEXT, 186 187 region TEXT, 187 188 successor TEXT, 189 + supporter_badge_tiers TEXT, 188 190 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 189 191 ); 190 192 CREATE INDEX IF NOT EXISTS idx_hold_captain_updated ON hold_captain_records(updated_at);
+58
pkg/appview/handlers/settings.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "database/sql" 5 6 "encoding/json" 6 7 "html/template" 7 8 "log/slog" ··· 268 269 return 269 270 } 270 271 272 + // Cache default hold DID locally (don't wait for Jetstream roundtrip) 273 + if h.DB != nil { 274 + _ = db.UpdateUserDefaultHold(h.DB, user.DID, holdDID) 275 + 276 + // Refresh captain record for the selected hold so badge tiers are available immediately 277 + if holdDID != "" { 278 + go refreshCaptainRecord(holdDID, h.DB) 279 + } 280 + } 281 + 271 282 w.Header().Set("Content-Type", "text/html") 272 283 if err := h.Templates.ExecuteTemplate(w, "alert", map[string]string{ 273 284 "Type": "success", ··· 276 287 slog.Warn("Failed to render alert", "error", err) 277 288 } 278 289 } 290 + 291 + // refreshCaptainRecord fetches a hold's captain record via XRPC and caches it locally. 292 + // This ensures badge tiers and other captain metadata are available immediately 293 + // without waiting for Jetstream or the next backfill cycle. 294 + func refreshCaptainRecord(holdDID string, dbConn *sql.DB) { 295 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 296 + defer cancel() 297 + 298 + holdURL, err := atproto.ResolveHoldURL(ctx, holdDID) 299 + if err != nil { 300 + slog.Debug("Failed to resolve hold URL for captain refresh", "hold_did", holdDID, "error", err) 301 + return 302 + } 303 + 304 + holdClient := atproto.NewClient(holdURL, holdDID, "") 305 + record, err := holdClient.GetRecord(ctx, "io.atcr.hold.captain", "self") 306 + if err != nil { 307 + slog.Debug("Failed to fetch captain record for refresh", "hold_did", holdDID, "error", err) 308 + return 309 + } 310 + 311 + var captainRecord db.HoldCaptainRecord 312 + if err := json.Unmarshal(record.Value, &captainRecord); err != nil { 313 + slog.Debug("Failed to parse captain record for refresh", "hold_did", holdDID, "error", err) 314 + return 315 + } 316 + 317 + captainRecord.HoldDID = holdDID 318 + captainRecord.UpdatedAt = time.Now() 319 + 320 + // Extract supporterBadgeTiers from raw JSON (db struct uses json:"-") 321 + var raw struct { 322 + SupporterBadgeTiers []string `json:"supporterBadgeTiers"` 323 + } 324 + if err := json.Unmarshal(record.Value, &raw); err == nil && len(raw.SupporterBadgeTiers) > 0 { 325 + if jsonBytes, err := json.Marshal(raw.SupporterBadgeTiers); err == nil { 326 + captainRecord.SupporterBadgeTiers = string(jsonBytes) 327 + } 328 + } 329 + 330 + if err := db.UpsertCaptainRecord(dbConn, &captainRecord); err != nil { 331 + slog.Debug("Failed to cache captain record on refresh", "hold_did", holdDID, "error", err) 332 + return 333 + } 334 + 335 + slog.Info("Refreshed captain record for hold", "hold_did", holdDID, "badge_tiers", captainRecord.SupporterBadgeTiers) 336 + }
+26 -16
pkg/appview/handlers/storage.go
··· 6 6 "log/slog" 7 7 "net/http" 8 8 9 + "atcr.io/pkg/appview/db" 9 10 "atcr.io/pkg/appview/middleware" 10 11 "atcr.io/pkg/appview/storage" 11 12 "atcr.io/pkg/atproto" ··· 83 84 } 84 85 85 86 // Render the stats partial 86 - h.renderStats(w, stats) 87 + h.renderStats(w, stats, holdDID) 87 88 } 88 89 89 - func (h *StorageHandler) renderStats(w http.ResponseWriter, stats QuotaStats) { 90 + func (h *StorageHandler) renderStats(w http.ResponseWriter, stats QuotaStats, holdDID string) { 90 91 // Calculate usage percentage if limit exists 91 92 var usagePercent int 92 93 var hasLimit bool ··· 99 100 if usagePercent > 100 { 100 101 usagePercent = 100 101 102 } 103 + } 104 + 105 + // Check if user's tier earns a supporter badge on this hold 106 + var hasSupporterBadge bool 107 + if stats.Tier != "" && h.ReadOnlyDB != nil && holdDID != "" { 108 + badge := db.GetSupporterBadge(h.ReadOnlyDB, stats.UserDID, holdDID) 109 + hasSupporterBadge = badge != "" 102 110 } 103 111 104 112 data := struct { 105 - UniqueBlobs int 106 - TotalSize int64 107 - HumanSize string 108 - HasLimit bool 109 - HumanLimit string 110 - UsagePercent int 111 - Tier string 113 + UniqueBlobs int 114 + TotalSize int64 115 + HumanSize string 116 + HasLimit bool 117 + HumanLimit string 118 + UsagePercent int 119 + Tier string 120 + HasSupporterBadge bool 112 121 }{ 113 - UniqueBlobs: stats.UniqueBlobs, 114 - TotalSize: stats.TotalSize, 115 - HumanSize: humanizeBytes(stats.TotalSize), 116 - HasLimit: hasLimit, 117 - HumanLimit: humanLimit, 118 - UsagePercent: usagePercent, 119 - Tier: stats.Tier, 122 + UniqueBlobs: stats.UniqueBlobs, 123 + TotalSize: stats.TotalSize, 124 + HumanSize: humanizeBytes(stats.TotalSize), 125 + HasLimit: hasLimit, 126 + HumanLimit: humanLimit, 127 + UsagePercent: usagePercent, 128 + Tier: stats.Tier, 129 + HasSupporterBadge: hasSupporterBadge, 120 130 } 121 131 122 132 w.Header().Set("Content-Type", "text/html")
+17 -9
pkg/appview/handlers/user.go
··· 62 62 } 63 63 db.SetRegistryURL(cards, h.RegistryURL) 64 64 65 + // Check for supporter badge on user's default hold 66 + var supporterBadge string 67 + if hasProfile && h.ReadOnlyDB != nil && viewedUser.DefaultHoldDID != "" { 68 + supporterBadge = db.GetSupporterBadge(h.ReadOnlyDB, viewedUser.DID, viewedUser.DefaultHoldDID) 69 + } 70 + 65 71 // Build page meta 66 72 meta := NewPageMeta( 67 73 viewedUser.Handle+" - "+h.ClientShortName, ··· 75 81 76 82 data := struct { 77 83 PageData 78 - Meta *PageMeta 79 - ViewedUser *db.User // User whose page we're viewing 80 - Repositories []db.RepoCardData 81 - HasProfile bool 84 + Meta *PageMeta 85 + ViewedUser *db.User // User whose page we're viewing 86 + Repositories []db.RepoCardData 87 + HasProfile bool 88 + SupporterBadge string 82 89 }{ 83 - PageData: NewPageData(r, &h.BaseUIHandler), 84 - Meta: meta, 85 - ViewedUser: viewedUser, 86 - Repositories: cards, 87 - HasProfile: hasProfile, 90 + PageData: NewPageData(r, &h.BaseUIHandler), 91 + Meta: meta, 92 + ViewedUser: viewedUser, 93 + Repositories: cards, 94 + HasProfile: hasProfile, 95 + SupporterBadge: supporterBadge, 88 96 } 89 97 90 98 if err := h.Templates.ExecuteTemplate(w, "user", data); err != nil {
+405
pkg/appview/handlers/webhooks.go
··· 1 + package handlers 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json" 6 + "fmt" 7 + "log/slog" 8 + "net/http" 9 + 10 + "atcr.io/pkg/appview/db" 11 + "atcr.io/pkg/appview/middleware" 12 + "atcr.io/pkg/appview/storage" 13 + "atcr.io/pkg/atproto" 14 + "atcr.io/pkg/auth" 15 + "github.com/go-chi/chi/v5" 16 + ) 17 + 18 + // webhookListResponse mirrors the hold's listWebhooks response 19 + type webhookListResponse struct { 20 + Webhooks []webhookEntry `json:"webhooks"` 21 + Limits webhookLimits `json:"limits"` 22 + } 23 + 24 + type webhookEntry struct { 25 + Rkey string `json:"rkey"` 26 + Triggers int `json:"triggers"` 27 + URL string `json:"url"` 28 + HasSecret bool `json:"hasSecret"` 29 + CreatedAt string `json:"createdAt"` 30 + 31 + // Computed fields (not from JSON) 32 + HasFirst bool 33 + HasAll bool 34 + HasChanged bool 35 + } 36 + 37 + type webhookLimits struct { 38 + Max int `json:"max"` 39 + AllTriggers bool `json:"allTriggers"` 40 + } 41 + 42 + // WebhooksHandler returns the webhooks list partial via HTMX 43 + type WebhooksHandler struct { 44 + BaseUIHandler 45 + } 46 + 47 + func (h *WebhooksHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 48 + user := middleware.GetUser(r) 49 + if user == nil { 50 + h.renderWebhookError(w, "Authentication required") 51 + return 52 + } 53 + 54 + holdDID, holdEndpoint, err := h.resolveUserHold(r, user) 55 + if err != nil { 56 + h.renderWebhookError(w, "Could not resolve hold: "+err.Error()) 57 + return 58 + } 59 + 60 + serviceToken, err := auth.GetOrFetchServiceToken(r.Context(), h.Refresher, user.DID, holdDID, user.PDSEndpoint) 61 + if err != nil { 62 + h.renderWebhookError(w, "Failed to authenticate with hold") 63 + return 64 + } 65 + 66 + // Fetch webhooks from hold 67 + listURL := fmt.Sprintf("%s%s?userDid=%s", holdEndpoint, atproto.HoldListWebhooks, user.DID) 68 + req, _ := http.NewRequestWithContext(r.Context(), "GET", listURL, nil) 69 + req.Header.Set("Authorization", "Bearer "+serviceToken) 70 + 71 + resp, err := http.DefaultClient.Do(req) 72 + if err != nil { 73 + slog.Warn("Failed to fetch webhooks from hold", "error", err) 74 + h.renderWebhookError(w, "Hold unreachable") 75 + return 76 + } 77 + defer resp.Body.Close() 78 + 79 + if resp.StatusCode != http.StatusOK { 80 + h.renderWebhookError(w, fmt.Sprintf("Hold returned status %d", resp.StatusCode)) 81 + return 82 + } 83 + 84 + var listResp webhookListResponse 85 + if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { 86 + h.renderWebhookError(w, "Invalid response from hold") 87 + return 88 + } 89 + 90 + h.renderWebhookList(w, listResp, holdDID) 91 + } 92 + 93 + // AddWebhookHandler handles adding a new webhook via form POST 94 + type AddWebhookHandler struct { 95 + BaseUIHandler 96 + } 97 + 98 + func (h *AddWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 99 + user := middleware.GetUser(r) 100 + if user == nil { 101 + h.renderWebhookError(w, "Authentication required") 102 + return 103 + } 104 + 105 + webhookURL := r.FormValue("url") 106 + secret := r.FormValue("secret") 107 + if webhookURL == "" { 108 + h.renderWebhookError(w, "URL is required") 109 + return 110 + } 111 + 112 + // Parse trigger checkboxes 113 + triggers := 0 114 + if r.FormValue("trigger_first") == "on" { 115 + triggers |= atproto.TriggerFirst 116 + } 117 + if r.FormValue("trigger_all") == "on" { 118 + triggers |= atproto.TriggerAll 119 + } 120 + if r.FormValue("trigger_changed") == "on" { 121 + triggers |= atproto.TriggerChanged 122 + } 123 + if triggers == 0 { 124 + triggers = atproto.TriggerFirst // default 125 + } 126 + 127 + holdDID, holdEndpoint, err := h.resolveUserHold(r, user) 128 + if err != nil { 129 + h.renderWebhookError(w, "Could not resolve hold") 130 + return 131 + } 132 + 133 + serviceToken, err := auth.GetOrFetchServiceToken(r.Context(), h.Refresher, user.DID, holdDID, user.PDSEndpoint) 134 + if err != nil { 135 + h.renderWebhookError(w, "Failed to authenticate with hold") 136 + return 137 + } 138 + 139 + // Call hold addWebhook 140 + addBody, _ := json.Marshal(map[string]any{ 141 + "url": webhookURL, 142 + "secret": secret, 143 + "triggers": triggers, 144 + }) 145 + 146 + addURL := holdEndpoint + atproto.HoldAddWebhook 147 + req, _ := http.NewRequestWithContext(r.Context(), "POST", addURL, bytes.NewReader(addBody)) 148 + req.Header.Set("Authorization", "Bearer "+serviceToken) 149 + req.Header.Set("Content-Type", "application/json") 150 + 151 + resp, err := http.DefaultClient.Do(req) 152 + if err != nil { 153 + h.renderWebhookError(w, "Hold unreachable") 154 + return 155 + } 156 + defer resp.Body.Close() 157 + 158 + if resp.StatusCode != http.StatusCreated { 159 + var errBody struct { 160 + Message string `json:"message"` 161 + } 162 + body := make([]byte, 512) 163 + n, _ := resp.Body.Read(body) 164 + _ = json.Unmarshal(body[:n], &errBody) 165 + msg := string(body[:n]) 166 + if errBody.Message != "" { 167 + msg = errBody.Message 168 + } 169 + h.renderWebhookError(w, "Failed to add webhook: "+msg) 170 + return 171 + } 172 + 173 + var addResp struct { 174 + Rkey string `json:"rkey"` 175 + CID string `json:"cid"` 176 + } 177 + _ = json.NewDecoder(resp.Body).Decode(&addResp) 178 + 179 + // Write sailor webhook record to user's PDS 180 + client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) 181 + sailorRecord := atproto.NewSailorWebhookRecord(holdDID, triggers, addResp.CID) 182 + if _, err := client.PutRecord(r.Context(), atproto.SailorWebhookCollection, addResp.Rkey, sailorRecord); err != nil { 183 + slog.Warn("Failed to write sailor webhook record to PDS (hold record exists)", 184 + "did", user.DID, "rkey", addResp.Rkey, "error", err) 185 + // Not fatal — hold has the record, PDS write is best-effort 186 + } 187 + 188 + // Re-render the full list 189 + h.refetchAndRender(w, r, user, holdDID, holdEndpoint, serviceToken) 190 + } 191 + 192 + // DeleteWebhookHandler handles deleting a webhook 193 + type DeleteWebhookHandler struct { 194 + BaseUIHandler 195 + } 196 + 197 + func (h *DeleteWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 198 + user := middleware.GetUser(r) 199 + if user == nil { 200 + h.renderWebhookError(w, "Authentication required") 201 + return 202 + } 203 + 204 + rkey := chi.URLParam(r, "id") 205 + if rkey == "" { 206 + h.renderWebhookError(w, "Missing webhook ID") 207 + return 208 + } 209 + 210 + holdDID, holdEndpoint, err := h.resolveUserHold(r, user) 211 + if err != nil { 212 + h.renderWebhookError(w, "Could not resolve hold") 213 + return 214 + } 215 + 216 + serviceToken, err := auth.GetOrFetchServiceToken(r.Context(), h.Refresher, user.DID, holdDID, user.PDSEndpoint) 217 + if err != nil { 218 + h.renderWebhookError(w, "Failed to authenticate with hold") 219 + return 220 + } 221 + 222 + // Call hold deleteWebhook 223 + delBody, _ := json.Marshal(map[string]string{"rkey": rkey}) 224 + delURL := holdEndpoint + atproto.HoldDeleteWebhook 225 + req, _ := http.NewRequestWithContext(r.Context(), "POST", delURL, bytes.NewReader(delBody)) 226 + req.Header.Set("Authorization", "Bearer "+serviceToken) 227 + req.Header.Set("Content-Type", "application/json") 228 + 229 + resp, err := http.DefaultClient.Do(req) 230 + if err != nil { 231 + h.renderWebhookError(w, "Hold unreachable") 232 + return 233 + } 234 + defer resp.Body.Close() 235 + 236 + if resp.StatusCode != http.StatusOK { 237 + h.renderWebhookError(w, "Failed to delete webhook") 238 + return 239 + } 240 + 241 + // Delete sailor webhook record from PDS (best-effort) 242 + client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) 243 + if err := client.DeleteRecord(r.Context(), atproto.SailorWebhookCollection, rkey); err != nil { 244 + slog.Warn("Failed to delete sailor webhook record from PDS", 245 + "did", user.DID, "rkey", rkey, "error", err) 246 + } 247 + 248 + // Re-render the full list 249 + h.refetchAndRender(w, r, user, holdDID, holdEndpoint, serviceToken) 250 + } 251 + 252 + // TestWebhookHandler sends a test payload 253 + type TestWebhookHandler struct { 254 + BaseUIHandler 255 + } 256 + 257 + func (h *TestWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 258 + user := middleware.GetUser(r) 259 + if user == nil { 260 + h.renderWebhookError(w, "Authentication required") 261 + return 262 + } 263 + 264 + rkey := chi.URLParam(r, "id") 265 + if rkey == "" { 266 + h.renderWebhookError(w, "Missing webhook ID") 267 + return 268 + } 269 + 270 + holdDID, holdEndpoint, err := h.resolveUserHold(r, user) 271 + if err != nil { 272 + h.renderWebhookError(w, "Could not resolve hold") 273 + return 274 + } 275 + 276 + serviceToken, err := auth.GetOrFetchServiceToken(r.Context(), h.Refresher, user.DID, holdDID, user.PDSEndpoint) 277 + if err != nil { 278 + h.renderWebhookError(w, "Failed to authenticate with hold") 279 + return 280 + } 281 + 282 + testBody, _ := json.Marshal(map[string]string{"rkey": rkey}) 283 + testURL := holdEndpoint + atproto.HoldTestWebhook 284 + req, _ := http.NewRequestWithContext(r.Context(), "POST", testURL, bytes.NewReader(testBody)) 285 + req.Header.Set("Authorization", "Bearer "+serviceToken) 286 + req.Header.Set("Content-Type", "application/json") 287 + 288 + resp, err := http.DefaultClient.Do(req) 289 + if err != nil { 290 + h.renderAlert(w, "error", "Hold unreachable") 291 + return 292 + } 293 + defer resp.Body.Close() 294 + 295 + var testResp struct { 296 + Success bool `json:"success"` 297 + } 298 + _ = json.NewDecoder(resp.Body).Decode(&testResp) 299 + 300 + if testResp.Success { 301 + h.renderAlert(w, "success", "Test webhook delivered successfully!") 302 + } else { 303 + h.renderAlert(w, "error", "Test delivery failed - check the webhook URL") 304 + } 305 + } 306 + 307 + // ---- Shared helpers ---- 308 + 309 + func (h *BaseUIHandler) resolveUserHold(r *http.Request, user *db.User) (holdDID, holdEndpoint string, err error) { 310 + client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) 311 + profile, profileErr := storage.GetProfile(r.Context(), client) 312 + 313 + holdDID = h.DefaultHoldDID 314 + if profileErr == nil && profile != nil && profile.DefaultHold != "" { 315 + holdDID = profile.DefaultHold 316 + } 317 + 318 + if holdDID == "" { 319 + return "", "", fmt.Errorf("no hold configured") 320 + } 321 + 322 + holdEndpoint, err = atproto.ResolveHoldURL(r.Context(), holdDID) 323 + if err != nil { 324 + return holdDID, "", fmt.Errorf("failed to resolve hold: %w", err) 325 + } 326 + return holdDID, holdEndpoint, nil 327 + } 328 + 329 + func (h *BaseUIHandler) refetchAndRender(w http.ResponseWriter, r *http.Request, user *db.User, holdDID, holdEndpoint, serviceToken string) { 330 + listURL := fmt.Sprintf("%s%s?userDid=%s", holdEndpoint, atproto.HoldListWebhooks, user.DID) 331 + req, _ := http.NewRequestWithContext(r.Context(), "GET", listURL, nil) 332 + req.Header.Set("Authorization", "Bearer "+serviceToken) 333 + 334 + resp, err := http.DefaultClient.Do(req) 335 + if err != nil { 336 + h.renderWebhookError(w, "Failed to refresh webhook list") 337 + return 338 + } 339 + defer resp.Body.Close() 340 + 341 + var listResp webhookListResponse 342 + if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { 343 + h.renderWebhookError(w, "Invalid response from hold") 344 + return 345 + } 346 + 347 + h.renderWebhookList(w, listResp, holdDID) 348 + } 349 + 350 + func (h *BaseUIHandler) renderWebhookList(w http.ResponseWriter, data webhookListResponse, holdDID string) { 351 + w.Header().Set("Content-Type", "text/html") 352 + 353 + // Populate computed trigger fields from bitmask 354 + for i := range data.Webhooks { 355 + data.Webhooks[i].HasFirst = data.Webhooks[i].Triggers&atproto.TriggerFirst != 0 356 + data.Webhooks[i].HasAll = data.Webhooks[i].Triggers&atproto.TriggerAll != 0 357 + data.Webhooks[i].HasChanged = data.Webhooks[i].Triggers&atproto.TriggerChanged != 0 358 + } 359 + 360 + templateData := struct { 361 + Webhooks []webhookEntry 362 + Limits webhookLimits 363 + HoldDID string 364 + TriggerInfo []triggerInfo 365 + }{ 366 + Webhooks: data.Webhooks, 367 + Limits: data.Limits, 368 + HoldDID: holdDID, 369 + TriggerInfo: []triggerInfo{ 370 + {Name: "scan:first", Bit: atproto.TriggerFirst, Label: "First scan", Description: "When an image is scanned for the first time", AlwaysAvailable: true}, 371 + {Name: "scan:all", Bit: atproto.TriggerAll, Label: "Every scan", Description: "On every scan completion"}, 372 + {Name: "scan:changed", Bit: atproto.TriggerChanged, Label: "Vulnerability change", Description: "When vulnerability counts change"}, 373 + }, 374 + } 375 + 376 + if err := h.Templates.ExecuteTemplate(w, "webhooks_list", templateData); err != nil { 377 + slog.Error("Failed to render webhooks template", "error", err) 378 + h.renderWebhookError(w, "Failed to render template") 379 + } 380 + } 381 + 382 + type triggerInfo struct { 383 + Name string 384 + Bit int 385 + Label string 386 + Description string 387 + AlwaysAvailable bool 388 + } 389 + 390 + func (h *BaseUIHandler) renderWebhookError(w http.ResponseWriter, message string) { 391 + w.Header().Set("Content-Type", "text/html") 392 + _ = h.Templates.ExecuteTemplate(w, "alert", map[string]string{ 393 + "Type": "error", 394 + "Message": message, 395 + }) 396 + } 397 + 398 + func (h *BaseUIHandler) renderAlert(w http.ResponseWriter, alertType, message string) { 399 + w.Header().Set("Content-Type", "text/html") 400 + _ = h.Templates.ExecuteTemplate(w, "alert", map[string]string{ 401 + "Type": alertType, 402 + "Message": message, 403 + }) 404 + } 405 +
+10
pkg/appview/jetstream/backfill.go
··· 433 433 captainRecord.HoldDID = holdDID 434 434 captainRecord.UpdatedAt = time.Now() 435 435 436 + // Extract supporterBadgeTiers from raw JSON (db struct uses json:"-" so unmarshal skips it) 437 + var raw struct { 438 + SupporterBadgeTiers []string `json:"supporterBadgeTiers"` 439 + } 440 + if err := json.Unmarshal(record.Value, &raw); err == nil && len(raw.SupporterBadgeTiers) > 0 { 441 + if jsonBytes, err := json.Marshal(raw.SupporterBadgeTiers); err == nil { 442 + captainRecord.SupporterBadgeTiers = string(jsonBytes) 443 + } 444 + } 445 + 436 446 if err := db.UpsertCaptainRecord(b.db, &captainRecord); err != nil { 437 447 return fmt.Errorf("failed to cache captain record: %w", err) 438 448 }
+22 -8
pkg/appview/jetstream/processor.go
··· 455 455 return nil 456 456 } 457 457 458 + // Cache default hold DID on the user record 459 + if err := db.UpdateUserDefaultHold(p.db, did, holdDID); err != nil { 460 + slog.Warn("Failed to cache default hold DID", "component", "processor", "did", did, "holdDid", holdDID, "error", err) 461 + } 462 + 458 463 // Query and cache the captain record using provided function 459 464 // This allows backfill-specific logic (retries, test mode handling) without duplicating it here 460 465 if queryCaptainFn != nil { ··· 674 679 return fmt.Errorf("failed to unmarshal captain record: %w", err) 675 680 } 676 681 682 + // Marshal supporter badge tiers to JSON string for storage 683 + badgeTiersJSON := "" 684 + if len(captainRecord.SupporterBadgeTiers) > 0 { 685 + if jsonBytes, err := json.Marshal(captainRecord.SupporterBadgeTiers); err == nil { 686 + badgeTiersJSON = string(jsonBytes) 687 + } 688 + } 689 + 677 690 // Convert to db struct and upsert 678 691 record := &db.HoldCaptainRecord{ 679 - HoldDID: holdDID, 680 - OwnerDID: captainRecord.Owner, 681 - Public: captainRecord.Public, 682 - AllowAllCrew: captainRecord.AllowAllCrew, 683 - DeployedAt: captainRecord.DeployedAt, 684 - Region: captainRecord.Region, 685 - Successor: captainRecord.Successor, 686 - UpdatedAt: time.Now(), 692 + HoldDID: holdDID, 693 + OwnerDID: captainRecord.Owner, 694 + Public: captainRecord.Public, 695 + AllowAllCrew: captainRecord.AllowAllCrew, 696 + DeployedAt: captainRecord.DeployedAt, 697 + Region: captainRecord.Region, 698 + Successor: captainRecord.Successor, 699 + SupporterBadgeTiers: badgeTiersJSON, 700 + UpdatedAt: time.Now(), 687 701 } 688 702 689 703 if err := db.UpsertCaptainRecord(p.db, record); err != nil {
+1
pkg/appview/jetstream/processor_test.go
··· 41 41 handle TEXT NOT NULL, 42 42 pds_endpoint TEXT NOT NULL, 43 43 avatar TEXT, 44 + default_hold_did TEXT, 44 45 last_seen TIMESTAMP NOT NULL 45 46 ); 46 47
+2
pkg/appview/public/icons.svg
··· 6 6 <symbol id="arrow-down-to-line" viewBox="0 0 24 24"><path d="M12 17V3"/><path d="m6 11 6 6 6-6"/><path d="M19 21H5"/></symbol> 7 7 <symbol id="arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></symbol> 8 8 <symbol id="arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol> 9 + <symbol id="badge-check" viewBox="0 0 24 24"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></symbol> 9 10 <symbol id="box" viewBox="0 0 24 24"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></symbol> 10 11 <symbol id="check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol> 11 12 <symbol id="check-circle" viewBox="0 0 24 24"><path d="M21.801 10A10 10 0 1 1 17 3.335"/><path d="m9 11 3 3L22 4"/></symbol> ··· 48 49 <symbol id="upload" viewBox="0 0 24 24"><path d="M12 3v12"/><path d="m17 8-5-5-5 5"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/></symbol> 49 50 <symbol id="user" viewBox="0 0 24 24"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol> 50 51 <symbol id="user-plus" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" x2="19" y1="8" y2="14"/><line x1="22" x2="16" y1="11" y2="11"/></symbol> 52 + <symbol id="webhook" viewBox="0 0 24 24"><path d="M18 16.98h-5.99c-1.1 0-1.95.94-2.48 1.9A4 4 0 0 1 2 17c.01-.7.2-1.4.57-2"/><path d="m6 17 3.13-5.78c.53-.97.1-2.18-.5-3.1a4 4 0 1 1 6.89-4.06"/><path d="m12 6 3.13 5.73C15.66 12.7 16.9 13 18 13a4 4 0 0 1 0 8"/></symbol> 51 53 <symbol id="x-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></symbol> 52 54 <symbol id="helm" viewBox="0 0 24 24"><path d="M12.337 0c-.475 0-.861 1.016-.861 2.269 0 .527.069 1.011.183 1.396a8.514 8.514 0 0 0-3.961 1.22 5.229 5.229 0 0 0-.595-1.093c-.606-.866-1.34-1.436-1.79-1.43a.381.381 0 0 0-.217.066c-.39.273-.123 1.326.596 2.353.267.381.559.705.84.948a8.683 8.683 0 0 0-1.528 1.716h1.734a7.179 7.179 0 0 1 5.381-2.421 7.18 7.18 0 0 1 5.382 2.42h1.733a8.687 8.687 0 0 0-1.32-1.53c.35-.249.735-.643 1.078-1.133.719-1.027.986-2.08.596-2.353a.382.382 0 0 0-.217-.065c-.45-.007-1.184.563-1.79 1.43a4.897 4.897 0 0 0-.676 1.325 8.52 8.52 0 0 0-3.899-1.42c.12-.39.193-.887.193-1.429 0-1.253-.386-2.269-.862-2.269zM1.624 9.443v5.162h1.358v-1.968h1.64v1.968h1.357V9.443H4.62v1.838H2.98V9.443zm5.912 0v5.162h3.21v-1.108H8.893v-.95h1.64v-1.142h-1.64v-.84h1.853V9.443zm4.698 0v5.162h3.218v-1.362h-1.86v-3.8zm4.706 0v5.162h1.364v-2.643l1.357 1.225 1.35-1.232v2.65h1.365V9.443h-.614l-2.1 1.914-2.109-1.914zm-11.82 7.28a8.688 8.688 0 0 0 1.412 1.548 5.206 5.206 0 0 0-.841.948c-.719 1.027-.985 2.08-.596 2.353.39.273 1.289-.338 2.007-1.364a5.23 5.23 0 0 0 .595-1.092 8.514 8.514 0 0 0 3.961 1.219 5.01 5.01 0 0 0-.183 1.396c0 1.253.386 2.269.861 2.269.476 0 .862-1.016.862-2.269 0-.542-.072-1.04-.193-1.43a8.52 8.52 0 0 0 3.9-1.42c.121.4.352.865.675 1.327.719 1.026 1.617 1.637 2.007 1.364.39-.273.123-1.326-.596-2.353-.343-.49-.727-.885-1.077-1.135a8.69 8.69 0 0 0 1.202-1.36h-1.771a7.174 7.174 0 0 1-5.227 2.252 7.174 7.174 0 0 1-5.226-2.252z" fill="currentColor" stroke="none"/></symbol> 53 55 </svg>
+6
pkg/appview/routes/routes.go
··· 162 162 r.Delete("/api/manifests", (&uihandlers.DeleteManifestHandler{BaseUIHandler: base}).ServeHTTP) 163 163 r.Post("/api/avatar", (&uihandlers.UploadAvatarHandler{BaseUIHandler: base}).ServeHTTP) 164 164 165 + // Webhook management 166 + r.Get("/api/webhooks", (&uihandlers.WebhooksHandler{BaseUIHandler: base}).ServeHTTP) 167 + r.Post("/api/webhooks", (&uihandlers.AddWebhookHandler{BaseUIHandler: base}).ServeHTTP) 168 + r.Delete("/api/webhooks/{id}", (&uihandlers.DeleteWebhookHandler{BaseUIHandler: base}).ServeHTTP) 169 + r.Post("/api/webhooks/{id}/test", (&uihandlers.TestWebhookHandler{BaseUIHandler: base}).ServeHTTP) 170 + 165 171 // Device approval page (authenticated) 166 172 r.Get("/device", (&uihandlers.DeviceApprovalPageHandler{BaseUIHandler: base}).ServeHTTP) 167 173 r.Post("/device/approve", (&uihandlers.DeviceApproveHandler{BaseUIHandler: base}).ServeHTTP)
+1
pkg/appview/server.go
··· 183 183 slog.Debug("Base URL for OAuth", "base_url", baseURL) 184 184 if testMode { 185 185 slog.Info("TEST_MODE enabled - will use HTTP for local DID resolution") 186 + atproto.SetTestMode(true) 186 187 } 187 188 188 189 // Load crypto keys from database (with file fallback and migration)
+30
pkg/appview/src/css/main.css
··· 292 292 @apply badge-secondary; 293 293 } 294 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 { 308 + @apply badge-accent; 309 + } 310 + 311 + .supporter-badge-owner { 312 + @apply badge-primary; 313 + } 314 + 295 315 /* ---------------------------------------- 296 316 CARD EXTENSIONS 297 317 ---------------------------------------- */ ··· 399 419 .vuln-box-medium { background-color: oklch(72% 0.15 70); color: oklch(25% 0.05 70); } 400 420 .vuln-box-low { background-color: oklch(80% 0.1 85); color: oklch(25% 0.05 85); } 401 421 } 422 + 423 + /* ======================================== 424 + SUPPORTER BADGE TEXT COLOR OVERRIDES 425 + Unlayered — wins over DaisyUI's layered 426 + .badge base class (utilities layer) 427 + ======================================== */ 428 + .supporter-badge-deckhand { color: var(--color-base-content); } 429 + .supporter-badge-bosun { color: var(--color-secondary-content); } 430 + .supporter-badge-quartermaster { color: var(--color-accent-content); } 431 + .supporter-badge-owner { color: var(--color-primary-content); }
+21 -1
pkg/appview/templates/pages/settings.html
··· 23 23 <button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="storage"> 24 24 {{ icon "hard-drive" "size-4" }} Storage 25 25 </button> 26 + <button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="webhooks"> 27 + {{ icon "webhook" "size-4" }} Webhooks 28 + </button> 26 29 <button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="advanced"> 27 30 {{ icon "shield-check" "size-4" }} Advanced 28 31 </button> ··· 35 38 <li data-tab="identity"><a href="#identity">{{ icon "fingerprint" "size-4" }} Identity</a></li> 36 39 <li data-tab="devices"><a href="#devices">{{ icon "terminal" "size-4" }} Devices</a></li> 37 40 <li data-tab="storage"><a href="#storage">{{ icon "hard-drive" "size-4" }} Storage</a></li> 41 + <li data-tab="webhooks"><a href="#webhooks">{{ icon "webhook" "size-4" }} Webhooks</a></li> 38 42 <li data-tab="advanced"><a href="#advanced">{{ icon "shield-check" "size-4" }} Advanced</a></li> 39 43 </ul> 40 44 </aside> ··· 220 224 </div> 221 225 </div> 222 226 227 + <!-- WEBHOOKS TAB --> 228 + <div id="tab-webhooks" class="settings-panel hidden space-y-6"> 229 + <section class="card bg-base-100 shadow-sm p-6 space-y-4"> 230 + <div> 231 + <h2 class="text-xl font-semibold">Scan Webhooks</h2> 232 + <p class="text-base-content/70 mt-1">Get HTTP notifications when vulnerability scans complete.</p> 233 + </div> 234 + <div id="webhooks-content" 235 + hx-get="/api/webhooks" 236 + hx-trigger="tab:webhooks from:body once" 237 + hx-swap="innerHTML"> 238 + <p class="flex items-center gap-2">{{ icon "loader-2" "size-4 animate-spin" }} Loading webhooks...</p> 239 + </div> 240 + </section> 241 + </div> 242 + 223 243 <!-- ADVANCED TAB --> 224 244 <div id="tab-advanced" class="settings-panel hidden space-y-6"> 225 245 <!-- Data Privacy Section --> ··· 289 309 290 310 // Tab switching 291 311 (function() { 292 - var validTabs = ['identity', 'devices', 'storage', 'advanced']; 312 + var validTabs = ['identity', 'devices', 'storage', 'webhooks', 'advanced']; 293 313 294 314 function switchSettingsTab(tabId) { 295 315 // Hide all panels
+6 -1
pkg/appview/templates/pages/user.html
··· 31 31 </div> 32 32 </div> 33 33 {{ end }} 34 - <h1 class="text-2xl font-bold">{{ .ViewedUser.Handle }}</h1> 34 + <div class="flex items-center gap-2"> 35 + <h1 class="text-2xl font-bold">{{ .ViewedUser.Handle }}</h1> 36 + {{ if .SupporterBadge }} 37 + <span class="badge badge-sm supporter-badge-{{ .SupporterBadge }}">{{ .SupporterBadge }}</span> 38 + {{ end }} 39 + </div> 35 40 </div> 36 41 37 42 <!-- Content -->
+6
pkg/appview/templates/partials/storage_stats.html
··· 5 5 <span class="text-base-content/60">Tier:</span> 6 6 <span class="badge badge-xs badge-{{ .Tier }} font-semibold">{{ .Tier }}</span> 7 7 </div> 8 + {{ if .HasSupporterBadge }} 9 + <div class="flex justify-between items-center"> 10 + <span class="text-base-content/60">Profile Badge:</span> 11 + <span class="text-sm text-success flex items-center gap-1">{{ icon "badge-check" "size-4" }} Visible on your profile</span> 12 + </div> 13 + {{ end }} 8 14 {{ end }} 9 15 <div class="flex justify-between items-center"> 10 16 <span class="text-base-content/60">Storage:</span>
+105
pkg/appview/templates/partials/webhooks_list.html
··· 1 + {{ define "webhooks_list" }} 2 + <div class="space-y-6"> 3 + <!-- Add Webhook Form --> 4 + <form hx-post="/api/webhooks" 5 + hx-target="#webhooks-content" 6 + hx-swap="innerHTML" 7 + class="space-y-4 bg-base-200 rounded-lg p-4"> 8 + <h3 class="font-semibold">Add Webhook</h3> 9 + 10 + <fieldset class="fieldset"> 11 + <label class="label" for="webhook-url"> 12 + <span class="label-text">Webhook URL</span> 13 + </label> 14 + <input type="url" id="webhook-url" name="url" placeholder="https://example.com/webhook" 15 + class="input input-bordered w-full" required> 16 + </fieldset> 17 + 18 + <fieldset class="fieldset"> 19 + <label class="label" for="webhook-secret"> 20 + <span class="label-text">Signing Secret <span class="text-base-content/50">(optional)</span></span> 21 + </label> 22 + <input type="password" id="webhook-secret" name="secret" placeholder="HMAC-SHA256 signing secret" 23 + class="input input-bordered w-full" autocomplete="new-password"> 24 + <p class="text-xs text-base-content/60 mt-1">If set, payloads include an <code class="cmd">X-Webhook-Signature-256</code> header</p> 25 + </fieldset> 26 + 27 + <fieldset class="fieldset"> 28 + <legend class="label"><span class="label-text">Trigger Events</span></legend> 29 + <div class="space-y-2 mt-1"> 30 + {{ range .TriggerInfo }} 31 + <label class="flex items-start gap-3 cursor-pointer{{ if and (not .AlwaysAvailable) (not $.Limits.AllTriggers) }} opacity-50{{ end }}"> 32 + <input type="checkbox" name="trigger_{{ if eq .Name "scan:first" }}first{{ else if eq .Name "scan:all" }}all{{ else }}changed{{ end }}" 33 + class="checkbox checkbox-sm mt-0.5" 34 + {{ if .AlwaysAvailable }}checked{{ end }} 35 + {{ if and (not .AlwaysAvailable) (not $.Limits.AllTriggers) }}disabled{{ end }}> 36 + <span> 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 }} 39 + <br><span class="text-xs text-base-content/60">{{ .Description }}</span> 40 + </span> 41 + </label> 42 + {{ end }} 43 + </div> 44 + </fieldset> 45 + 46 + <div class="flex items-center justify-between"> 47 + <span class="text-sm text-base-content/60"> 48 + {{ len .Webhooks }} / {{ if eq .Limits.Max -1 }}unlimited{{ else }}{{ .Limits.Max }}{{ end }} webhooks configured 49 + </span> 50 + <button type="submit" class="btn btn-primary btn-sm" 51 + {{ if and (ne .Limits.Max -1) (ge (len .Webhooks) .Limits.Max) }}disabled{{ end }}> 52 + Add Webhook 53 + </button> 54 + </div> 55 + </form> 56 + 57 + <!-- Existing Webhooks --> 58 + {{ if .Webhooks }} 59 + <div class="space-y-3"> 60 + {{ range .Webhooks }} 61 + <div class="card bg-base-100 border border-base-300 p-4"> 62 + <div class="flex items-start justify-between gap-4"> 63 + <div class="min-w-0 flex-1"> 64 + <code class="text-sm break-all">{{ .URL }}</code> 65 + <div class="flex flex-wrap gap-1 mt-2"> 66 + {{ if .HasFirst }} 67 + <span class="badge badge-sm badge-primary">scan:first</span> 68 + {{ end }} 69 + {{ if .HasAll }} 70 + <span class="badge badge-sm badge-secondary">scan:all</span> 71 + {{ end }} 72 + {{ if .HasChanged }} 73 + <span class="badge badge-sm badge-accent">scan:changed</span> 74 + {{ end }} 75 + {{ if .HasSecret }} 76 + <span class="badge badge-sm badge-ghost">Signed</span> 77 + {{ end }} 78 + </div> 79 + </div> 80 + <div class="flex gap-2 shrink-0"> 81 + <button class="btn btn-xs btn-ghost" 82 + hx-post="/api/webhooks/{{ .Rkey }}/test" 83 + hx-target="closest .card" 84 + hx-swap="afterend" 85 + title="Send test payload"> 86 + Test 87 + </button> 88 + <button class="btn btn-xs btn-error btn-ghost" 89 + hx-delete="/api/webhooks/{{ .Rkey }}" 90 + hx-target="#webhooks-content" 91 + hx-swap="innerHTML" 92 + hx-confirm="Delete this webhook?" 93 + title="Delete webhook"> 94 + Delete 95 + </button> 96 + </div> 97 + </div> 98 + </div> 99 + {{ end }} 100 + </div> 101 + {{ else }} 102 + <p class="text-base-content/50 text-sm text-center py-4">No webhooks configured yet.</p> 103 + {{ end }} 104 + </div> 105 + {{ end }}
+298 -2
pkg/atproto/cbor_gen.go
··· 377 377 } 378 378 379 379 cw := cbg.NewCborWriter(w) 380 - fieldCount := 8 380 + fieldCount := 9 381 381 382 382 if t.Region == "" { 383 383 fieldCount-- 384 384 } 385 385 386 386 if t.Successor == "" { 387 + fieldCount-- 388 + } 389 + 390 + if t.SupporterBadgeTiers == nil { 387 391 fieldCount-- 388 392 } 389 393 ··· 558 562 559 563 if err := cbg.WriteBool(w, t.EnableBlueskyPosts); err != nil { 560 564 return err 565 + } 566 + 567 + // t.SupporterBadgeTiers ([]string) (slice) 568 + if t.SupporterBadgeTiers != nil { 569 + 570 + if len("supporterBadgeTiers") > 8192 { 571 + return xerrors.Errorf("Value in field \"supporterBadgeTiers\" was too long") 572 + } 573 + 574 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("supporterBadgeTiers"))); err != nil { 575 + return err 576 + } 577 + if _, err := cw.WriteString(string("supporterBadgeTiers")); err != nil { 578 + return err 579 + } 580 + 581 + if len(t.SupporterBadgeTiers) > 8192 { 582 + return xerrors.Errorf("Slice value in field t.SupporterBadgeTiers was too long") 583 + } 584 + 585 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.SupporterBadgeTiers))); err != nil { 586 + return err 587 + } 588 + for _, v := range t.SupporterBadgeTiers { 589 + if len(v) > 8192 { 590 + return xerrors.Errorf("Value in field v was too long") 591 + } 592 + 593 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 594 + return err 595 + } 596 + if _, err := cw.WriteString(string(v)); err != nil { 597 + return err 598 + } 599 + 600 + } 561 601 } 562 602 return nil 563 603 } ··· 587 627 588 628 n := extra 589 629 590 - nameBuf := make([]byte, 18) 630 + nameBuf := make([]byte, 19) 591 631 for i := uint64(0); i < n; i++ { 592 632 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) 593 633 if err != nil { ··· 711 751 t.EnableBlueskyPosts = true 712 752 default: 713 753 return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra) 754 + } 755 + // t.SupporterBadgeTiers ([]string) (slice) 756 + case "supporterBadgeTiers": 757 + 758 + maj, extra, err = cr.ReadHeader() 759 + if err != nil { 760 + return err 761 + } 762 + 763 + if extra > 8192 { 764 + return fmt.Errorf("t.SupporterBadgeTiers: array too large (%d)", extra) 765 + } 766 + 767 + if maj != cbg.MajArray { 768 + return fmt.Errorf("expected cbor array") 769 + } 770 + 771 + if extra > 0 { 772 + t.SupporterBadgeTiers = make([]string, extra) 773 + } 774 + 775 + for i := 0; i < int(extra); i++ { 776 + { 777 + var maj byte 778 + var extra uint64 779 + var err error 780 + _ = maj 781 + _ = extra 782 + _ = err 783 + 784 + { 785 + sval, err := cbg.ReadStringWithMax(cr, 8192) 786 + if err != nil { 787 + return err 788 + } 789 + 790 + t.SupporterBadgeTiers[i] = string(sval) 791 + } 792 + 793 + } 714 794 } 715 795 716 796 default: ··· 2425 2505 2426 2506 return nil 2427 2507 } 2508 + func (t *HoldWebhookRecord) MarshalCBOR(w io.Writer) error { 2509 + if t == nil { 2510 + _, err := w.Write(cbg.CborNull) 2511 + return err 2512 + } 2513 + 2514 + cw := cbg.NewCborWriter(w) 2515 + 2516 + if _, err := cw.Write([]byte{164}); err != nil { 2517 + return err 2518 + } 2519 + 2520 + // t.Type (string) (string) 2521 + if len("$type") > 8192 { 2522 + return xerrors.Errorf("Value in field \"$type\" was too long") 2523 + } 2524 + 2525 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 2526 + return err 2527 + } 2528 + if _, err := cw.WriteString(string("$type")); err != nil { 2529 + return err 2530 + } 2531 + 2532 + if len(t.Type) > 8192 { 2533 + return xerrors.Errorf("Value in field t.Type was too long") 2534 + } 2535 + 2536 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Type))); err != nil { 2537 + return err 2538 + } 2539 + if _, err := cw.WriteString(string(t.Type)); err != nil { 2540 + return err 2541 + } 2542 + 2543 + // t.UserDID (string) (string) 2544 + if len("userDid") > 8192 { 2545 + return xerrors.Errorf("Value in field \"userDid\" was too long") 2546 + } 2547 + 2548 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("userDid"))); err != nil { 2549 + return err 2550 + } 2551 + if _, err := cw.WriteString(string("userDid")); err != nil { 2552 + return err 2553 + } 2554 + 2555 + if len(t.UserDID) > 8192 { 2556 + return xerrors.Errorf("Value in field t.UserDID was too long") 2557 + } 2558 + 2559 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.UserDID))); err != nil { 2560 + return err 2561 + } 2562 + if _, err := cw.WriteString(string(t.UserDID)); err != nil { 2563 + return err 2564 + } 2565 + 2566 + // t.Triggers (int64) (int64) 2567 + if len("triggers") > 8192 { 2568 + return xerrors.Errorf("Value in field \"triggers\" was too long") 2569 + } 2570 + 2571 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("triggers"))); err != nil { 2572 + return err 2573 + } 2574 + if _, err := cw.WriteString(string("triggers")); err != nil { 2575 + return err 2576 + } 2577 + 2578 + if t.Triggers >= 0 { 2579 + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Triggers)); err != nil { 2580 + return err 2581 + } 2582 + } else { 2583 + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Triggers-1)); err != nil { 2584 + return err 2585 + } 2586 + } 2587 + 2588 + // t.CreatedAt (string) (string) 2589 + if len("createdAt") > 8192 { 2590 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 2591 + } 2592 + 2593 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 2594 + return err 2595 + } 2596 + if _, err := cw.WriteString(string("createdAt")); err != nil { 2597 + return err 2598 + } 2599 + 2600 + if len(t.CreatedAt) > 8192 { 2601 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 2602 + } 2603 + 2604 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 2605 + return err 2606 + } 2607 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 2608 + return err 2609 + } 2610 + return nil 2611 + } 2612 + 2613 + func (t *HoldWebhookRecord) UnmarshalCBOR(r io.Reader) (err error) { 2614 + *t = HoldWebhookRecord{} 2615 + 2616 + cr := cbg.NewCborReader(r) 2617 + 2618 + maj, extra, err := cr.ReadHeader() 2619 + if err != nil { 2620 + return err 2621 + } 2622 + defer func() { 2623 + if err == io.EOF { 2624 + err = io.ErrUnexpectedEOF 2625 + } 2626 + }() 2627 + 2628 + if maj != cbg.MajMap { 2629 + return fmt.Errorf("cbor input should be of type map") 2630 + } 2631 + 2632 + if extra > cbg.MaxLength { 2633 + return fmt.Errorf("HoldWebhookRecord: map struct too large (%d)", extra) 2634 + } 2635 + 2636 + n := extra 2637 + 2638 + nameBuf := make([]byte, 9) 2639 + for i := uint64(0); i < n; i++ { 2640 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) 2641 + if err != nil { 2642 + return err 2643 + } 2644 + 2645 + if !ok { 2646 + // Field doesn't exist on this type, so ignore it 2647 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 2648 + return err 2649 + } 2650 + continue 2651 + } 2652 + 2653 + switch string(nameBuf[:nameLen]) { 2654 + // t.Type (string) (string) 2655 + case "$type": 2656 + 2657 + { 2658 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2659 + if err != nil { 2660 + return err 2661 + } 2662 + 2663 + t.Type = string(sval) 2664 + } 2665 + // t.UserDID (string) (string) 2666 + case "userDid": 2667 + 2668 + { 2669 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2670 + if err != nil { 2671 + return err 2672 + } 2673 + 2674 + t.UserDID = string(sval) 2675 + } 2676 + // t.Triggers (int64) (int64) 2677 + case "triggers": 2678 + { 2679 + maj, extra, err := cr.ReadHeader() 2680 + if err != nil { 2681 + return err 2682 + } 2683 + var extraI int64 2684 + switch maj { 2685 + case cbg.MajUnsignedInt: 2686 + extraI = int64(extra) 2687 + if extraI < 0 { 2688 + return fmt.Errorf("int64 positive overflow") 2689 + } 2690 + case cbg.MajNegativeInt: 2691 + extraI = int64(extra) 2692 + if extraI < 0 { 2693 + return fmt.Errorf("int64 negative overflow") 2694 + } 2695 + extraI = -1 - extraI 2696 + default: 2697 + return fmt.Errorf("wrong type for int64 field: %d", maj) 2698 + } 2699 + 2700 + t.Triggers = int64(extraI) 2701 + } 2702 + // t.CreatedAt (string) (string) 2703 + case "createdAt": 2704 + 2705 + { 2706 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2707 + if err != nil { 2708 + return err 2709 + } 2710 + 2711 + t.CreatedAt = string(sval) 2712 + } 2713 + 2714 + default: 2715 + // Field doesn't exist on this type, so ignore it 2716 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 2717 + return err 2718 + } 2719 + } 2720 + } 2721 + 2722 + return nil 2723 + }
+15
pkg/atproto/directory.go
··· 15 15 // Shared identity directory instance (singleton) 16 16 sharedDirectory identity.Directory 17 17 directoryOnce sync.Once 18 + 19 + // testMode allows HTTP did:web resolution (IPs, non-TLS) for local development. 20 + // Set via SetTestMode() on startup. 21 + testMode bool 18 22 ) 23 + 24 + // SetTestMode enables relaxed did:web resolution for local development, 25 + // allowing HTTP and IP-based did:web identifiers that the indigo directory rejects. 26 + func SetTestMode(enabled bool) { 27 + testMode = enabled 28 + } 29 + 30 + // IsTestMode returns whether test mode is enabled. 31 + func IsTestMode() bool { 32 + return testMode 33 + } 19 34 20 35 // GetDirectory returns a shared identity.Directory instance with a 24-hour cache TTL. 21 36 // This is based on indigo's DefaultDirectory() with event-driven cache invalidation.
+29 -1
pkg/atproto/endpoints.go
··· 60 60 61 61 // HoldExportUserData exports all user data from a hold service (GDPR compliance). 62 62 // Method: GET 63 - // Query: userDid={did} 64 63 // Response: JSON containing all user data stored by the hold 65 64 HoldExportUserData = "/xrpc/io.atcr.hold.exportUserData" 65 + 66 + // HoldDeleteUserData deletes all user data from a hold service (GDPR compliance). 67 + // Method: DELETE 68 + // Response: {"success": true, "crew_deleted": bool, "layers_deleted": int, "stats_deleted": int} 69 + HoldDeleteUserData = "/xrpc/io.atcr.hold.deleteUserData" 66 70 ) 67 71 68 72 // Hold service crew management endpoints (io.atcr.hold.*) ··· 81 85 // Auth: Shared secret (query param or header) 82 86 // Response: Stream of scan job events (JSON) 83 87 HoldSubscribeScanJobs = "/xrpc/io.atcr.hold.subscribeScanJobs" 88 + 89 + // HoldListWebhooks lists webhook configurations for a user. 90 + // Method: GET 91 + // Query: userDid={did} 92 + // Response: {webhooks: [...], limits: {max, allTriggers}} 93 + HoldListWebhooks = "/xrpc/io.atcr.hold.listWebhooks" 94 + 95 + // HoldAddWebhook creates a new webhook configuration. 96 + // Method: POST 97 + // Request: {userDid, url, secret, triggers} 98 + // Response: {rkey, cid} 99 + HoldAddWebhook = "/xrpc/io.atcr.hold.addWebhook" 100 + 101 + // HoldDeleteWebhook deletes a webhook configuration. 102 + // Method: POST 103 + // Request: {rkey} 104 + // Response: {success: true} 105 + HoldDeleteWebhook = "/xrpc/io.atcr.hold.deleteWebhook" 106 + 107 + // HoldTestWebhook sends a test payload to a webhook. 108 + // Method: POST 109 + // Request: {rkey} 110 + // Response: {statusCode, success} 111 + HoldTestWebhook = "/xrpc/io.atcr.hold.testWebhook" 84 112 85 113 // Future: HoldDelegateAccess = "/xrpc/io.atcr.hold.delegateAccess" 86 114 )
+1
pkg/atproto/generate.go
··· 33 33 atproto.TangledProfileRecord{}, 34 34 atproto.StatsRecord{}, 35 35 atproto.ScanRecord{}, 36 + atproto.HoldWebhookRecord{}, 36 37 ); err != nil { 37 38 fmt.Printf("Failed to generate CBOR encoders: %v\n", err) 38 39 os.Exit(1)
+67 -2
pkg/atproto/lexicon.go
··· 64 64 // RepoPageCollection is the collection name for repository page metadata 65 65 // Stored in user's PDS with rkey = repository name 66 66 RepoPageCollection = "io.atcr.repo.page" 67 + 68 + // SailorWebhookCollection is the collection name for webhook configs in user's PDS 69 + SailorWebhookCollection = "io.atcr.sailor.webhook" 70 + 71 + // WebhookCollection is the collection name for webhook records in hold's embedded PDS 72 + WebhookCollection = "io.atcr.hold.webhook" 67 73 ) 68 74 69 75 // ManifestRecord represents a container image manifest stored in ATProto ··· 667 673 AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew 668 674 EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var) 669 675 DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp 670 - Region string `json:"region,omitempty" cborgen:"region,omitempty"` // Deployment region (optional) 671 - Successor string `json:"successor,omitempty" cborgen:"successor,omitempty"` // DID of successor hold (migration redirect) 676 + Region string `json:"region,omitempty" cborgen:"region,omitempty"` // Deployment region (optional) 677 + Successor string `json:"successor,omitempty" cborgen:"successor,omitempty"` // DID of successor hold (migration redirect) 678 + SupporterBadgeTiers []string `json:"supporterBadgeTiers,omitempty" cborgen:"supporterBadgeTiers,omitempty"` // Tier names that earn a supporter badge on profiles 672 679 } 673 680 674 681 // CrewRecord represents a crew member in the hold ··· 813 820 func ScanRecordKey(manifestDigest string) string { 814 821 // Remove the "sha256:" prefix - the hex digest is already a valid rkey 815 822 return strings.TrimPrefix(manifestDigest, "sha256:") 823 + } 824 + 825 + // Webhook trigger bitmask constants 826 + const ( 827 + TriggerFirst = 0x01 // First-time scan (no previous scan record) 828 + TriggerAll = 0x02 // Every scan completion 829 + TriggerChanged = 0x04 // Vulnerability counts changed from previous 830 + ) 831 + 832 + // SailorWebhookRecord represents a webhook config in the user's PDS 833 + // Links to a private HoldWebhookRecord via privateCid 834 + type SailorWebhookRecord struct { 835 + Type string `json:"$type"` 836 + HoldDID string `json:"holdDid"` 837 + Triggers int `json:"triggers"` 838 + PrivateCID string `json:"privateCid"` 839 + CreatedAt string `json:"createdAt"` 840 + UpdatedAt string `json:"updatedAt"` 841 + } 842 + 843 + // NewSailorWebhookRecord creates a new sailor webhook record 844 + func NewSailorWebhookRecord(holdDID string, triggers int, privateCID string) *SailorWebhookRecord { 845 + now := time.Now().Format(time.RFC3339) 846 + return &SailorWebhookRecord{ 847 + Type: SailorWebhookCollection, 848 + HoldDID: holdDID, 849 + Triggers: triggers, 850 + PrivateCID: privateCID, 851 + CreatedAt: now, 852 + UpdatedAt: now, 853 + } 854 + } 855 + 856 + // HoldWebhookRecord represents a webhook record in the hold's embedded PDS 857 + // The actual URL and secret are stored in SQLite (never in ATProto records) 858 + type HoldWebhookRecord struct { 859 + Type string `json:"$type" cborgen:"$type"` 860 + UserDID string `json:"userDid" cborgen:"userDid"` 861 + Triggers int64 `json:"triggers" cborgen:"triggers"` 862 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 863 + } 864 + 865 + // NewHoldWebhookRecord creates a new hold webhook record 866 + func NewHoldWebhookRecord(userDID string, triggers int) *HoldWebhookRecord { 867 + return &HoldWebhookRecord{ 868 + Type: WebhookCollection, 869 + UserDID: userDID, 870 + Triggers: int64(triggers), 871 + CreatedAt: time.Now().Format(time.RFC3339), 872 + } 873 + } 874 + 875 + // WebhookRecordKey generates a deterministic rkey for a webhook record 876 + // Uses hash of userDID + sequence number to support multiple webhooks per user 877 + func WebhookRecordKey(userDID string, seq int) string { 878 + combined := fmt.Sprintf("%s/webhook/%d", userDID, seq) 879 + hash := sha256.Sum256([]byte(combined)) 880 + return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(hash[:16])) 816 881 } 817 882 818 883 // TangledProfileRecord represents a Tangled profile for the hold
+19
pkg/atproto/resolver.go
··· 96 96 97 97 ident, err := directory.LookupDID(ctx, didParsed) 98 98 if err != nil { 99 + // In test mode, fall back to deriving URL directly from did:web. 100 + // The indigo directory hardcodes HTTPS and rejects IPs/ports, 101 + // so local dev (HTTP, IP:port) always needs this fallback. 102 + if testMode && strings.HasPrefix(did, "did:web:") { 103 + return didWebToURL(did), nil 104 + } 99 105 return "", fmt.Errorf("failed to resolve hold DID %s: %w", did, err) 100 106 } 101 107 ··· 110 116 } 111 117 112 118 return "", fmt.Errorf("no hold or PDS service endpoint found for DID %s", did) 119 + } 120 + 121 + // didWebToURL converts a did:web DID to its base URL. 122 + // did:web:example.com → https://example.com 123 + // did:web:172.28.0.3%3A8080 → http://172.28.0.3:8080 124 + func didWebToURL(did string) string { 125 + host := strings.TrimPrefix(did, "did:web:") 126 + host = strings.ReplaceAll(host, "%3A", ":") 127 + scheme := "https" 128 + if strings.Contains(host, ":") { 129 + scheme = "http" 130 + } 131 + return scheme + "://" + host 113 132 } 114 133 115 134 // ResolveDIDToPDS resolves a DID to its PDS endpoint.
+2
pkg/hold/admin/public/icons.svg
··· 6 6 <symbol id="arrow-down-to-line" viewBox="0 0 24 24"><path d="M12 17V3"/><path d="m6 11 6 6 6-6"/><path d="M19 21H5"/></symbol> 7 7 <symbol id="arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></symbol> 8 8 <symbol id="arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol> 9 + <symbol id="badge-check" viewBox="0 0 24 24"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></symbol> 9 10 <symbol id="box" viewBox="0 0 24 24"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></symbol> 10 11 <symbol id="check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol> 11 12 <symbol id="check-circle" viewBox="0 0 24 24"><path d="M21.801 10A10 10 0 1 1 17 3.335"/><path d="m9 11 3 3L22 4"/></symbol> ··· 48 49 <symbol id="upload" viewBox="0 0 24 24"><path d="M12 3v12"/><path d="m17 8-5-5-5 5"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/></symbol> 49 50 <symbol id="user" viewBox="0 0 24 24"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol> 50 51 <symbol id="user-plus" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" x2="19" y1="8" y2="14"/><line x1="22" x2="16" y1="11" y2="11"/></symbol> 52 + <symbol id="webhook" viewBox="0 0 24 24"><path d="M18 16.98h-5.99c-1.1 0-1.95.94-2.48 1.9A4 4 0 0 1 2 17c.01-.7.2-1.4.57-2"/><path d="m6 17 3.13-5.78c.53-.97.1-2.18-.5-3.1a4 4 0 1 1 6.89-4.06"/><path d="m12 6 3.13 5.73C15.66 12.7 16.9 13 18 13a4 4 0 0 1 0 8"/></symbol> 51 53 <symbol id="x-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></symbol> 52 54 <symbol id="helm" viewBox="0 0 24 24"><path d="M12.337 0c-.475 0-.861 1.016-.861 2.269 0 .527.069 1.011.183 1.396a8.514 8.514 0 0 0-3.961 1.22 5.229 5.229 0 0 0-.595-1.093c-.606-.866-1.34-1.436-1.79-1.43a.381.381 0 0 0-.217.066c-.39.273-.123 1.326.596 2.353.267.381.559.705.84.948a8.683 8.683 0 0 0-1.528 1.716h1.734a7.179 7.179 0 0 1 5.381-2.421 7.18 7.18 0 0 1 5.382 2.42h1.733a8.687 8.687 0 0 0-1.32-1.53c.35-.249.735-.643 1.078-1.133.719-1.027.986-2.08.596-2.353a.382.382 0 0 0-.217-.065c-.45-.007-1.184.563-1.79 1.43a4.897 4.897 0 0 0-.676 1.325 8.52 8.52 0 0 0-3.899-1.42c.12-.39.193-.887.193-1.429 0-1.253-.386-2.269-.862-2.269zM1.624 9.443v5.162h1.358v-1.968h1.64v1.968h1.357V9.443H4.62v1.838H2.98V9.443zm5.912 0v5.162h3.21v-1.108H8.893v-.95h1.64v-1.142h-1.64v-.84h1.853V9.443zm4.698 0v5.162h3.218v-1.362h-1.86v-3.8zm4.706 0v5.162h1.364v-2.643l1.357 1.225 1.35-1.232v2.65h1.365V9.443h-.614l-2.1 1.914-2.109-1.914zm-11.82 7.28a8.688 8.688 0 0 0 1.412 1.548 5.206 5.206 0 0 0-.841.948c-.719 1.027-.985 2.08-.596 2.353.39.273 1.289-.338 2.007-1.364a5.23 5.23 0 0 0 .595-1.092 8.514 8.514 0 0 0 3.961 1.219 5.01 5.01 0 0 0-.183 1.396c0 1.253.386 2.269.861 2.269.476 0 .862-1.016.862-2.269 0-.542-.072-1.04-.193-1.43a8.52 8.52 0 0 0 3.9-1.42c.121.4.352.865.675 1.327.719 1.026 1.617 1.637 2.007 1.364.39-.273.123-1.326-.596-2.353-.343-.49-.727-.885-1.077-1.135a8.69 8.69 0 0 0 1.202-1.36h-1.771a7.174 7.174 0 0 1-5.227 2.252 7.174 7.174 0 0 1-5.226-2.252z" fill="currentColor" stroke="none"/></symbol> 53 55 </svg>
+4 -3
pkg/hold/config.go
··· 248 248 // Populate example quota tiers so operators see the structure 249 249 cfg.Quota = quota.Config{ 250 250 Tiers: map[string]quota.TierConfig{ 251 - "deckhand": {Quota: "5GB"}, 252 - "bosun": {Quota: "50GB", ScanOnPush: true}, 253 - "quartermaster": {Quota: "100GB", ScanOnPush: true}, 251 + "deckhand": {Quota: "5GB", MaxWebhooks: 1}, 252 + "bosun": {Quota: "50GB", ScanOnPush: true, MaxWebhooks: 5, WebhookAllTriggers: true, SupporterBadge: true}, 253 + "quartermaster": {Quota: "100GB", ScanOnPush: true, MaxWebhooks: -1, WebhookAllTriggers: true, SupporterBadge: true}, 254 254 }, 255 255 Defaults: quota.DefaultsConfig{ 256 256 NewCrewTier: "deckhand", 257 + OwnerBadge: true, 257 258 }, 258 259 } 259 260
+5 -4
pkg/hold/pds/did.go
··· 405 405 return did, nil 406 406 } 407 407 408 - // GenerateDIDFromURL creates a did:web identifier from a public URL 409 - // Example: "http://hold1.example.com:8080" -> "did:web:hold1.example.com:8080" 410 - // Note: Per did:web spec, non-standard ports (not 80/443) are included in the DID 408 + // GenerateDIDFromURL creates a did:web identifier from a public URL. 409 + // Per the did:web spec, ports are percent-encoded: the colon becomes %3A. 410 + // Example: "http://hold1.example.com:8080" -> "did:web:hold1.example.com%3A8080" 411 411 func GenerateDIDFromURL(publicURL string) string { 412 412 // Parse URL 413 413 u, err := url.Parse(publicURL) ··· 426 426 port := u.Port() 427 427 428 428 // Include port in DID if it's non-standard (not 80 for http, not 443 for https) 429 + // Per did:web spec, the colon is percent-encoded as %3A 429 430 if port != "" && port != "80" && port != "443" { 430 - return fmt.Sprintf("did:web:%s:%s", hostname, port) 431 + return fmt.Sprintf("did:web:%s%%3A%s", hostname, port) 431 432 } 432 433 433 434 return fmt.Sprintf("did:web:%s", hostname)
+7 -7
pkg/hold/pds/did_test.go
··· 27 27 { 28 28 name: "HTTP with non-standard port", 29 29 publicURL: "http://hold.example.com:8080", 30 - expectedDID: "did:web:hold.example.com:8080", 30 + expectedDID: "did:web:hold.example.com%3A8080", 31 31 }, 32 32 { 33 33 name: "HTTPS with non-standard port", 34 34 publicURL: "https://hold.example.com:8443", 35 - expectedDID: "did:web:hold.example.com:8443", 35 + expectedDID: "did:web:hold.example.com%3A8443", 36 36 }, 37 37 { 38 38 name: "localhost with port", 39 39 publicURL: "http://localhost:8080", 40 - expectedDID: "did:web:localhost:8080", 40 + expectedDID: "did:web:localhost%3A8080", 41 41 }, 42 42 { 43 43 name: "HTTP with explicit port 80", ··· 183 183 keyPath := filepath.Join(tmpDir, "signing-key") 184 184 publicURL := "https://hold.example.com:8443" 185 185 186 - pds, err := NewHoldPDS(ctx, "did:web:hold.example.com:8443", publicURL, dbPath, keyPath, false) 186 + pds, err := NewHoldPDS(ctx, "did:web:hold.example.com%3A8443", publicURL, dbPath, keyPath, false) 187 187 if err != nil { 188 188 t.Fatalf("Failed to create PDS: %v", err) 189 189 } ··· 193 193 t.Fatalf("Failed to generate DID document: %v", err) 194 194 } 195 195 196 - // Verify DID includes port 197 - if doc.ID != "did:web:hold.example.com:8443" { 198 - t.Errorf("Expected DID did:web:hold.example.com:8443, got %s", doc.ID) 196 + // Verify DID includes percent-encoded port 197 + if doc.ID != "did:web:hold.example.com%3A8443" { 198 + t.Errorf("Expected DID did:web:hold.example.com%%3A8443, got %s", doc.ID) 199 199 } 200 200 201 201 // Verify alsoKnownAs includes port
+17
pkg/hold/pds/scan_broadcaster.go
··· 145 145 db.Close() 146 146 return nil, fmt.Errorf("failed to initialize scan_jobs schema: %w", err) 147 147 } 148 + if err := sb.initWebhookSchema(); err != nil { 149 + db.Close() 150 + return nil, fmt.Errorf("failed to initialize webhook schema: %w", err) 151 + } 148 152 149 153 // Start re-dispatch loop for timed-out jobs 150 154 sb.wg.Add(1) ··· 186 190 187 191 if err := sb.initSchema(); err != nil { 188 192 return nil, fmt.Errorf("failed to initialize scan_jobs schema: %w", err) 193 + } 194 + if err := sb.initWebhookSchema(); err != nil { 195 + return nil, fmt.Errorf("failed to initialize webhook schema: %w", err) 189 196 } 190 197 191 198 sb.wg.Add(1) ··· 509 516 510 517 // Store scan result as a record in the hold's embedded PDS 511 518 if msg.Summary != nil { 519 + // Check for existing scan record before creating new one (for webhook dispatch) 520 + var previousScan *atproto.ScanRecord 521 + _, prevScan, err := sb.pds.GetScanRecord(ctx, manifestDigest) 522 + if err == nil { 523 + previousScan = prevScan 524 + } 525 + 512 526 scanRecord := atproto.NewScanRecord( 513 527 manifestDigest, repository, userDID, 514 528 sbomBlob, vulnReportBlob, ··· 529 543 "high", msg.Summary.High, 530 544 "total", msg.Summary.Total) 531 545 } 546 + 547 + // Dispatch webhooks after scan record is stored 548 + go sb.dispatchWebhooks(manifestDigest, repository, tag, userDID, msg.Summary, previousScan) 532 549 } 533 550 534 551 // Mark job as completed
+1
pkg/hold/pds/server.go
··· 32 32 lexutil.RegisterType(atproto.TangledProfileCollection, &atproto.TangledProfileRecord{}) 33 33 lexutil.RegisterType(atproto.StatsCollection, &atproto.StatsRecord{}) 34 34 lexutil.RegisterType(atproto.ScanCollection, &atproto.ScanRecord{}) 35 + lexutil.RegisterType(atproto.WebhookCollection, &atproto.HoldWebhookRecord{}) 35 36 } 36 37 37 38 // HoldPDS is a minimal ATProto PDS implementation for a hold service
+614
pkg/hold/pds/webhooks.go
··· 1 + package pds 2 + 3 + import ( 4 + "context" 5 + "crypto/hmac" 6 + "crypto/sha256" 7 + "encoding/hex" 8 + "encoding/json" 9 + "fmt" 10 + "log/slog" 11 + "net/http" 12 + "net/url" 13 + "strings" 14 + "time" 15 + 16 + "atcr.io/pkg/atproto" 17 + "github.com/go-chi/chi/v5" 18 + "github.com/go-chi/render" 19 + "github.com/ipfs/go-cid" 20 + ) 21 + 22 + // webhookConfig represents a webhook for list/display (masked URL, no secret) 23 + type webhookConfig struct { 24 + Rkey string `json:"rkey"` 25 + Triggers int `json:"triggers"` 26 + URL string `json:"url"` // masked 27 + HasSecret bool `json:"hasSecret"` 28 + CreatedAt string `json:"createdAt"` 29 + } 30 + 31 + // activeWebhook is the internal representation with secret for dispatch 32 + type activeWebhook struct { 33 + Rkey string 34 + URL string 35 + Secret string 36 + Triggers int 37 + } 38 + 39 + // WebhookPayload is the JSON body sent to webhook URLs 40 + type WebhookPayload struct { 41 + Trigger string `json:"trigger"` 42 + HoldDID string `json:"holdDid"` 43 + HoldEndpoint string `json:"holdEndpoint"` 44 + Manifest WebhookManifestInfo `json:"manifest"` 45 + Scan WebhookScanInfo `json:"scan"` 46 + Previous *WebhookVulnCounts `json:"previous"` 47 + } 48 + 49 + // WebhookManifestInfo describes the scanned manifest 50 + type WebhookManifestInfo struct { 51 + Digest string `json:"digest"` 52 + Repository string `json:"repository"` 53 + Tag string `json:"tag"` 54 + UserDID string `json:"userDid"` 55 + } 56 + 57 + // WebhookScanInfo describes the scan results 58 + type WebhookScanInfo struct { 59 + ScannedAt string `json:"scannedAt"` 60 + ScannerVersion string `json:"scannerVersion"` 61 + Vulnerabilities WebhookVulnCounts `json:"vulnerabilities"` 62 + } 63 + 64 + // WebhookVulnCounts contains vulnerability counts by severity 65 + type WebhookVulnCounts struct { 66 + Critical int `json:"critical"` 67 + High int `json:"high"` 68 + Medium int `json:"medium"` 69 + Low int `json:"low"` 70 + Total int `json:"total"` 71 + } 72 + 73 + // initWebhookSchema creates the webhook_secrets table. 74 + // Called from ScanBroadcaster init alongside scan_jobs table. 75 + func (sb *ScanBroadcaster) initWebhookSchema() error { 76 + stmts := []string{ 77 + `CREATE TABLE IF NOT EXISTS webhook_secrets ( 78 + rkey TEXT PRIMARY KEY, 79 + user_did TEXT NOT NULL, 80 + url TEXT NOT NULL, 81 + secret TEXT 82 + )`, 83 + `CREATE INDEX IF NOT EXISTS idx_webhook_secrets_user ON webhook_secrets(user_did)`, 84 + } 85 + for _, stmt := range stmts { 86 + if _, err := sb.db.Exec(stmt); err != nil { 87 + return fmt.Errorf("failed to create webhook_secrets table: %w", err) 88 + } 89 + } 90 + return nil 91 + } 92 + 93 + // CountWebhooks returns the number of webhooks configured for a user 94 + func (sb *ScanBroadcaster) CountWebhooks(userDID string) (int, error) { 95 + var count int 96 + err := sb.db.QueryRow(`SELECT COUNT(*) FROM webhook_secrets WHERE user_did = ?`, userDID).Scan(&count) 97 + return count, err 98 + } 99 + 100 + // ListWebhookConfigs returns webhook configurations for display (masked URLs) 101 + func (sb *ScanBroadcaster) ListWebhookConfigs(userDID string) ([]webhookConfig, error) { 102 + rows, err := sb.db.Query(` 103 + SELECT rkey, url, secret FROM webhook_secrets WHERE user_did = ? 104 + `, userDID) 105 + if err != nil { 106 + return nil, err 107 + } 108 + defer rows.Close() 109 + 110 + var configs []webhookConfig 111 + for rows.Next() { 112 + var rkey, rawURL, secret string 113 + if err := rows.Scan(&rkey, &rawURL, &secret); err != nil { 114 + continue 115 + } 116 + 117 + // Get triggers from PDS record 118 + triggers := 0 119 + _, val, err := sb.pds.repomgr.GetRecord(context.Background(), sb.pds.uid, atproto.WebhookCollection, rkey, cid.Undef) 120 + if err == nil { 121 + if rec, ok := val.(*atproto.HoldWebhookRecord); ok { 122 + triggers = int(rec.Triggers) 123 + } 124 + } 125 + 126 + // Get createdAt from PDS record 127 + createdAt := "" 128 + if val != nil { 129 + if rec, ok := val.(*atproto.HoldWebhookRecord); ok { 130 + createdAt = rec.CreatedAt 131 + } 132 + } 133 + 134 + configs = append(configs, webhookConfig{ 135 + Rkey: rkey, 136 + Triggers: triggers, 137 + URL: maskURL(rawURL), 138 + HasSecret: secret != "", 139 + CreatedAt: createdAt, 140 + }) 141 + } 142 + if configs == nil { 143 + configs = []webhookConfig{} 144 + } 145 + return configs, nil 146 + } 147 + 148 + // AddWebhookConfig creates a new webhook: stores secret in SQLite, record in PDS 149 + func (sb *ScanBroadcaster) AddWebhookConfig(userDID, webhookURL, secret string, triggers int) (string, cid.Cid, error) { 150 + ctx := context.Background() 151 + 152 + // Find next available sequence number for this user 153 + var maxSeq int 154 + err := sb.db.QueryRow(` 155 + SELECT COUNT(*) FROM webhook_secrets WHERE user_did = ? 156 + `, userDID).Scan(&maxSeq) 157 + if err != nil { 158 + return "", cid.Undef, fmt.Errorf("failed to count existing webhooks: %w", err) 159 + } 160 + 161 + rkey := atproto.WebhookRecordKey(userDID, maxSeq) 162 + 163 + // Create PDS record 164 + record := atproto.NewHoldWebhookRecord(userDID, triggers) 165 + _, recordCID, err := sb.pds.repomgr.PutRecord(ctx, sb.pds.uid, atproto.WebhookCollection, rkey, record) 166 + if err != nil { 167 + return "", cid.Undef, fmt.Errorf("failed to create webhook PDS record: %w", err) 168 + } 169 + 170 + // Store secret in SQLite 171 + _, err = sb.db.Exec(` 172 + INSERT INTO webhook_secrets (rkey, user_did, url, secret) VALUES (?, ?, ?, ?) 173 + `, rkey, userDID, webhookURL, secret) 174 + if err != nil { 175 + // Try to clean up PDS record on SQLite failure 176 + _ = sb.pds.repomgr.DeleteRecord(ctx, sb.pds.uid, atproto.WebhookCollection, rkey) 177 + return "", cid.Undef, fmt.Errorf("failed to store webhook secret: %w", err) 178 + } 179 + 180 + return rkey, recordCID, nil 181 + } 182 + 183 + // DeleteWebhookConfig deletes a webhook by rkey (validates ownership) 184 + func (sb *ScanBroadcaster) DeleteWebhookConfig(userDID, rkey string) error { 185 + ctx := context.Background() 186 + 187 + // Validate ownership 188 + var owner string 189 + err := sb.db.QueryRow(`SELECT user_did FROM webhook_secrets WHERE rkey = ?`, rkey).Scan(&owner) 190 + if err != nil { 191 + return fmt.Errorf("webhook not found") 192 + } 193 + if owner != userDID { 194 + return fmt.Errorf("unauthorized: webhook belongs to a different user") 195 + } 196 + 197 + // Delete SQLite row 198 + if _, err := sb.db.Exec(`DELETE FROM webhook_secrets WHERE rkey = ?`, rkey); err != nil { 199 + return fmt.Errorf("failed to delete webhook secret: %w", err) 200 + } 201 + 202 + // Delete PDS record 203 + if err := sb.pds.repomgr.DeleteRecord(ctx, sb.pds.uid, atproto.WebhookCollection, rkey); err != nil { 204 + slog.Warn("Failed to delete webhook PDS record (secret already removed)", "rkey", rkey, "error", err) 205 + } 206 + 207 + return nil 208 + } 209 + 210 + // GetWebhooksForUser returns all active webhooks with secrets for dispatch 211 + func (sb *ScanBroadcaster) GetWebhooksForUser(userDID string) ([]activeWebhook, error) { 212 + rows, err := sb.db.Query(` 213 + SELECT rkey, url, secret FROM webhook_secrets WHERE user_did = ? 214 + `, userDID) 215 + if err != nil { 216 + return nil, err 217 + } 218 + defer rows.Close() 219 + 220 + var webhooks []activeWebhook 221 + for rows.Next() { 222 + var w activeWebhook 223 + if err := rows.Scan(&w.Rkey, &w.URL, &w.Secret); err != nil { 224 + continue 225 + } 226 + 227 + // Get triggers from PDS record 228 + _, val, err := sb.pds.repomgr.GetRecord(context.Background(), sb.pds.uid, atproto.WebhookCollection, w.Rkey, cid.Undef) 229 + if err == nil { 230 + if rec, ok := val.(*atproto.HoldWebhookRecord); ok { 231 + w.Triggers = int(rec.Triggers) 232 + } 233 + } 234 + 235 + webhooks = append(webhooks, w) 236 + } 237 + return webhooks, nil 238 + } 239 + 240 + // dispatchWebhooks fires matching webhooks after a scan completes 241 + func (sb *ScanBroadcaster) dispatchWebhooks(manifestDigest, repository, tag, userDID string, summary *VulnerabilitySummary, previousScan *atproto.ScanRecord) { 242 + webhooks, err := sb.GetWebhooksForUser(userDID) 243 + if err != nil || len(webhooks) == 0 { 244 + return 245 + } 246 + 247 + isFirst := previousScan == nil 248 + isChanged := previousScan != nil && vulnCountsChanged(summary, previousScan) 249 + 250 + scanInfo := WebhookScanInfo{ 251 + ScannedAt: time.Now().Format(time.RFC3339), 252 + ScannerVersion: "atcr-scanner-v1.0.0", 253 + Vulnerabilities: WebhookVulnCounts{ 254 + Critical: summary.Critical, 255 + High: summary.High, 256 + Medium: summary.Medium, 257 + Low: summary.Low, 258 + Total: summary.Total, 259 + }, 260 + } 261 + 262 + manifestInfo := WebhookManifestInfo{ 263 + Digest: manifestDigest, 264 + Repository: repository, 265 + Tag: tag, 266 + UserDID: userDID, 267 + } 268 + 269 + for _, wh := range webhooks { 270 + // Check each trigger condition 271 + triggers := []string{} 272 + if wh.Triggers&atproto.TriggerFirst != 0 && isFirst { 273 + triggers = append(triggers, "scan:first") 274 + } 275 + if wh.Triggers&atproto.TriggerAll != 0 { 276 + triggers = append(triggers, "scan:all") 277 + } 278 + if wh.Triggers&atproto.TriggerChanged != 0 && isChanged { 279 + triggers = append(triggers, "scan:changed") 280 + } 281 + 282 + for _, trigger := range triggers { 283 + payload := WebhookPayload{ 284 + Trigger: trigger, 285 + HoldDID: sb.holdDID, 286 + HoldEndpoint: sb.holdEndpoint, 287 + Manifest: manifestInfo, 288 + Scan: scanInfo, 289 + } 290 + 291 + // Include previous counts for scan:changed 292 + if trigger == "scan:changed" && previousScan != nil { 293 + payload.Previous = &WebhookVulnCounts{ 294 + Critical: int(previousScan.Critical), 295 + High: int(previousScan.High), 296 + Medium: int(previousScan.Medium), 297 + Low: int(previousScan.Low), 298 + Total: int(previousScan.Total), 299 + } 300 + } 301 + 302 + payloadBytes, err := json.Marshal(payload) 303 + if err != nil { 304 + slog.Error("Failed to marshal webhook payload", "error", err) 305 + continue 306 + } 307 + 308 + go sb.deliverWithRetry(wh.URL, wh.Secret, payloadBytes) 309 + } 310 + } 311 + } 312 + 313 + // deliverWithRetry attempts to deliver a webhook with exponential backoff 314 + func (sb *ScanBroadcaster) deliverWithRetry(webhookURL, secret string, payload []byte) { 315 + delays := []time.Duration{0, 30 * time.Second, 2 * time.Minute, 8 * time.Minute} 316 + for attempt, delay := range delays { 317 + if attempt > 0 { 318 + time.Sleep(delay) 319 + } 320 + if sb.attemptDelivery(webhookURL, secret, payload) { 321 + return 322 + } 323 + } 324 + slog.Warn("Webhook delivery failed after retries", "url", maskURL(webhookURL)) 325 + } 326 + 327 + // attemptDelivery sends a single webhook HTTP POST 328 + func (sb *ScanBroadcaster) attemptDelivery(webhookURL, secret string, payload []byte) bool { 329 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 330 + defer cancel() 331 + 332 + req, err := http.NewRequestWithContext(ctx, "POST", webhookURL, strings.NewReader(string(payload))) 333 + if err != nil { 334 + slog.Warn("Failed to create webhook request", "error", err) 335 + return false 336 + } 337 + 338 + req.Header.Set("Content-Type", "application/json") 339 + req.Header.Set("User-Agent", "ATCR-Webhook/1.0") 340 + 341 + // HMAC signing if secret is set 342 + if secret != "" { 343 + mac := hmac.New(sha256.New, []byte(secret)) 344 + mac.Write(payload) 345 + sig := hex.EncodeToString(mac.Sum(nil)) 346 + req.Header.Set("X-Webhook-Signature-256", "sha256="+sig) 347 + } 348 + 349 + client := &http.Client{Timeout: 10 * time.Second} 350 + resp, err := client.Do(req) 351 + if err != nil { 352 + slog.Debug("Webhook delivery attempt failed", "url", maskURL(webhookURL), "error", err) 353 + return false 354 + } 355 + defer resp.Body.Close() 356 + 357 + if resp.StatusCode >= 200 && resp.StatusCode < 300 { 358 + slog.Info("Webhook delivered successfully", "url", maskURL(webhookURL), "status", resp.StatusCode) 359 + return true 360 + } 361 + 362 + slog.Debug("Webhook delivery got non-2xx response", "url", maskURL(webhookURL), "status", resp.StatusCode) 363 + return false 364 + } 365 + 366 + // vulnCountsChanged checks if vulnerability counts differ between current scan and previous 367 + func vulnCountsChanged(current *VulnerabilitySummary, previous *atproto.ScanRecord) bool { 368 + return current.Critical != int(previous.Critical) || 369 + current.High != int(previous.High) || 370 + current.Medium != int(previous.Medium) || 371 + current.Low != int(previous.Low) 372 + } 373 + 374 + // maskURL masks a URL for display (shows scheme + host, hides path/query) 375 + func maskURL(rawURL string) string { 376 + u, err := url.Parse(rawURL) 377 + if err != nil { 378 + if len(rawURL) > 30 { 379 + return rawURL[:30] + "***" 380 + } 381 + return rawURL 382 + } 383 + masked := u.Scheme + "://" + u.Host 384 + if u.Path != "" && u.Path != "/" { 385 + masked += "/***" 386 + } 387 + return masked 388 + } 389 + 390 + // ---- XRPC Handlers ---- 391 + 392 + // HandleListWebhooks returns webhook configs for a user 393 + func (h *XRPCHandler) HandleListWebhooks(w http.ResponseWriter, r *http.Request) { 394 + user := getUserFromContext(r) 395 + if user == nil { 396 + http.Error(w, "authentication required", http.StatusUnauthorized) 397 + return 398 + } 399 + 400 + if h.scanBroadcaster == nil { 401 + render.JSON(w, r, map[string]any{ 402 + "webhooks": []any{}, 403 + "limits": map[string]any{"max": 0, "allTriggers": false}, 404 + }) 405 + return 406 + } 407 + 408 + configs, err := h.scanBroadcaster.ListWebhookConfigs(user.DID) 409 + if err != nil { 410 + http.Error(w, fmt.Sprintf("failed to list webhooks: %v", err), http.StatusInternalServerError) 411 + return 412 + } 413 + 414 + // Get tier limits 415 + maxWebhooks, allTriggers := 1, false 416 + if h.quotaMgr != nil { 417 + _, crew, _ := h.pds.GetCrewMemberByDID(r.Context(), user.DID) 418 + tierKey := "" 419 + if crew != nil { 420 + tierKey = crew.Tier 421 + } 422 + maxWebhooks, allTriggers = h.quotaMgr.WebhookLimits(tierKey) 423 + } 424 + 425 + render.JSON(w, r, map[string]any{ 426 + "webhooks": configs, 427 + "limits": map[string]any{ 428 + "max": maxWebhooks, 429 + "allTriggers": allTriggers, 430 + }, 431 + }) 432 + } 433 + 434 + // HandleAddWebhook creates a new webhook configuration 435 + func (h *XRPCHandler) HandleAddWebhook(w http.ResponseWriter, r *http.Request) { 436 + user := getUserFromContext(r) 437 + if user == nil { 438 + http.Error(w, "authentication required", http.StatusUnauthorized) 439 + return 440 + } 441 + 442 + if h.scanBroadcaster == nil { 443 + http.Error(w, "scanning not enabled", http.StatusNotImplemented) 444 + return 445 + } 446 + 447 + var req struct { 448 + URL string `json:"url"` 449 + Secret string `json:"secret"` 450 + Triggers int `json:"triggers"` 451 + } 452 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 453 + http.Error(w, "invalid request body", http.StatusBadRequest) 454 + return 455 + } 456 + 457 + // Validate HTTPS URL 458 + u, err := url.Parse(req.URL) 459 + if err != nil || (u.Scheme != "https" && u.Scheme != "http") { 460 + http.Error(w, "invalid webhook URL: must be https", http.StatusBadRequest) 461 + return 462 + } 463 + 464 + // Tier enforcement 465 + tierKey := "" 466 + _, crew, _ := h.pds.GetCrewMemberByDID(r.Context(), user.DID) 467 + if crew != nil { 468 + tierKey = crew.Tier 469 + } 470 + 471 + maxWebhooks, allTriggers := 1, false 472 + if h.quotaMgr != nil { 473 + maxWebhooks, allTriggers = h.quotaMgr.WebhookLimits(tierKey) 474 + } 475 + 476 + // Check webhook count limit 477 + count, err := h.scanBroadcaster.CountWebhooks(user.DID) 478 + if err != nil { 479 + http.Error(w, "failed to check webhook count", http.StatusInternalServerError) 480 + return 481 + } 482 + if maxWebhooks >= 0 && count >= maxWebhooks { 483 + http.Error(w, fmt.Sprintf("webhook limit reached (%d/%d)", count, maxWebhooks), http.StatusForbidden) 484 + return 485 + } 486 + 487 + // Trigger bitmask enforcement: free users can only set TriggerFirst 488 + if !allTriggers && req.Triggers & ^atproto.TriggerFirst != 0 { 489 + http.Error(w, "trigger types beyond scan:first require a paid tier", http.StatusForbidden) 490 + return 491 + } 492 + 493 + rkey, recordCID, err := h.scanBroadcaster.AddWebhookConfig(user.DID, req.URL, req.Secret, req.Triggers) 494 + if err != nil { 495 + http.Error(w, fmt.Sprintf("failed to add webhook: %v", err), http.StatusInternalServerError) 496 + return 497 + } 498 + 499 + render.Status(r, http.StatusCreated) 500 + render.JSON(w, r, map[string]any{ 501 + "rkey": rkey, 502 + "cid": recordCID.String(), 503 + }) 504 + } 505 + 506 + // HandleDeleteWebhook deletes a webhook configuration 507 + func (h *XRPCHandler) HandleDeleteWebhook(w http.ResponseWriter, r *http.Request) { 508 + user := getUserFromContext(r) 509 + if user == nil { 510 + http.Error(w, "authentication required", http.StatusUnauthorized) 511 + return 512 + } 513 + 514 + if h.scanBroadcaster == nil { 515 + http.Error(w, "scanning not enabled", http.StatusNotImplemented) 516 + return 517 + } 518 + 519 + var req struct { 520 + Rkey string `json:"rkey"` 521 + } 522 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 523 + http.Error(w, "invalid request body", http.StatusBadRequest) 524 + return 525 + } 526 + 527 + if err := h.scanBroadcaster.DeleteWebhookConfig(user.DID, req.Rkey); err != nil { 528 + if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "unauthorized") { 529 + http.Error(w, err.Error(), http.StatusForbidden) 530 + } else { 531 + http.Error(w, fmt.Sprintf("failed to delete webhook: %v", err), http.StatusInternalServerError) 532 + } 533 + return 534 + } 535 + 536 + render.JSON(w, r, map[string]any{"success": true}) 537 + } 538 + 539 + // HandleTestWebhook sends a test payload to a webhook 540 + func (h *XRPCHandler) HandleTestWebhook(w http.ResponseWriter, r *http.Request) { 541 + user := getUserFromContext(r) 542 + if user == nil { 543 + http.Error(w, "authentication required", http.StatusUnauthorized) 544 + return 545 + } 546 + 547 + if h.scanBroadcaster == nil { 548 + http.Error(w, "scanning not enabled", http.StatusNotImplemented) 549 + return 550 + } 551 + 552 + var req struct { 553 + Rkey string `json:"rkey"` 554 + } 555 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 556 + http.Error(w, "invalid request body", http.StatusBadRequest) 557 + return 558 + } 559 + 560 + // Look up webhook URL and secret 561 + var webhookURL, secret, owner string 562 + err := h.scanBroadcaster.db.QueryRow(` 563 + SELECT url, secret, user_did FROM webhook_secrets WHERE rkey = ? 564 + `, req.Rkey).Scan(&webhookURL, &secret, &owner) 565 + if err != nil { 566 + http.Error(w, "webhook not found", http.StatusNotFound) 567 + return 568 + } 569 + if owner != user.DID { 570 + http.Error(w, "unauthorized", http.StatusForbidden) 571 + return 572 + } 573 + 574 + // Build test payload 575 + payload := WebhookPayload{ 576 + Trigger: "test", 577 + HoldDID: h.scanBroadcaster.holdDID, 578 + HoldEndpoint: h.scanBroadcaster.holdEndpoint, 579 + Manifest: WebhookManifestInfo{ 580 + Digest: "sha256:0000000000000000000000000000000000000000000000000000000000000000", 581 + Repository: "test-repo", 582 + Tag: "latest", 583 + UserDID: user.DID, 584 + }, 585 + Scan: WebhookScanInfo{ 586 + ScannedAt: time.Now().Format(time.RFC3339), 587 + ScannerVersion: "atcr-scanner-v1.0.0", 588 + Vulnerabilities: WebhookVulnCounts{ 589 + Critical: 0, High: 1, Medium: 3, Low: 5, Total: 9, 590 + }, 591 + }, 592 + } 593 + 594 + payloadBytes, _ := json.Marshal(payload) 595 + 596 + // Deliver test payload synchronously 597 + success := h.scanBroadcaster.attemptDelivery(webhookURL, secret, payloadBytes) 598 + 599 + render.JSON(w, r, map[string]any{ 600 + "success": success, 601 + }) 602 + } 603 + 604 + // registerWebhookHandlers registers webhook XRPC handlers on the router. 605 + // Called from RegisterHandlers. 606 + func (h *XRPCHandler) registerWebhookHandlers(r chi.Router) { 607 + r.Group(func(r chi.Router) { 608 + r.Use(h.requireAuth) 609 + r.Get(atproto.HoldListWebhooks, h.HandleListWebhooks) 610 + r.Post(atproto.HoldAddWebhook, h.HandleAddWebhook) 611 + r.Post(atproto.HoldDeleteWebhook, h.HandleDeleteWebhook) 612 + r.Post(atproto.HoldTestWebhook, h.HandleTestWebhook) 613 + }) 614 + }
+16 -6
pkg/hold/pds/xrpc.go
··· 201 201 r.Use(h.requireAuth) 202 202 r.Post(atproto.HoldRequestCrew, h.HandleRequestCrew) 203 203 // GDPR data export endpoint 204 - r.Get("/xrpc/io.atcr.hold.exportUserData", h.HandleExportUserData) 204 + r.Get(atproto.HoldExportUserData, h.HandleExportUserData) 205 205 // GDPR data deletion endpoint 206 - r.Delete("/xrpc/io.atcr.hold.deleteUserData", h.HandleDeleteUserData) 206 + r.Delete(atproto.HoldDeleteUserData, h.HandleDeleteUserData) 207 207 }) 208 208 209 209 // Public quota endpoint (no auth - quota is per-user, just needs userDid param) ··· 211 211 212 212 // Scanner WebSocket endpoint (shared secret auth) 213 213 r.Get(atproto.HoldSubscribeScanJobs, h.HandleSubscribeScanJobs) 214 + 215 + // Webhook management endpoints (service token auth) 216 + h.registerWebhookHandlers(r) 214 217 } 215 218 216 219 // HandleHealth returns health check information ··· 248 251 249 252 // For this hold PDS, the handle is the domain part of the DID 250 253 // e.g., "hold01.atcr.io did:web:hold01.atcr.io" 251 - expectedHandle := strings.TrimPrefix(h.pds.DID(), "did:web:") 254 + expectedHandle := didWebHandle(h.pds.DID()) 252 255 253 256 // Check if the handle matches 254 257 if handle != expectedHandle { ··· 275 278 actorDID := actor 276 279 if !atproto.IsDID(actor) { 277 280 // It's a handle, resolve to DID 278 - expectedHandle := strings.TrimPrefix(h.pds.DID(), "did:web:") 281 + expectedHandle := didWebHandle(h.pds.DID()) 279 282 if actor == expectedHandle { 280 283 actorDID = h.pds.DID() 281 284 } else { ··· 307 310 profiles := []map[string]any{} 308 311 309 312 // Expected handle for this hold 310 - expectedHandle := strings.TrimPrefix(h.pds.DID(), "did:web:") 313 + expectedHandle := didWebHandle(h.pds.DID()) 311 314 312 315 // Check each actor to see if it matches this hold's DID 313 316 for _, actor := range actors { ··· 356 359 // Base response with minimal info 357 360 response := map[string]any{ 358 361 "did": h.pds.DID(), 359 - "handle": strings.TrimPrefix(h.pds.DID(), "did:web:"), 362 + "handle": didWebHandle(h.pds.DID()), 360 363 "postsCount": 0, 361 364 "followersCount": 0, 362 365 "followsCount": 0, ··· 1834 1837 StatsDeleted: result.StatsDeleted, 1835 1838 }) 1836 1839 } 1840 + 1841 + // didWebHandle extracts the hostname (handle) from a did:web DID, 1842 + // decoding percent-encoded ports (e.g. did:web:host%3A8080 → host:8080). 1843 + func didWebHandle(did string) string { 1844 + host := strings.TrimPrefix(did, "did:web:") 1845 + return strings.ReplaceAll(host, "%3A", ":") 1846 + }
+65
pkg/hold/quota/config.go
··· 5 5 "fmt" 6 6 "os" 7 7 "regexp" 8 + "sort" 8 9 "strconv" 9 10 "strings" 10 11 ··· 27 28 28 29 // Whether pushing triggers an immediate vulnerability scan. 29 30 ScanOnPush bool `yaml:"scan_on_push" comment:"Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling."` 31 + 32 + // Maximum number of webhook URLs a user can configure. 0 = none, -1 = unlimited. 33 + MaxWebhooks int `yaml:"max_webhooks" comment:"Maximum webhook URLs (0=none, -1=unlimited). Default: 1."` 34 + 35 + // Whether all trigger types are allowed. Free tiers only get scan:first. 36 + WebhookAllTriggers bool `yaml:"webhook_all_triggers" comment:"Allow all webhook trigger types. Free tiers only get scan:first."` 37 + 38 + // Whether this tier earns a supporter badge on user profiles. 39 + SupporterBadge bool `yaml:"supporter_badge" comment:"Show supporter badge on user profiles for members at this tier."` 30 40 } 31 41 32 42 // DefaultsConfig represents default settings. 33 43 type DefaultsConfig struct { 34 44 // Name of the tier assigned to new crew members. 35 45 NewCrewTier string `yaml:"new_crew_tier" comment:"Tier assigned to new crew members who don't have an explicit tier."` 46 + 47 + // Whether the hold owner (captain) gets a supporter badge on their profile. 48 + OwnerBadge bool `yaml:"owner_badge" comment:"Show supporter badge on the hold owner's profile."` 36 49 } 37 50 38 51 // Manager manages quota configuration and tier resolution ··· 193 206 } 194 207 195 208 return false 209 + } 210 + 211 + // WebhookLimits returns the webhook limits for a tier. 212 + // Returns (maxWebhooks, allTriggers). Default when no config: (1, false). 213 + // Follows the same fallback logic as GetTierLimit. 214 + func (m *Manager) WebhookLimits(tierKey string) (maxWebhooks int, allTriggers bool) { 215 + if !m.IsEnabled() { 216 + return 1, false 217 + } 218 + 219 + if tierKey != "" { 220 + if tier, ok := m.config.Tiers[tierKey]; ok { 221 + max := tier.MaxWebhooks 222 + if max == 0 { 223 + max = 1 // default 224 + } 225 + return max, tier.WebhookAllTriggers 226 + } 227 + } 228 + 229 + // Fall back to default tier 230 + if m.config.Defaults.NewCrewTier != "" { 231 + if tier, ok := m.config.Tiers[m.config.Defaults.NewCrewTier]; ok { 232 + max := tier.MaxWebhooks 233 + if max == 0 { 234 + max = 1 235 + } 236 + return max, tier.WebhookAllTriggers 237 + } 238 + } 239 + 240 + return 1, false 241 + } 242 + 243 + // BadgeTiers returns the names of tiers that have supporter badges enabled. 244 + // Includes "owner" if defaults.owner_badge is true. 245 + // Returns nil if quotas are disabled or no tiers have badges. 246 + func (m *Manager) BadgeTiers() []string { 247 + if !m.IsEnabled() { 248 + return nil 249 + } 250 + var tiers []string 251 + if m.config.Defaults.OwnerBadge { 252 + tiers = append(tiers, "owner") 253 + } 254 + for name, tier := range m.config.Tiers { 255 + if tier.SupporterBadge { 256 + tiers = append(tiers, name) 257 + } 258 + } 259 + sort.Strings(tiers) 260 + return tiers 196 261 } 197 262 198 263 // TierCount returns the number of configured tiers
+33
pkg/hold/server.go
··· 67 67 Config: cfg, 68 68 } 69 69 70 + if cfg.Server.TestMode { 71 + atproto.SetTestMode(true) 72 + } 73 + 70 74 // Initialize embedded PDS if database path is configured 71 75 var xrpcHandler *pds.XRPCHandler 72 76 var s3Service *s3.S3Service ··· 182 186 slog.Info("Quota enforcement enabled", "tiers", s.QuotaManager.TierCount(), "defaultTier", s.QuotaManager.GetDefaultTier()) 183 187 } else { 184 188 slog.Info("Quota enforcement disabled (no quota tiers configured)") 189 + } 190 + 191 + // Sync supporter badge tiers from quota config into captain record 192 + if s.PDS != nil { 193 + badgeTiers := s.QuotaManager.BadgeTiers() 194 + badgeCtx := context.Background() 195 + if _, captain, err := s.PDS.GetCaptainRecord(badgeCtx); err == nil { 196 + if !stringSlicesEqual(captain.SupporterBadgeTiers, badgeTiers) { 197 + captain.SupporterBadgeTiers = badgeTiers 198 + if _, err := s.PDS.UpdateCaptainRecord(badgeCtx, captain); err != nil { 199 + slog.Warn("Failed to sync supporter badge tiers", "error", err) 200 + } else { 201 + slog.Info("Synced supporter badge tiers from quota config", "tiers", badgeTiers) 202 + } 203 + } 204 + } 185 205 } 186 206 187 207 // Create XRPC handlers ··· 408 428 409 429 logging.Shutdown() 410 430 } 431 + 432 + // stringSlicesEqual returns true if two string slices have the same elements. 433 + func stringSlicesEqual(a, b []string) bool { 434 + if len(a) != len(b) { 435 + return false 436 + } 437 + for i := range a { 438 + if a[i] != b[i] { 439 + return false 440 + } 441 + } 442 + return true 443 + }