···11# Tangled Workflow: Release Credential Helper
22#
33-# This workflow builds cross-platform binaries for the credential helper.
44-# Creates tarballs for curl/bash installation and provides instructions
55-# for updating the Homebrew formula.
33+# Builds cross-platform binaries using GoReleaser and publishes
44+# artifacts to the repo owner's PDS as sh.tangled.repo.artifact records.
65#
76# Triggers on version tags (v*) pushed to the repository.
77+#
88+# Required secrets: PUBLISH_APP_PASSWORD (ATProto app password for artifact publishing)
89910when:
1011 - event: ["manual"]
1112 tag: ["v*"]
12131313-engine: "nixery"
1414-1515-dependencies:
1616- nixpkgs:
1717- - go_1_24 # Go 1.24+ for building
1818- - goreleaser # For building multi-platform binaries
1919- - curl # Required by go generate for downloading vendor assets
2020- - gnugrep # Required for tag detection
2121- - gnutar # Required for creating tarballs
2222- - gzip # Required for compressing tarballs
2323- - coreutils # Required for sha256sum
1414+engine: kubernetes
1515+image: golang:1.25-trixie
1616+architecture: amd64
24172518environment:
2626- CGO_ENABLED: "0" # Build static binaries
1919+ CGO_ENABLED: "0"
2020+ REPO_RKEY: "3m2pjukohu322"
27212822steps:
2929- - name: Get tag for current commit
3030- command: |
3131- # Fetch tags (shallow clone doesn't include them by default)
3232- git fetch --tags
3333-3434- # Find the tag that points to the current commit
3535- TAG=$(git tag --points-at HEAD | grep -E '^v[0-9]' | head -n1)
3636-3737- if [ -z "$TAG" ]; then
3838- echo "Error: No version tag found for current commit"
3939- echo "Available tags:"
4040- git tag
4141- echo "Current commit:"
4242- git rev-parse HEAD
4343- exit 1
4444- fi
4545-4646- echo "Building version: $TAG"
4747- echo "$TAG" > .version
4848-4949- # Also get the commit hash for reference
5050- COMMIT_HASH=$(git rev-parse HEAD)
5151- echo "Commit: $COMMIT_HASH"
5252-5353- - name: Build binaries with GoReleaser
2323+ - name: Install tools
5424 command: |
5555- VERSION=$(cat .version)
5656- export VERSION
5757-5858- # Build for all platforms using GoReleaser
5959- goreleaser build --clean --snapshot --config .goreleaser.yaml
6060-6161- # List what was built
6262- echo "Built artifacts:"
6363- if [ -d "dist" ]; then
6464- ls -lh dist/
6565- else
6666- echo "Error: dist/ directory was not created by GoReleaser"
6767- exit 1
6868- fi
6969-7070- - name: Package artifacts
7171- command: |
7272- VERSION=$(cat .version)
7373- VERSION_NO_V=${VERSION#v} # Remove 'v' prefix for filenames
7474-7575- cd dist
7676-7777- # Create tarballs for each platform
7878- # GoReleaser creates directories like: credential-helper_{os}_{arch}_v{goversion}
7979-8080- # Darwin x86_64
8181- if [ -d "credential-helper_darwin_amd64_v1" ]; then
8282- tar czf "docker-credential-atcr_${VERSION_NO_V}_Darwin_x86_64.tar.gz" \
8383- -C credential-helper_darwin_amd64_v1 docker-credential-atcr
8484- echo "Created: docker-credential-atcr_${VERSION_NO_V}_Darwin_x86_64.tar.gz"
8585- fi
8686-8787- # Darwin arm64
8888- for dir in credential-helper_darwin_arm64*; do
8989- if [ -d "$dir" ]; then
9090- tar czf "docker-credential-atcr_${VERSION_NO_V}_Darwin_arm64.tar.gz" \
9191- -C "$dir" docker-credential-atcr
9292- echo "Created: docker-credential-atcr_${VERSION_NO_V}_Darwin_arm64.tar.gz"
9393- break
9494- fi
9595- done
2525+ go install github.com/bluesky-social/goat@latest
2626+ go install github.com/goreleaser/goreleaser/v2@latest
2727+ apt-get update && apt-get install -y jq xxd
96289797- # Linux x86_64
9898- if [ -d "credential-helper_linux_amd64_v1" ]; then
9999- tar czf "docker-credential-atcr_${VERSION_NO_V}_Linux_x86_64.tar.gz" \
100100- -C credential-helper_linux_amd64_v1 docker-credential-atcr
101101- echo "Created: docker-credential-atcr_${VERSION_NO_V}_Linux_x86_64.tar.gz"
102102- fi
103103-104104- # Linux arm64
105105- for dir in credential-helper_linux_arm64*; do
106106- if [ -d "$dir" ]; then
107107- tar czf "docker-credential-atcr_${VERSION_NO_V}_Linux_arm64.tar.gz" \
108108- -C "$dir" docker-credential-atcr
109109- echo "Created: docker-credential-atcr_${VERSION_NO_V}_Linux_arm64.tar.gz"
110110- break
111111- fi
112112- done
113113-114114- echo ""
115115- echo "Tarballs ready:"
116116- ls -lh *.tar.gz 2>/dev/null || echo "Warning: No tarballs created"
117117-118118- - name: Generate checksums
2929+ - name: Build and publish release
11930 command: |
120120- VERSION=$(cat .version)
121121- VERSION_NO_V=${VERSION#v}
122122-123123- cd dist
124124-125125- echo ""
126126- echo "=========================================="
127127- echo "SHA256 Checksums"
128128- echo "=========================================="
129129- echo ""
3131+ git fetch --tags
13032131131- # Generate checksums file
132132- sha256sum docker-credential-atcr_${VERSION_NO_V}_*.tar.gz 2>/dev/null | tee checksums.txt || echo "No checksums generated"
3333+ # REPO_URL is built from Tangled-provided env vars
3434+ export REPO_URL="at://${TANGLED_REPO_DID}/sh.tangled.repo/${REPO_RKEY}"
3535+ export APP_PASSWORD="${PUBLISH_APP_PASSWORD}"
13336134134- - name: Next steps
135135- command: |
136136- VERSION=$(cat .version)
137137-138138- echo ""
139139- echo "=========================================="
140140- echo "Release $VERSION is ready!"
141141- echo "=========================================="
142142- echo ""
143143- echo "Distribution tarballs are in: dist/"
144144- echo ""
145145- echo "Next steps:"
146146- echo ""
147147- echo "1. Upload tarballs to your hosting/CDN (or GitHub releases)"
148148- echo ""
149149- echo "2. For Homebrew users, update the formula:"
150150- echo " ./scripts/update-homebrew-formula.sh $VERSION"
151151- echo " # Then update Formula/docker-credential-atcr.rb and push to homebrew-tap"
152152- echo ""
153153- echo "3. For curl/bash installation, users can download directly:"
154154- echo " curl -L <your-cdn>/docker-credential-atcr_<version>_<os>_<arch>.tar.gz | tar xz"
155155- echo " sudo mv docker-credential-atcr /usr/local/bin/"
3737+ goreleaser release --clean
+18-17
config-appview.example.yaml
···9191 company_name: ""
9292 # Governing law jurisdiction for legal terms.
9393 jurisdiction: ""
9494+# AI-powered image advisor settings.
9595+ai:
9696+ # Anthropic API key for AI Image Advisor. Also reads CLAUDE_API_KEY env var as fallback.
9797+ api_key: ""
9498# Stripe billing integration (requires -tags billing build).
9599billing:
96100 # Stripe secret key. Can also be set via STRIPE_SECRET_KEY env var (takes precedence). Billing is enabled automatically when set.
···100104 # ISO 4217 currency code (e.g. "usd").
101105 currency: usd
102106 # Redirect URL after successful checkout. Use {base_url} placeholder.
103103- success_url: '{base_url}/settings#storage'
107107+ success_url: '{base_url}/settings#billing'
104108 # Redirect URL after cancelled checkout. Use {base_url} placeholder.
105105- cancel_url: '{base_url}/settings#storage'
109109+ cancel_url: '{base_url}/settings#billing'
106110 # Subscription tiers ordered by rank (lowest to highest).
107111 tiers:
108112 - # Tier name. Position in list determines rank (0-based).
···119123 max_webhooks: 1
120124 # Allow all webhook trigger types (not just first-scan).
121125 webhook_all_triggers: false
126126+ # Enable AI Image Advisor for this tier.
127127+ ai_advisor: false
128128+ # Show supporter badge on user profiles for subscribers at this tier.
122129 supporter_badge: false
123130 - # Tier name. Position in list determines rank (0-based).
124131 name: Supporter
···133140 # Maximum webhooks for this tier (-1 = unlimited).
134141 max_webhooks: 1
135142 # Allow all webhook trigger types (not just first-scan).
136136- webhook_all_triggers: false
143143+ webhook_all_triggers: true
144144+ # Enable AI Image Advisor for this tier.
145145+ ai_advisor: true
146146+ # Show supporter badge on user profiles for subscribers at this tier.
137147 supporter_badge: true
138148 - # Tier name. Position in list determines rank (0-based).
139149 name: bosun
···149159 max_webhooks: 10
150160 # Allow all webhook trigger types (not just first-scan).
151161 webhook_all_triggers: true
162162+ # Enable AI Image Advisor for this tier.
163163+ ai_advisor: true
164164+ # Show supporter badge on user profiles for subscribers at this tier.
152165 supporter_badge: true
153153- # - # Tier name. Position in list determines rank (0-based).
154154- # name: quartermaster
155155- # # Short description shown on the plan card.
156156- # description: Maximum storage for power users
157157- # # List of features included in this tier.
158158- # features: []
159159- # # Stripe price ID for monthly billing. Empty = free tier.
160160- # stripe_price_monthly: price_xxx
161161- # # Stripe price ID for yearly billing.
162162- # stripe_price_yearly: price_yyy
163163- # # Maximum webhooks for this tier (-1 = unlimited).
164164- # max_webhooks: -1
165165- # # Allow all webhook trigger types (not just first-scan).
166166- # webhook_all_triggers: true
166166+ # Show supporter badge on hold owner profiles.
167167+ owner_badge: true
+47
lexicons/io/atcr/hold/stats/daily.json
···11+{
22+ "lexicon": 1,
33+ "id": "io.atcr.hold.stats.daily",
44+ "defs": {
55+ "main": {
66+ "type": "record",
77+ "key": "any",
88+ "description": "Daily repository statistics stored in the hold's embedded PDS. Tracks pull/push counts per owner+repository+date combination. Record key is deterministic: base32(sha256(ownerDID + \"/\" + repository + \"/\" + date)[:16]). Complements cumulative io.atcr.hold.stats records by providing daily granularity for trend charts.",
99+ "record": {
1010+ "type": "object",
1111+ "required": ["ownerDid", "repository", "date", "pullCount", "pushCount", "updatedAt"],
1212+ "properties": {
1313+ "ownerDid": {
1414+ "type": "string",
1515+ "format": "did",
1616+ "description": "DID of the image owner (e.g., did:plc:xyz123)"
1717+ },
1818+ "repository": {
1919+ "type": "string",
2020+ "description": "Repository name (e.g., myapp)",
2121+ "maxLength": 256
2222+ },
2323+ "date": {
2424+ "type": "string",
2525+ "description": "Date in YYYY-MM-DD format (UTC)",
2626+ "maxLength": 10
2727+ },
2828+ "pullCount": {
2929+ "type": "integer",
3030+ "minimum": 0,
3131+ "description": "Number of manifest downloads on this date"
3232+ },
3333+ "pushCount": {
3434+ "type": "integer",
3535+ "minimum": 0,
3636+ "description": "Number of manifest uploads on this date"
3737+ },
3838+ "updatedAt": {
3939+ "type": "string",
4040+ "format": "datetime",
4141+ "description": "RFC3339 timestamp of when this record was last updated"
4242+ }
4343+ }
4444+ }
4545+ }
4646+ }
4747+}
+17-2
pkg/appview/config.go
···3232 Auth AuthConfig `yaml:"auth" comment:"JWT authentication settings."`
3333 CredentialHelper CredentialHelperConfig `yaml:"credential_helper" comment:"Credential helper download settings."`
3434 Legal LegalConfig `yaml:"legal" comment:"Legal page customization for self-hosted instances."`
3535+ AI AIConfig `yaml:"ai" comment:"AI-powered image advisor settings."`
3536 Billing billing.Config `yaml:"billing" comment:"Stripe billing integration (requires -tags billing build)."`
3637 Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility
3738}
···140141 Jurisdiction string `yaml:"jurisdiction" comment:"Governing law jurisdiction for legal terms."`
141142}
142143144144+// AIConfig defines AI-powered image advisor settings
145145+type AIConfig struct {
146146+ // Anthropic API key for the AI Image Advisor feature.
147147+ APIKey string `yaml:"api_key" comment:"Anthropic API key for AI Image Advisor. Also reads CLAUDE_API_KEY env var as fallback."`
148148+}
149149+143150// setDefaults registers all default values on the given Viper instance.
144151func setDefaults(v *viper.Viper) {
145152 v.SetDefault("version", "0.1")
···188195 // Log shipper defaults
189196 v.SetDefault("log_shipper.batch_size", 100)
190197 v.SetDefault("log_shipper.flush_interval", "5s")
198198+199199+ // AI defaults
200200+ v.SetDefault("ai.api_key", "")
191201192202 // Legal defaults
193203 v.SetDefault("legal.company_name", "")
···213223214224 // Populate example billing tiers so operators see the structure
215225 cfg.Billing.Currency = "usd"
216216- cfg.Billing.SuccessURL = "{base_url}/settings#storage"
217217- cfg.Billing.CancelURL = "{base_url}/settings#storage"
226226+ cfg.Billing.SuccessURL = "{base_url}/settings#billing"
227227+ cfg.Billing.CancelURL = "{base_url}/settings#billing"
218228 cfg.Billing.OwnerBadge = true
219229 cfg.Billing.Tiers = []billing.BillingTierConfig{
220230 {Name: "deckhand", Description: "Get started with basic storage", MaxWebhooks: 1},
···253263 // Post-load: CompanyName defaults to ClientName
254264 if cfg.Legal.CompanyName == "" {
255265 cfg.Legal.CompanyName = cfg.Server.ClientName
266266+ }
267267+268268+ // Post-load: AI API key fallback to CLAUDE_API_KEY env
269269+ if cfg.AI.APIKey == "" {
270270+ cfg.AI.APIKey = os.Getenv("CLAUDE_API_KEY")
256271 }
257272258273 // Validation
···11+description: Cache AI image advisor suggestions per manifest digest
22+query: |
33+ CREATE TABLE IF NOT EXISTS advisor_suggestions (
44+ manifest_digest TEXT PRIMARY KEY,
55+ suggestions_json TEXT NOT NULL,
66+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
77+ );
···11+description: Add subject_digest column to manifests for tracking OCI referrers (attestations, signatures)
22+query: |
33+ ALTER TABLE manifests ADD COLUMN subject_digest TEXT;
44+ CREATE INDEX IF NOT EXISTS idx_manifests_subject_digest ON manifests(subject_digest);
55+ UPDATE manifests SET subject_digest = 'backfill'
66+ WHERE artifact_type = 'unknown'
77+ AND media_type NOT LIKE '%index%'
88+ AND media_type NOT LIKE '%manifest.list%'
99+ AND id IN (
1010+ SELECT m.id FROM manifests m
1111+ JOIN manifest_references mr ON mr.digest = m.digest
1212+ WHERE mr.is_attestation = 1
1313+ );
···11+description: Add daily repository stats table for pull/push trend tracking
22+query: |
33+ CREATE TABLE IF NOT EXISTS repository_stats_daily (
44+ did TEXT NOT NULL,
55+ repository TEXT NOT NULL,
66+ date TEXT NOT NULL,
77+ pull_count INTEGER NOT NULL DEFAULT 0,
88+ push_count INTEGER NOT NULL DEFAULT 0,
99+ PRIMARY KEY(did, repository, date),
1010+ FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
1111+ );
1212+ CREATE INDEX IF NOT EXISTS idx_repo_stats_daily_date ON repository_stats_daily(date DESC);
+10
pkg/appview/db/models.go
···2525 ConfigDigest string
2626 ConfigSize int64
2727 ArtifactType string // container-image, helm-chart, unknown
2828+ SubjectDigest string // digest of the parent manifest (for attestations/referrers)
2829 CreatedAt time.Time
2930 // Annotations removed - now stored in repository_annotations table
3031}
···9091 LastPull *time.Time `json:"last_pull,omitempty"`
9192 PushCount int `json:"push_count"`
9293 LastPush *time.Time `json:"last_push,omitempty"`
9494+}
9595+9696+// DailyStats represents daily pull/push statistics for a repository
9797+type DailyStats struct {
9898+ DID string `json:"did"`
9999+ Repository string `json:"repository"`
100100+ Date string `json:"date"`
101101+ PullCount int `json:"pull_count"`
102102+ PushCount int `json:"push_count"`
93103}
9410495105// RepositoryWithStats combines repository data with statistics
+141-11
pkg/appview/db/queries.go
···627627 _, err := db.Exec(`
628628 INSERT INTO manifests
629629 (did, repository, digest, hold_endpoint, schema_version, media_type,
630630- config_digest, config_size, artifact_type, created_at)
631631- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
630630+ config_digest, config_size, artifact_type, subject_digest, created_at)
631631+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
632632 ON CONFLICT(did, repository, digest) DO UPDATE SET
633633 hold_endpoint = excluded.hold_endpoint,
634634 schema_version = excluded.schema_version,
635635 media_type = excluded.media_type,
636636 config_digest = excluded.config_digest,
637637 config_size = excluded.config_size,
638638- artifact_type = excluded.artifact_type
638638+ artifact_type = excluded.artifact_type,
639639+ subject_digest = excluded.subject_digest
639640 WHERE excluded.hold_endpoint != manifests.hold_endpoint
640641 OR excluded.schema_version != manifests.schema_version
641642 OR excluded.media_type != manifests.media_type
642643 OR excluded.config_digest IS NOT manifests.config_digest
643644 OR excluded.config_size IS NOT manifests.config_size
644645 OR excluded.artifact_type != manifests.artifact_type
646646+ OR excluded.subject_digest IS NOT manifests.subject_digest
645647 `, manifest.DID, manifest.Repository, manifest.Digest, manifest.HoldEndpoint,
646648 manifest.SchemaVersion, manifest.MediaType, manifest.ConfigDigest,
647647- manifest.ConfigSize, manifest.ArtifactType, manifest.CreatedAt)
649649+ manifest.ConfigSize, manifest.ArtifactType,
650650+ sql.NullString{String: manifest.SubjectDigest, Valid: manifest.SubjectDigest != ""},
651651+ manifest.CreatedAt)
648652649653 if err != nil {
650654 return 0, err
···776780// Single-arch tags will have empty Platforms slice (platform is obvious for single-arch)
777781// Attestation references (unknown/unknown platforms) are filtered out but tracked via HasAttestations
778782func GetTagsWithPlatforms(db DBTX, did, repository string, limit, offset int) ([]TagWithPlatforms, error) {
779779- rows, err := db.Query(`
783783+ return getTagsWithPlatformsFiltered(db, did, repository, "", limit, offset)
784784+}
785785+786786+// getTagsWithPlatformsFiltered is the shared implementation for GetTagsWithPlatforms and GetTagByName.
787787+// If tagName is non-empty, only that specific tag is returned.
788788+func getTagsWithPlatformsFiltered(db DBTX, did, repository, tagName string, limit, offset int) ([]TagWithPlatforms, error) {
789789+ var tagFilter string
790790+ var args []any
791791+ if tagName != "" {
792792+ tagFilter = "AND tag = ?"
793793+ args = append(args, did, repository, tagName, limit, offset)
794794+ } else {
795795+ args = append(args, did, repository, limit, offset)
796796+ }
797797+798798+ query := `
780799 WITH paged_tags AS (
781800 SELECT id, did, repository, tag, digest, created_at
782801 FROM tags
783802 WHERE did = ? AND repository = ?
803803+ ` + tagFilter + `
784804 ORDER BY created_at DESC
785805 LIMIT ? OFFSET ?
786806 )
···806826 JOIN manifests m ON t.digest = m.digest AND t.did = m.did AND t.repository = m.repository
807827 LEFT JOIN manifest_references mr ON m.id = mr.manifest_id
808828 LEFT JOIN manifests child_m ON mr.digest = child_m.digest AND child_m.did = t.did AND child_m.repository = t.repository
809809- ORDER BY t.created_at DESC, mr.reference_index
810810- `, did, repository, limit, offset)
829829+ ORDER BY t.created_at DESC, mr.reference_index`
811830831831+ rows, err := db.Query(query, args...)
812832 if err != nil {
813833 return nil, err
814834 }
···878898 return result, nil
879899}
880900881881-// DeleteManifest deletes a manifest and its associated layers
882882-// If repository is empty, deletes all manifests matching did and digest
901901+// DeleteManifest deletes a manifest and its associated layers.
902902+// Also deletes any attestation manifests that reference this manifest via subject_digest.
903903+// If repository is empty, deletes all manifests matching did and digest.
883904func DeleteManifest(db DBTX, did, repository, digest string) error {
884905 var err error
885906 if repository == "" {
886886- // Delete by DID + digest only (used when repository is unknown, e.g., Jetstream DELETE events)
907907+ // Delete attestation children first, then the manifest itself
908908+ _, _ = db.Exec(`DELETE FROM manifests WHERE did = ? AND subject_digest = ?`, did, digest)
887909 _, err = db.Exec(`DELETE FROM manifests WHERE did = ? AND digest = ?`, did, digest)
888910 } else {
889889- // Delete specific manifest
911911+ _, _ = db.Exec(`DELETE FROM manifests WHERE did = ? AND repository = ? AND subject_digest = ?`, did, repository, digest)
890912 _, err = db.Exec(`DELETE FROM manifests WHERE did = ? AND repository = ? AND digest = ?`, did, repository, digest)
891913 }
892914 return err
···11141136 LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository
11151137 LEFT JOIN manifest_references mr ON m.id = mr.manifest_id
11161138 WHERE m.did = ? AND m.repository = ?
11391139+ AND m.subject_digest IS NULL
11401140+ AND m.artifact_type != 'unknown'
11171141 AND (
11181142 -- Include manifest lists
11191143 m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%'
···14141438 FROM manifests m
14151439 LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository
14161440 WHERE m.did = ? AND m.repository = ?
14411441+ AND m.subject_digest IS NULL
14171442 AND (
14181443 m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%'
14191444 OR
···24352460 }
24362461 return true, nil
24372462}
24632463+24642464+// GetTagByName returns a single tag with platform information by tag name.
24652465+// Returns nil, nil if the tag doesn't exist.
24662466+func GetTagByName(db DBTX, did, repository, tagName string) (*TagWithPlatforms, error) {
24672467+ tags, err := getTagsWithPlatformsFiltered(db, did, repository, tagName, 1, 0)
24682468+ if err != nil {
24692469+ return nil, err
24702470+ }
24712471+ if len(tags) == 0 {
24722472+ return nil, nil
24732473+ }
24742474+ return &tags[0], nil
24752475+}
24762476+24772477+// GetAllTagNames returns all tag names for a repository, ordered by most recent first.
24782478+func GetAllTagNames(db DBTX, did, repository string) ([]string, error) {
24792479+ rows, err := db.Query(`
24802480+ SELECT tag FROM tags
24812481+ WHERE did = ? AND repository = ?
24822482+ ORDER BY created_at DESC
24832483+ `, did, repository)
24842484+ if err != nil {
24852485+ return nil, err
24862486+ }
24872487+ defer rows.Close()
24882488+24892489+ var names []string
24902490+ for rows.Next() {
24912491+ var name string
24922492+ if err := rows.Scan(&name); err != nil {
24932493+ return nil, err
24942494+ }
24952495+ names = append(names, name)
24962496+ }
24972497+ return names, rows.Err()
24982498+}
24992499+25002500+// GetLayerCountForManifest returns the number of layers for a manifest identified by digest.
25012501+func GetLayerCountForManifest(db DBTX, did, repository, digest string) (int, error) {
25022502+ var count int
25032503+ err := db.QueryRow(`
25042504+ SELECT COUNT(*) FROM layers l
25052505+ JOIN manifests m ON l.manifest_id = m.id
25062506+ WHERE m.did = ? AND m.repository = ? AND m.digest = ?
25072507+ `, did, repository, digest).Scan(&count)
25082508+ return count, err
25092509+}
25102510+25112511+// GetAdvisorSuggestions returns cached AI advisor suggestions for a manifest digest.
25122512+// Returns sql.ErrNoRows if no cached suggestions exist.
25132513+func GetAdvisorSuggestions(db DBTX, manifestDigest string) (suggestionsJSON string, createdAt time.Time, err error) {
25142514+ err = db.QueryRow(
25152515+ `SELECT suggestions_json, created_at FROM advisor_suggestions WHERE manifest_digest = ?`,
25162516+ manifestDigest,
25172517+ ).Scan(&suggestionsJSON, &createdAt)
25182518+ return
25192519+}
25202520+25212521+// UpsertAdvisorSuggestions caches AI advisor suggestions for a manifest digest.
25222522+func UpsertAdvisorSuggestions(db DBTX, manifestDigest, suggestionsJSON string) error {
25232523+ _, err := db.Exec(
25242524+ `INSERT OR REPLACE INTO advisor_suggestions (manifest_digest, suggestions_json, created_at) VALUES (?, ?, CURRENT_TIMESTAMP)`,
25252525+ manifestDigest, suggestionsJSON,
25262526+ )
25272527+ return err
25282528+}
25292529+25302530+// UpsertDailyStats inserts or updates daily repository stats
25312531+func UpsertDailyStats(db DBTX, stats *DailyStats) error {
25322532+ _, err := db.Exec(`
25332533+ INSERT INTO repository_stats_daily (did, repository, date, pull_count, push_count)
25342534+ VALUES (?, ?, ?, ?, ?)
25352535+ ON CONFLICT(did, repository, date) DO UPDATE SET
25362536+ pull_count = excluded.pull_count,
25372537+ push_count = excluded.push_count
25382538+ WHERE excluded.pull_count != repository_stats_daily.pull_count
25392539+ OR excluded.push_count != repository_stats_daily.push_count
25402540+ `, stats.DID, stats.Repository, stats.Date, stats.PullCount, stats.PushCount)
25412541+ return err
25422542+}
25432543+25442544+// GetDailyStats retrieves daily stats for a repository within a date range
25452545+// startDate and endDate should be in YYYY-MM-DD format
25462546+func GetDailyStats(db DBTX, did, repository, startDate, endDate string) ([]DailyStats, error) {
25472547+ rows, err := db.Query(`
25482548+ SELECT did, repository, date, pull_count, push_count
25492549+ FROM repository_stats_daily
25502550+ WHERE did = ? AND repository = ? AND date >= ? AND date <= ?
25512551+ ORDER BY date ASC
25522552+ `, did, repository, startDate, endDate)
25532553+ if err != nil {
25542554+ return nil, err
25552555+ }
25562556+ defer rows.Close()
25572557+25582558+ var stats []DailyStats
25592559+ for rows.Next() {
25602560+ var s DailyStats
25612561+ if err := rows.Scan(&s.DID, &s.Repository, &s.Date, &s.PullCount, &s.PushCount); err != nil {
25622562+ return nil, err
25632563+ }
25642564+ stats = append(stats, s)
25652565+ }
25662566+ return stats, rows.Err()
25672567+}
+19
pkg/appview/db/schema.sql
···3030 config_digest TEXT,
3131 config_size INTEGER,
3232 artifact_type TEXT NOT NULL DEFAULT 'container-image', -- container-image, helm-chart, unknown
3333+ subject_digest TEXT, -- digest of the parent manifest (for attestations/referrers)
3334 created_at TIMESTAMP NOT NULL,
3435 UNIQUE(did, repository, digest),
3536 FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
···3839CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC);
3940CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest);
4041CREATE INDEX IF NOT EXISTS idx_manifests_artifact_type ON manifests(artifact_type);
4242+CREATE INDEX IF NOT EXISTS idx_manifests_subject_digest ON manifests(subject_digest);
41434244CREATE TABLE IF NOT EXISTS repository_annotations (
4345 did TEXT NOT NULL,
···167169CREATE INDEX IF NOT EXISTS idx_repository_stats_did ON repository_stats(did);
168170CREATE INDEX IF NOT EXISTS idx_repository_stats_pull_count ON repository_stats(pull_count DESC);
169171172172+CREATE TABLE IF NOT EXISTS repository_stats_daily (
173173+ did TEXT NOT NULL,
174174+ repository TEXT NOT NULL,
175175+ date TEXT NOT NULL,
176176+ pull_count INTEGER NOT NULL DEFAULT 0,
177177+ push_count INTEGER NOT NULL DEFAULT 0,
178178+ PRIMARY KEY(did, repository, date),
179179+ FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
180180+);
181181+CREATE INDEX IF NOT EXISTS idx_repo_stats_daily_date ON repository_stats_daily(date DESC);
182182+170183CREATE TABLE IF NOT EXISTS stars (
171184 starrer_did TEXT NOT NULL,
172185 owner_did TEXT NOT NULL,
···273286 PRIMARY KEY(hold_did, manifest_digest)
274287);
275288CREATE INDEX IF NOT EXISTS idx_scans_user ON scans(user_did);
289289+290290+CREATE TABLE IF NOT EXISTS advisor_suggestions (
291291+ manifest_digest TEXT PRIMARY KEY,
292292+ suggestions_json TEXT NOT NULL,
293293+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
294294+);
+6-5
pkg/appview/handlers/base.go
···4040 OAuthStore *db.OAuthStore
41414242 // Config
4343- DefaultHoldDID string
4444- CompanyName string
4545- Jurisdiction string
4646- ClientName string // Full name: "AT Container Registry"
4747- ClientShortName string // Short name: "ATCR"
4343+ DefaultHoldDID string
4444+ CompanyName string
4545+ Jurisdiction string
4646+ ClientName string // Full name: "AT Container Registry"
4747+ ClientShortName string // Short name: "ATCR"
4848+ AIAdvisorEnabled bool // True when Claude API key is configured
4849}
+16-14
pkg/appview/handlers/common.go
···10101111// PageData contains common fields shared across all page templates
1212type PageData struct {
1313- User *db.User // Logged-in user (nil if not logged in)
1414- Query string // Search query from URL parameter
1515- RegistryURL string // Docker registry domain (e.g., "buoy.cr")
1616- SiteURL string // Website domain (e.g., "seamark.dev")
1717- ClientName string // Brand name for templates (e.g., "AT Container Registry")
1818- ClientShortName string // Brand name for templates (e.g., "ATCR")
1919- OciClient string // Preferred OCI client for pull commands (e.g., "docker", "podman")
1313+ User *db.User // Logged-in user (nil if not logged in)
1414+ Query string // Search query from URL parameter
1515+ RegistryURL string // Docker registry domain (e.g., "buoy.cr")
1616+ SiteURL string // Website domain (e.g., "seamark.dev")
1717+ ClientName string // Brand name for templates (e.g., "AT Container Registry")
1818+ ClientShortName string // Brand name for templates (e.g., "ATCR")
1919+ OciClient string // Preferred OCI client for pull commands (e.g., "docker", "podman")
2020+ AIAdvisorEnabled bool // True when AI Image Advisor is available
2021}
21222223// NewPageData creates a PageData struct with common fields populated from the request
···2728 ociClient = user.OciClient
2829 }
2930 return PageData{
3030- User: user,
3131- Query: r.URL.Query().Get("q"),
3232- RegistryURL: h.RegistryURL,
3333- SiteURL: h.SiteURL,
3434- ClientName: h.ClientName,
3535- ClientShortName: h.ClientShortName,
3636- OciClient: ociClient,
3131+ User: user,
3232+ Query: r.URL.Query().Get("q"),
3333+ RegistryURL: h.RegistryURL,
3434+ SiteURL: h.SiteURL,
3535+ ClientName: h.ClientName,
3636+ ClientShortName: h.ClientShortName,
3737+ OciClient: ociClient,
3838+ AIAdvisorEnabled: h.AIAdvisorEnabled,
3739 }
3840}
3941
···4545 digest string
4646 }{
4747 {"shared", "sha256:base"},
4848- {"removed", "sha256:old"}, // no command, different digest → -/+
4848+ {"removed", "sha256:old"}, // no command, different digest → -/+
4949 {"added", "sha256:new1"},
5050- {"added", "sha256:new2"}, // extra layer in to
5050+ {"added", "sha256:new2"}, // extra layer in to
5151 }
52525353 for i, e := range expected {
···129129 w.WriteHeader(http.StatusNoContent)
130130 return
131131 }
132132- } else if currentManifest.IsManifestList || newerManifest.IsManifestList {
133133- // One is multi-arch, the other isn't — can't compare meaningfully
134134- // Still show a basic banner without layer/vuln details
135132 }
133133+ // If one is multi-arch and the other isn't, we can't match platforms —
134134+ // fall through and show a basic banner without layer/vuln details.
136135137136 // Fetch layers for both
138137 currentDBLayers, _ := db.GetLayersForManifest(h.ReadOnlyDB, currentManifestForLayers.ID)
···4444 // Stored in hold's embedded PDS to track pull/push counts per owner+repo
4545 StatsCollection = "io.atcr.hold.stats"
46464747+ // DailyStatsCollection is the collection name for daily repository statistics
4848+ // Stored in hold's embedded PDS to track daily pull/push counts per owner+repo+date
4949+ DailyStatsCollection = "io.atcr.hold.stats.daily"
5050+4751 // ScanCollection is the collection name for vulnerability scan results
4852 // Stored in hold's embedded PDS to track scan results per manifest
4953 ScanCollection = "io.atcr.hold.scan"
···352356 // OciClient is the preferred OCI client for pull commands (docker, podman, buildah, nerdctl, crane).
353357 // Defaults to "docker" if empty.
354358 OciClient string `json:"ociClient,omitempty"`
359359+360360+ // AIAdvisorEnabled controls whether the AI Image Advisor feature is active for this user.
361361+ // nil = default (enabled if user has billing access), false = explicitly disabled.
362362+ AIAdvisorEnabled *bool `json:"aiAdvisorEnabled,omitempty"`
355363356364 // CreatedAt timestamp
357365 CreatedAt time.Time `json:"createdAt"`
···772780 hash := sha256.Sum256([]byte(combined))
773781 // Use first 16 bytes (128 bits) for collision resistance
774782 // Encode with base32 (alphanumeric, lowercase, no padding) for ATProto rkey compatibility
783783+ return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(hash[:16]))
784784+}
785785+786786+// DailyStatsRecord represents daily repository statistics stored in the hold's PDS
787787+// Collection: io.atcr.hold.stats.daily
788788+// Stored in the hold's embedded PDS for tracking daily pull/push counts
789789+// Uses CBOR encoding for efficient storage in hold's carstore
790790+// RKey is deterministic: base32(sha256(ownerDID + "/" + repository + "/" + date)[:16])
791791+type DailyStatsRecord struct {
792792+ Type string `json:"$type" cborgen:"$type"`
793793+ OwnerDID string `json:"ownerDid" cborgen:"ownerDid"` // DID of the image owner
794794+ Repository string `json:"repository" cborgen:"repository"` // Repository name
795795+ Date string `json:"date" cborgen:"date"` // YYYY-MM-DD format
796796+ PullCount int64 `json:"pullCount" cborgen:"pullCount"` // Number of manifest downloads on this date
797797+ PushCount int64 `json:"pushCount" cborgen:"pushCount"` // Number of manifest uploads on this date
798798+ UpdatedAt string `json:"updatedAt" cborgen:"updatedAt"` // RFC3339 timestamp
799799+}
800800+801801+// NewDailyStatsRecord creates a new daily stats record
802802+func NewDailyStatsRecord(ownerDID, repository, date string) *DailyStatsRecord {
803803+ return &DailyStatsRecord{
804804+ Type: DailyStatsCollection,
805805+ OwnerDID: ownerDID,
806806+ Repository: repository,
807807+ Date: date,
808808+ PullCount: 0,
809809+ PushCount: 0,
810810+ UpdatedAt: time.Now().Format(time.RFC3339),
811811+ }
812812+}
813813+814814+// DailyStatsRecordKey generates a deterministic record key for daily stats
815815+// Uses base32 encoding of first 16 bytes of SHA-256 hash of "ownerDID/repository/date"
816816+func DailyStatsRecordKey(ownerDID, repository, date string) string {
817817+ combined := ownerDID + "/" + repository + "/" + date
818818+ hash := sha256.Sum256([]byte(combined))
775819 return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(hash[:16]))
776820}
777821
+32
pkg/billing/billing.go
···139139 return m.cfg.Tiers[0].MaxWebhooks, m.cfg.Tiers[0].WebhookAllTriggers
140140}
141141142142+// HasAIAdvisor returns whether a user has access to the AI Image Advisor based on their subscription tier.
143143+// Hold captains always have access.
144144+func (m *Manager) HasAIAdvisor(userDID string) bool {
145145+ if m.isCaptain(userDID) {
146146+ return true
147147+ }
148148+ if !m.Enabled() {
149149+ return false
150150+ }
151151+152152+ info, err := m.GetSubscriptionInfo(userDID)
153153+ if err != nil || info == nil {
154154+ return m.cfg.Tiers[0].AIAdvisor
155155+ }
156156+157157+ rank := info.TierRank
158158+ if rank >= 0 && rank < len(m.cfg.Tiers) {
159159+ return m.cfg.Tiers[rank].AIAdvisor
160160+ }
161161+162162+ return m.cfg.Tiers[0].AIAdvisor
163163+}
164164+142165// GetSupporterBadge returns the supporter badge tier name for a user based on their subscription.
143166// Returns the tier name if the user's current tier has supporter badges enabled, empty string otherwise.
144167// Hold captains get a "Captain" badge.
···203226 // Dynamic features: hold-derived first, then webhook limits, then static config
204227 features := m.aggregateHoldFeatures(i)
205228 features = append(features, webhookFeatures(tier.MaxWebhooks, tier.WebhookAllTriggers)...)
229229+ features = append(features, aiAdvisorFeatures(tier.AIAdvisor)...)
206230 if tier.SupporterBadge {
207231 features = append(features, "Supporter badge")
208232 }
···687711 features = append(features, "All webhook triggers")
688712 }
689713 return features
714714+}
715715+716716+// aiAdvisorFeatures generates feature bullet strings for AI advisor access.
717717+func aiAdvisorFeatures(enabled bool) []string {
718718+ if enabled {
719719+ return []string{"AI Image Advisor"}
720720+ }
721721+ return nil
690722}
691723692724// formatBytes formats bytes as a human-readable string (e.g. "5.0 GB").
+9
pkg/billing/billing_stub.go
···3636 return 1, false
3737}
38383939+// HasAIAdvisor returns whether a user has access to the AI Image Advisor.
4040+// Hold captains always have access. Default is false when billing is not compiled in.
4141+func (m *Manager) HasAIAdvisor(userDID string) bool {
4242+ if m.captainChecker != nil && userDID != "" && m.captainChecker(userDID) {
4343+ return true
4444+ }
4545+ return false
4646+}
4747+3948// GetSubscriptionInfo returns an error when billing is not compiled in.
4049func (m *Manager) GetSubscriptionInfo(_ string) (*SubscriptionInfo, error) {
4150 return nil, ErrBillingDisabled
+3
pkg/billing/config.go
···5151 // Whether all webhook trigger types are available (not just first-scan).
5252 WebhookAllTriggers bool `yaml:"webhook_all_triggers" comment:"Allow all webhook trigger types (not just first-scan)."`
53535454+ // Whether AI Image Advisor is available for this tier.
5555+ AIAdvisor bool `yaml:"ai_advisor" comment:"Enable AI Image Advisor for this tier."`
5656+5457 // Whether this tier earns a supporter badge on user profiles.
5558 SupporterBadge bool `yaml:"supporter_badge" comment:"Show supporter badge on user profiles for subscribers at this tier."`
5659}