diff --git a/lexicons/io/atcr/hold/getLayersForManifest.json b/lexicons/io/atcr/hold/getLayersForManifest.json
new file mode 100644
index 0000000..939726d
--- /dev/null
+++ b/lexicons/io/atcr/hold/getLayersForManifest.json
@@ -0,0 +1,37 @@
+{
+ "lexicon": 1,
+ "id": "io.atcr.hold.getLayersForManifest",
+ "defs": {
+ "main": {
+ "type": "query",
+ "description": "Returns layer records for a specific manifest AT-URI.",
+ "parameters": {
+ "type": "params",
+ "required": ["manifest"],
+ "properties": {
+ "manifest": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "AT-URI of the manifest to get layers for"
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["layers"],
+ "properties": {
+ "layers": {
+ "type": "array",
+ "items": {
+ "type": "ref",
+ "ref": "io.atcr.hold.layer"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/io/atcr/hold/image/config.json b/lexicons/io/atcr/hold/image/config.json
new file mode 100644
index 0000000..bfaf8e7
--- /dev/null
+++ b/lexicons/io/atcr/hold/image/config.json
@@ -0,0 +1,32 @@
+{
+ "lexicon": 1,
+ "id": "io.atcr.hold.image.config",
+ "defs": {
+ "main": {
+ "type": "record",
+ "key": "any",
+ "description": "OCI image configuration for a container manifest. Stored in the hold's embedded PDS. Record key is the manifest digest hex without the 'sha256:' prefix (deterministic, one per manifest). Contains the full OCI config JSON including history (Dockerfile commands), environment variables, entrypoint, labels, etc.",
+ "record": {
+ "type": "object",
+ "required": ["manifest", "configJson", "createdAt"],
+ "properties": {
+ "manifest": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "AT-URI of the manifest this config belongs to"
+ },
+ "configJson": {
+ "type": "string",
+ "description": "Raw OCI image config JSON blob",
+ "maxLength": 65536
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "RFC3339 timestamp of when the config was stored"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/io/atcr/hold/image/getConfig.json b/lexicons/io/atcr/hold/image/getConfig.json
new file mode 100644
index 0000000..d6dde42
--- /dev/null
+++ b/lexicons/io/atcr/hold/image/getConfig.json
@@ -0,0 +1,28 @@
+{
+ "lexicon": 1,
+ "id": "io.atcr.hold.image.getConfig",
+ "defs": {
+ "main": {
+ "type": "query",
+ "description": "Returns the OCI image config record for a specific manifest digest.",
+ "parameters": {
+ "type": "params",
+ "required": ["digest"],
+ "properties": {
+ "digest": {
+ "type": "string",
+ "description": "Manifest digest (e.g., sha256:abc123...)",
+ "maxLength": 128
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "ref",
+ "ref": "io.atcr.hold.image.config"
+ }
+ }
+ }
+ }
+}
diff --git a/pkg/appview/handlers/digest.go b/pkg/appview/handlers/digest.go
new file mode 100644
index 0000000..8851c34
--- /dev/null
+++ b/pkg/appview/handlers/digest.go
@@ -0,0 +1,178 @@
+package handlers
+
+import (
+ "log/slog"
+ "net/http"
+ "strings"
+
+ "atcr.io/pkg/appview/db"
+ "atcr.io/pkg/appview/holdclient"
+ "atcr.io/pkg/atproto"
+ "github.com/go-chi/chi/v5"
+)
+
+// LayerDetail combines OCI config history with layer metadata from the DB.
+type LayerDetail struct {
+ Index int
+ Command string // Dockerfile command (from config history)
+ Digest string
+ Size int64
+ MediaType string
+ EmptyLayer bool // ENV, LABEL, etc. — no actual layer blob
+}
+
+// DigestDetailHandler renders the digest detail page with layers + vulnerabilities.
+type DigestDetailHandler struct {
+ BaseUIHandler
+}
+
+func (h *DigestDetailHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ identifier := chi.URLParam(r, "handle")
+ // The route is /d/{handle}/*/* — first * is repo, second * is digest
+ // chi captures both wildcards, so we need to split the path
+ pathParts := strings.SplitN(strings.TrimPrefix(chi.URLParam(r, "*"), "/"), "/", 2)
+ if len(pathParts) < 2 {
+ RenderNotFound(w, r, &h.BaseUIHandler)
+ return
+ }
+ repository := pathParts[0]
+ digest := pathParts[1]
+
+ // Resolve identity
+ did, resolvedHandle, _, err := atproto.ResolveIdentity(r.Context(), identifier)
+ if err != nil {
+ RenderNotFound(w, r, &h.BaseUIHandler)
+ return
+ }
+
+ owner, err := db.GetUserByDID(h.ReadOnlyDB, did)
+ if err != nil || owner == nil {
+ RenderNotFound(w, r, &h.BaseUIHandler)
+ return
+ }
+ if owner.Handle != resolvedHandle {
+ _ = db.UpdateUserHandle(h.ReadOnlyDB, did, resolvedHandle)
+ owner.Handle = resolvedHandle
+ }
+
+ // Fetch manifest details
+ manifest, err := db.GetManifestDetail(h.ReadOnlyDB, owner.DID, repository, digest)
+ if err != nil {
+ RenderNotFound(w, r, &h.BaseUIHandler)
+ return
+ }
+
+ // Build layer details
+ var layers []LayerDetail
+ var vulnData *vulnDetailsData
+
+ if manifest.IsManifestList {
+ // Manifest list: no layers, show platform picker
+ // Platforms are already populated by GetManifestDetail
+ } else {
+ // Single manifest: fetch layers from DB
+ dbLayers, err := db.GetLayersForManifest(h.ReadOnlyDB, manifest.ID)
+ if err != nil {
+ slog.Warn("Failed to fetch layers", "error", err)
+ }
+
+ // Resolve hold endpoint (follow successor if migrated)
+ hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, manifest.HoldEndpoint)
+
+ // Fetch OCI image config from hold for layer history (including empty layers)
+ if holdErr == nil {
+ config, err := holdclient.FetchImageConfig(r.Context(), hold.URL, digest)
+ if err == nil {
+ layers = buildLayerDetails(config.History, dbLayers)
+ } else {
+ slog.Warn("Failed to fetch image config", "error", err,
+ "holdEndpoint", manifest.HoldEndpoint, "manifestDigest", digest)
+ layers = buildLayerDetails(nil, dbLayers)
+ }
+ } else {
+ layers = buildLayerDetails(nil, dbLayers)
+ }
+
+ // Fetch vulnerability details
+ if holdErr == nil {
+ vd := FetchVulnDetails(r.Context(), hold.DID, digest)
+ vulnData = &vd
+ }
+ }
+
+ // Build page meta
+ title := truncateDigestStr(digest, 16) + " - " + owner.Handle + "/" + repository + " - " + h.ClientShortName
+ description := "Image digest " + digest + " in " + owner.Handle + "/" + repository
+
+ meta := NewPageMeta(title, description).
+ WithCanonical("https://" + h.SiteURL + "/d/" + owner.Handle + "/" + repository + "/" + digest).
+ WithSiteName(h.ClientShortName)
+
+ data := struct {
+ PageData
+ Meta *PageMeta
+ Owner *db.User
+ Repository string
+ Manifest *db.ManifestWithMetadata
+ Layers []LayerDetail
+ VulnData *vulnDetailsData
+ }{
+ PageData: NewPageData(r, &h.BaseUIHandler),
+ Meta: meta,
+ Owner: owner,
+ Repository: repository,
+ Manifest: manifest,
+ Layers: layers,
+ VulnData: vulnData,
+ }
+
+ if err := h.Templates.ExecuteTemplate(w, "digest", data); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+}
+
+// buildLayerDetails correlates OCI config history entries with layer metadata.
+// History entries with empty_layer=true have no corresponding layer blob.
+func buildLayerDetails(history []holdclient.OCIHistoryEntry, dbLayers []db.Layer) []LayerDetail {
+ var details []LayerDetail
+ layerIdx := 0
+
+ if len(history) == 0 {
+ // No config history available — just list layers from DB
+ for _, l := range dbLayers {
+ details = append(details, LayerDetail{
+ Index: l.LayerIndex + 1,
+ Digest: l.Digest,
+ Size: l.Size,
+ MediaType: l.MediaType,
+ })
+ }
+ return details
+ }
+
+ for i, h := range history {
+ ld := LayerDetail{
+ Index: i + 1,
+ Command: h.CreatedBy,
+ EmptyLayer: h.EmptyLayer,
+ }
+
+ if !h.EmptyLayer && layerIdx < len(dbLayers) {
+ ld.Digest = dbLayers[layerIdx].Digest
+ ld.Size = dbLayers[layerIdx].Size
+ ld.MediaType = dbLayers[layerIdx].MediaType
+ layerIdx++
+ }
+
+ details = append(details, ld)
+ }
+
+ return details
+}
+
+func truncateDigestStr(s string, length int) string {
+ if len(s) <= length {
+ return s
+ }
+ return s[:length] + "..."
+}
diff --git a/pkg/appview/handlers/digest_test.go b/pkg/appview/handlers/digest_test.go
new file mode 100644
index 0000000..f9146e0
--- /dev/null
+++ b/pkg/appview/handlers/digest_test.go
@@ -0,0 +1,143 @@
+package handlers
+
+import (
+ "testing"
+
+ "atcr.io/pkg/appview/db"
+ "atcr.io/pkg/appview/holdclient"
+)
+
+func TestBuildLayerDetails_NoHistory(t *testing.T) {
+ dbLayers := []db.Layer{
+ {Digest: "sha256:aaa", Size: 1024, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 0},
+ {Digest: "sha256:bbb", Size: 2048, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 1},
+ }
+
+ details := buildLayerDetails(nil, dbLayers)
+
+ if len(details) != 2 {
+ t.Fatalf("Expected 2 layer details, got %d", len(details))
+ }
+ if details[0].Command != "" {
+ t.Errorf("Expected empty command, got %q", details[0].Command)
+ }
+ if details[0].Digest != "sha256:aaa" {
+ t.Errorf("Expected digest sha256:aaa, got %q", details[0].Digest)
+ }
+ if details[0].Index != 1 {
+ t.Errorf("Expected Index 1, got %d", details[0].Index)
+ }
+}
+
+func TestBuildLayerDetails_WithHistory(t *testing.T) {
+ history := []holdclient.OCIHistoryEntry{
+ {CreatedBy: "ENV PATH=/usr/local/bin", EmptyLayer: true},
+ {CreatedBy: "RUN apt-get install curl", EmptyLayer: false},
+ {CreatedBy: "COPY . /app", EmptyLayer: false},
+ }
+
+ dbLayers := []db.Layer{
+ {Digest: "sha256:aaa", Size: 1024, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 0},
+ {Digest: "sha256:bbb", Size: 2048, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 1},
+ }
+
+ details := buildLayerDetails(history, dbLayers)
+
+ if len(details) != 3 {
+ t.Fatalf("Expected 3 details (1 empty + 2 real), got %d", len(details))
+ }
+
+ // First entry is empty layer (ENV)
+ if !details[0].EmptyLayer {
+ t.Error("Expected first detail to be empty layer")
+ }
+ if details[0].Command != "ENV PATH=/usr/local/bin" {
+ t.Errorf("Expected ENV command, got %q", details[0].Command)
+ }
+ if details[0].Digest != "" {
+ t.Errorf("Expected empty digest for empty layer, got %q", details[0].Digest)
+ }
+
+ // Second entry is real layer with digest
+ if details[1].EmptyLayer {
+ t.Error("Expected second detail to not be empty layer")
+ }
+ if details[1].Digest != "sha256:aaa" {
+ t.Errorf("Expected digest sha256:aaa, got %q", details[1].Digest)
+ }
+ if details[1].Command != "RUN apt-get install curl" {
+ t.Errorf("Expected RUN command, got %q", details[1].Command)
+ }
+
+ // Third entry is real layer
+ if details[2].Digest != "sha256:bbb" {
+ t.Errorf("Expected digest sha256:bbb, got %q", details[2].Digest)
+ }
+ if details[2].Command != "COPY . /app" {
+ t.Errorf("Expected COPY command, got %q", details[2].Command)
+ }
+}
+
+func TestBuildLayerDetails_DistrolessWithUserLayers(t *testing.T) {
+ // Simulates distroless base (many empty layers) + user COPY layers
+ history := []holdclient.OCIHistoryEntry{
+ {CreatedBy: "bazel build ...", EmptyLayer: false}, // distroless base layer
+ {CreatedBy: "LABEL maintainer=distroless", EmptyLayer: true}, // metadata
+ {CreatedBy: "ENV SSL_CERT_DIR=/etc/ssl/certs", EmptyLayer: true},
+ {CreatedBy: "COPY /app /app # buildkit", EmptyLayer: false}, // user layer
+ }
+
+ dbLayers := []db.Layer{
+ {Digest: "sha256:base", Size: 5000000, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 0},
+ {Digest: "sha256:user", Size: 1024, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 1},
+ }
+
+ details := buildLayerDetails(history, dbLayers)
+
+ if len(details) != 4 {
+ t.Fatalf("Expected 4 details (2 real + 2 empty), got %d", len(details))
+ }
+
+ // Real layer
+ if details[0].EmptyLayer || details[0].Digest != "sha256:base" {
+ t.Errorf("Layer 0: expected real layer with sha256:base, got empty=%v digest=%q", details[0].EmptyLayer, details[0].Digest)
+ }
+
+ // Empty layers
+ if !details[1].EmptyLayer || details[1].Command != "LABEL maintainer=distroless" {
+ t.Errorf("Layer 1: expected empty LABEL layer")
+ }
+ if !details[2].EmptyLayer || details[2].Command != "ENV SSL_CERT_DIR=/etc/ssl/certs" {
+ t.Errorf("Layer 2: expected empty ENV layer")
+ }
+
+ // User layer
+ if details[3].EmptyLayer || details[3].Digest != "sha256:user" {
+ t.Errorf("Layer 3: expected real layer with sha256:user, got empty=%v digest=%q", details[3].EmptyLayer, details[3].Digest)
+ }
+ if details[3].Command != "COPY /app /app # buildkit" {
+ t.Errorf("Layer 3: Command = %q, want COPY command", details[3].Command)
+ }
+}
+
+func TestBuildLayerDetails_AllEmptyLayers(t *testing.T) {
+ // Edge case: all history entries are empty layers (scratch-based with only metadata)
+ history := []holdclient.OCIHistoryEntry{
+ {CreatedBy: "ENV FOO=bar", EmptyLayer: true},
+ {CreatedBy: "LABEL version=1.0", EmptyLayer: true},
+ }
+
+ details := buildLayerDetails(history, nil)
+
+ if len(details) != 2 {
+ t.Fatalf("Expected 2 details, got %d", len(details))
+ }
+ for i, d := range details {
+ if !d.EmptyLayer {
+ t.Errorf("Layer %d: expected empty layer", i)
+ }
+ if d.Digest != "" {
+ t.Errorf("Layer %d: expected empty digest, got %q", i, d.Digest)
+ }
+ }
+}
diff --git a/pkg/appview/holdclient/config.go b/pkg/appview/holdclient/config.go
new file mode 100644
index 0000000..bdaba93
--- /dev/null
+++ b/pkg/appview/holdclient/config.go
@@ -0,0 +1,69 @@
+package holdclient
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "atcr.io/pkg/atproto"
+)
+
+// OCIHistoryEntry represents a single entry in the OCI config history.
+type OCIHistoryEntry struct {
+ Created string `json:"created"`
+ CreatedBy string `json:"created_by"`
+ EmptyLayer bool `json:"empty_layer"`
+ Comment string `json:"comment"`
+}
+
+// OCIConfig represents the parsed OCI image config with fields useful for display.
+type OCIConfig struct {
+ History []OCIHistoryEntry `json:"history"`
+}
+
+// FetchImageConfig fetches the OCI image config record from the hold's
+// getImageConfig XRPC endpoint. holdURL should be a resolved HTTP(S) URL.
+// Returns the parsed OCI config with history entries.
+func FetchImageConfig(ctx context.Context, holdURL, manifestDigest string) (*OCIConfig, error) {
+ reqURL := fmt.Sprintf("%s%s?digest=%s",
+ strings.TrimSuffix(holdURL, "/"),
+ atproto.HoldGetImageConfig,
+ url.QueryEscape(manifestDigest),
+ )
+
+ ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("build request: %w", err)
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("fetch image config: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("hold returned status %d for %s", resp.StatusCode, reqURL)
+ }
+
+ var record struct {
+ ConfigJSON string `json:"configJson"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&record); err != nil {
+ return nil, fmt.Errorf("parse image config response: %w", err)
+ }
+
+ var config OCIConfig
+ if err := json.Unmarshal([]byte(record.ConfigJSON), &config); err != nil {
+ return nil, fmt.Errorf("parse OCI config JSON: %w", err)
+ }
+
+ return &config, nil
+}
diff --git a/pkg/appview/templates/pages/digest.html b/pkg/appview/templates/pages/digest.html
new file mode 100644
index 0000000..718b118
--- /dev/null
+++ b/pkg/appview/templates/pages/digest.html
@@ -0,0 +1,215 @@
+{{ define "digest" }}
+
+
+
+ {{ template "head" . }}
+ {{ template "meta" .Meta }}
+
+
+ {{ template "nav" . }}
+
+
+
+
+
+
+
+
+
+
+
{{ .Manifest.Digest }}
+
+ {{ if .Manifest.Tags }}
+ {{ range .Manifest.Tags }}
+ {{ . }}
+ {{ end }}
+ {{ end }}
+ {{ if .Manifest.IsManifestList }}
+ Multi-arch
+ {{ else if eq .Manifest.ArtifactType "helm-chart" }}
+ {{ icon "helm" "size-3" }} Helm
+ {{ end }}
+ {{ if .Manifest.HasAttestations }}
+ {{ icon "shield-check" "size-3" }} Attested
+ {{ end }}
+
+
+
+ {{ icon "history" "size-4" }}{{ timeAgoShort .Manifest.CreatedAt }}
+
+
+
+
+
+ {{ if .Manifest.IsManifestList }}
+
+
+
Platforms
+
This is a multi-architecture manifest list. Select a platform to view layers and vulnerabilities.
+
+
+ {{ else }}
+
+
+
+
+
+
Layers ({{ len .Layers }})
+
+
+ {{ if .Layers }}
+
+
+
+
+ | # |
+ Command |
+ Size |
+
+
+
+ {{ range .Layers }}
+
+ | {{ .Index }} |
+
+ {{ if .Command }}
+ {{ .Command }}
+ {{ end }}
+ |
+ {{ humanizeBytes .Size }} |
+
+ {{ end }}
+
+
+
+
+ {{ else }}
+
No layer information available
+ {{ end }}
+
+
+
+
+
Vulnerabilities
+ {{ if .VulnData }}
+ {{ template "vuln-details" .VulnData }}
+ {{ else }}
+
No vulnerability scan data available
+ {{ end }}
+
+
+ {{ end }}
+
+
+
+ {{ template "footer" . }}
+
+
+{{ end }}
diff --git a/pkg/appview/templates/partials/repo-tags.html b/pkg/appview/templates/partials/repo-tags.html
new file mode 100644
index 0000000..2285e57
--- /dev/null
+++ b/pkg/appview/templates/partials/repo-tags.html
@@ -0,0 +1,177 @@
+{{ define "artifact-entry-markup" }}
+
+
+
+
+ {{ .Entry.Label }}
+ {{ if eq .Entry.ArtifactType "helm-chart" }}
+ {{ icon "helm" "size-3" }} Helm
+ {{ else if .Entry.IsMultiArch }}
+ Multi-arch
+ {{ end }}
+ {{ if .Entry.HasAttestations }}
+
+ {{ end }}
+
+
+ {{ if eq .Entry.ArtifactType "helm-chart" }}
+ {{ if .Entry.IsTagged }}
+ {{ template "docker-command" (print "helm pull oci://" .RegistryURL "/" .OwnerHandle "/" .RepoName " --version " .Entry.Label) }}
+ {{ else }}
+ {{ template "docker-command" (print "helm pull oci://" .RegistryURL "/" .OwnerHandle "/" .RepoName "@" .Entry.Digest) }}
+ {{ end }}
+ {{ else }}
+ {{ if .Entry.IsTagged }}
+ {{ template "docker-command" (print "docker pull " .RegistryURL "/" .OwnerHandle "/" .RepoName ":" .Entry.Label) }}
+ {{ else }}
+ {{ template "docker-command" (print "docker pull " .RegistryURL "/" .OwnerHandle "/" .RepoName "@" .Entry.Digest) }}
+ {{ end }}
+ {{ end }}
+ {{ icon "history" "size-4" }}{{ timeAgoShort .Entry.CreatedAt }}
+ {{ if .IsOwner }}
+ {{ if .Entry.IsTagged }}
+
+ {{ else }}
+
+ {{ end }}
+ {{ end }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Digest |
+ Vulnerabilities |
+ OS/Arch |
+ Size |
+
+
+
+ {{ range .Entry.Platforms }}
+
+ |
+
+ |
+
+ {{ if .HoldEndpoint }}
+
+
+
+ {{ end }}
+ |
+
+ {{ if .OS }}{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}{{ else }}-{{ end }}
+ |
+ {{ if .CompressedSize }}{{ humanizeBytes .CompressedSize }}{{ else }}-{{ end }} |
+
+ {{ end }}
+
+
+
+{{ end }}
+
+{{ define "load-more-button" }}
+{{ if .HasMore }}
+
+
+
+{{ end }}
+{{ end }}
+
+{{ define "scan-batch-triggers" }}
+{{ range .ScanBatchParams }}
+
+{{ end }}
+{{ end }}
+
+{{ define "repo-tags" }}
+
+ {{ if .Entries }}
+
+
+
+ Sort by
+
+
+
+
+
+ {{ if $.IsOwner }}
+
+
+
+ {{ end }}
+
+
+
+
+
+ {{ range .Entries }}
+ {{ template "artifact-entry-markup" (dict "Entry" . "OwnerDID" $.Owner.DID "OwnerHandle" $.Owner.Handle "RepoName" $.Repository.Name "RegistryURL" $.RegistryURL "IsOwner" $.IsOwner) }}
+ {{ end }}
+
+ {{ template "load-more-button" . }}
+
+
+ {{ template "scan-batch-triggers" . }}
+ {{ else }}
+
No artifacts available
+ {{ end }}
+
+{{ end }}
+
+{{ define "repo-tags-page" }}
+{{ range .Entries }}
+ {{ template "artifact-entry-markup" (dict "Entry" . "OwnerDID" $.Owner.DID "OwnerHandle" $.Owner.Handle "RepoName" $.Repository.Name "RegistryURL" $.RegistryURL "IsOwner" $.IsOwner) }}
+{{ end }}
+{{ template "load-more-button" . }}
+{{ template "scan-batch-triggers" . }}
+{{ end }}
diff --git a/pkg/hold/pds/imageconfig.go b/pkg/hold/pds/imageconfig.go
new file mode 100644
index 0000000..e7a3e92
--- /dev/null
+++ b/pkg/hold/pds/imageconfig.go
@@ -0,0 +1,53 @@
+package pds
+
+import (
+ "context"
+ "fmt"
+
+ "atcr.io/pkg/atproto"
+ "github.com/ipfs/go-cid"
+)
+
+// CreateImageConfigRecord creates or updates an OCI image config record in the hold's PDS.
+// Uses a deterministic rkey based on the manifest digest, so re-pushes upsert.
+func (p *HoldPDS) CreateImageConfigRecord(ctx context.Context, record *atproto.ImageConfigRecord, manifestDigest string) (string, cid.Cid, error) {
+ if record.Type != atproto.ImageConfigCollection {
+ return "", cid.Undef, fmt.Errorf("invalid record type: %s", record.Type)
+ }
+
+ if record.Manifest == "" {
+ return "", cid.Undef, fmt.Errorf("manifest AT-URI is required")
+ }
+
+ rkey := atproto.ScanRecordKey(manifestDigest)
+
+ rpath, recordCID, _, err := p.repomgr.UpsertRecord(
+ ctx,
+ p.uid,
+ atproto.ImageConfigCollection,
+ rkey,
+ record,
+ )
+ if err != nil {
+ return "", cid.Undef, fmt.Errorf("failed to upsert image config record: %w", err)
+ }
+
+ return rpath, recordCID, nil
+}
+
+// GetImageConfigRecord retrieves an OCI image config record by manifest digest.
+func (p *HoldPDS) GetImageConfigRecord(ctx context.Context, manifestDigest string) (cid.Cid, *atproto.ImageConfigRecord, error) {
+ rkey := atproto.ScanRecordKey(manifestDigest)
+
+ recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, atproto.ImageConfigCollection, rkey, cid.Undef)
+ if err != nil {
+ return cid.Undef, nil, fmt.Errorf("failed to get image config record: %w", err)
+ }
+
+ configRecord, ok := val.(*atproto.ImageConfigRecord)
+ if !ok {
+ return cid.Undef, nil, fmt.Errorf("unexpected type for image config record: %T", val)
+ }
+
+ return recordCID, configRecord, nil
+}