···4747 test_mode: false
4848 # Request crawl from this relay on startup to make the embedded PDS discoverable.
4949 relay_endpoint: ""
5050+ # Preferred appview URL for links in webhooks and Bluesky posts, e.g. "https://seamark.dev".
5151+ appview_url: https://atcr.io
5052 # Read timeout for HTTP requests.
5153 read_timeout: 5m0s
5254 # Write timeout for HTTP requests.
···110112 # Allow all webhook trigger types. Free tiers only get scan:first.
111113 webhook_all_triggers: false
112114 # Show supporter badge on user profiles for members at this tier.
113113- supporter_badge: true
115115+ supporter_badge: false
114116 - # Tier name used as the key for crew assignments.
115117 name: bosun
116118 # Storage quota limit (e.g. "5GB", "50GB", "1TB").
···872872 }
873873}
874874875875-// WebhookRecordKey generates a deterministic rkey for a webhook record
876876-// Uses hash of userDID + sequence number to support multiple webhooks per user
877877-func WebhookRecordKey(userDID string, seq int) string {
878878- combined := fmt.Sprintf("%s/webhook/%d", userDID, seq)
879879- hash := sha256.Sum256([]byte(combined))
880880- return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(hash[:16]))
881881-}
882882-883875// TangledProfileRecord represents a Tangled profile for the hold
884876// Collection: sh.tangled.actor.profile (singleton record at rkey "self")
885877// Stored in the hold's embedded PDS
···128128 // Request crawl from this relay on startup.
129129 RelayEndpoint string `yaml:"relay_endpoint" comment:"Request crawl from this relay on startup to make the embedded PDS discoverable."`
130130131131+ // Preferred appview URL for links in webhooks and Bluesky posts.
132132+ AppviewURL string `yaml:"appview_url" comment:"Preferred appview URL for links in webhooks and Bluesky posts, e.g. \"https://seamark.dev\"."`
133133+131134 // ReadTimeout for HTTP requests.
132135 ReadTimeout time.Duration `yaml:"read_timeout" comment:"Read timeout for HTTP requests."`
133136···186189 v.SetDefault("server.successor", "")
187190 v.SetDefault("server.test_mode", false)
188191 v.SetDefault("server.relay_endpoint", "")
192192+ v.SetDefault("server.appview_url", "https://atcr.io")
189193 v.SetDefault("server.read_timeout", "5m")
190194 v.SetDefault("server.write_timeout", "5m")
191195
···2828 now := time.Now()
29293030 // Build AppView repository URL
3131- appViewURL := fmt.Sprintf("https://atcr.io/r/%s/%s", userHandle, repository)
3131+ appViewURL := fmt.Sprintf("%s/r/%s/%s", p.appviewURL, userHandle, repository)
32323333 // Build simplified text with mention - OG card handles the link
3434 repoWithTag := fmt.Sprintf("%s:%s", repository, tag)
···4545 // Build embed with OG card
4646 var embed *bsky.FeedPost_Embed
47474848- ogImageData, err := fetchOGImage(ctx, userHandle, repository)
4848+ ogImageData, err := fetchOGImage(ctx, p.appviewURL, userHandle, repository)
4949 if err != nil {
5050 slog.Warn("Failed to fetch OG image, posting without embed", "error", err)
5151 } else {
···5555 slog.Warn("Failed to upload OG image blob", "error", err)
5656 } else {
5757 // Build dynamic description
5858+ brandName := p.AppviewMeta().ClientShortName
5859 var description string
5960 if artifactType == "helm-chart" {
6060- description = "Helm chart pushed to ATCR"
6161+ description = "Helm chart pushed to " + brandName
6162 } else if len(platforms) > 0 {
6263 description = fmt.Sprintf("Multi-arch: %s", strings.Join(platforms, ", "))
6364 } else {
6464- description = fmt.Sprintf("Pushed %s to ATCR", formatSize(totalSize))
6565+ description = fmt.Sprintf("Pushed %s to %s", formatSize(totalSize), brandName)
6566 }
66676768 embed = &bsky.FeedPost_Embed{
···111112}
112113113114// fetchOGImage downloads the OG card image from AppView
114114-func fetchOGImage(ctx context.Context, userHandle, repository string) ([]byte, error) {
115115- url := fmt.Sprintf("https://atcr.io/og/r/%s/%s", userHandle, repository)
115115+func fetchOGImage(ctx context.Context, appviewURL, userHandle, repository string) ([]byte, error) {
116116+ url := fmt.Sprintf("%s/og/r/%s/%s", appviewURL, userHandle, repository)
116117117118 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
118119 if err != nil {
+5
pkg/hold/pds/repomgr.go
···8686 clk *syntax.TIDClock
8787}
88888989+// NextTID generates a new TID for use as a record key.
9090+func (rm *RepoManager) NextTID() string {
9191+ return rm.clk.Next().String()
9292+}
9393+8994type ActorInfo struct {
9095 Did string
9196 Handle string
+4-3
pkg/hold/pds/scan_broadcaster.go
···476476 repository string
477477 tag string
478478 userDID string
479479+ userHandle string
479480 )
480481 err := sb.db.QueryRow(`
481481- SELECT manifest_digest, repository, tag, user_did
482482+ SELECT manifest_digest, repository, tag, user_did, COALESCE(user_handle, '')
482483 FROM scan_jobs WHERE seq = ?
483483- `, msg.Seq).Scan(&manifestDigest, &repository, &tag, &userDID)
484484+ `, msg.Seq).Scan(&manifestDigest, &repository, &tag, &userDID, &userHandle)
484485 if err != nil {
485486 slog.Error("Failed to get job details for result storage",
486487 "seq", msg.Seq,
···545546 }
546547547548 // Dispatch webhooks after scan record is stored
548548- go sb.dispatchWebhooks(manifestDigest, repository, tag, userDID, msg.Summary, previousScan)
549549+ go sb.dispatchWebhooks(manifestDigest, repository, tag, userDID, userHandle, msg.Summary, previousScan)
549550 }
550551551552 // Mark job as completed
+22-2
pkg/hold/pds/server.go
···3939type HoldPDS struct {
4040 did string
4141 PublicURL string
4242+ appviewURL string
4343+ appviewMeta *atproto.AppviewMetadata
4244 carstore holddb.CarStore
4345 repomgr *RepoManager
4446 dbPath string
···4850 recordsIndex *RecordsIndex
4951}
50525353+// AppviewURL returns the configured appview base URL for links in webhooks and posts.
5454+func (p *HoldPDS) AppviewURL() string { return p.appviewURL }
5555+5656+// AppviewMeta returns cached appview metadata, or defaults derived from the appview URL.
5757+func (p *HoldPDS) AppviewMeta() atproto.AppviewMetadata {
5858+ if p.appviewMeta != nil {
5959+ return *p.appviewMeta
6060+ }
6161+ return atproto.DefaultAppviewMetadata(p.appviewURL)
6262+}
6363+6464+// SetAppviewMeta caches appview metadata fetched on startup.
6565+func (p *HoldPDS) SetAppviewMeta(m *atproto.AppviewMetadata) {
6666+ p.appviewMeta = m
6767+}
6868+5169// NewHoldPDS creates or opens a hold PDS with SQLite carstore
5252-func NewHoldPDS(ctx context.Context, did, publicURL, dbPath, keyPath string, enableBlueskyPosts bool) (*HoldPDS, error) {
7070+func NewHoldPDS(ctx context.Context, did, publicURL, appviewURL, dbPath, keyPath string, enableBlueskyPosts bool) (*HoldPDS, error) {
5371 // Generate or load signing key
5472 signingKey, err := oauth.GenerateOrLoadPDSKey(keyPath)
5573 if err != nil {
···116134 return &HoldPDS{
117135 did: did,
118136 PublicURL: publicURL,
137137+ appviewURL: appviewURL,
119138 carstore: cs,
120139 repomgr: rm,
121140 dbPath: dbPath,
···129148// NewHoldPDSWithDB creates or opens a hold PDS using an existing *sql.DB connection.
130149// The caller is responsible for the DB lifecycle. Used when the database is
131150// centrally managed (e.g., with libsql embedded replicas).
132132-func NewHoldPDSWithDB(ctx context.Context, did, publicURL, dbPath, keyPath string, enableBlueskyPosts bool, db *sql.DB) (*HoldPDS, error) {
151151+func NewHoldPDSWithDB(ctx context.Context, did, publicURL, appviewURL, dbPath, keyPath string, enableBlueskyPosts bool, db *sql.DB) (*HoldPDS, error) {
133152 signingKey, err := oauth.GenerateOrLoadPDSKey(keyPath)
134153 if err != nil {
135154 return nil, fmt.Errorf("failed to initialize signing key: %w", err)
···161180 return &HoldPDS{
162181 did: did,
163182 PublicURL: publicURL,
183183+ appviewURL: appviewURL,
164184 carstore: cs,
165185 repomgr: rm,
166186 dbPath: dbPath,