···11+description: Add is_attestation column to manifest_references table
22+query: |
33+ -- Add is_attestation column to track attestation manifests
44+ -- Attestation manifests have vnd.docker.reference.type = "attestation-manifest"
55+ ALTER TABLE manifest_references ADD COLUMN is_attestation BOOLEAN DEFAULT FALSE;
66+77+ -- Mark existing unknown/unknown platforms as attestations
88+ -- Docker BuildKit attestation manifests always have unknown/unknown platform
99+ UPDATE manifest_references
1010+ SET is_attestation = 1
1111+ WHERE platform_os = 'unknown' AND platform_architecture = 'unknown';
+8-6
pkg/appview/db/models.go
···4545 PlatformOS string
4646 PlatformVariant string
4747 PlatformOSVersion string
4848+ IsAttestation bool // true if vnd.docker.reference.type = "attestation-manifest"
4849 ReferenceIndex int
4950}
5051···154155// ManifestWithMetadata extends Manifest with tags and platform information
155156type ManifestWithMetadata struct {
156157 Manifest
157157- Tags []string
158158- Platforms []PlatformInfo
159159- PlatformCount int
160160- IsManifestList bool
161161- Reachable bool // Whether the hold endpoint is reachable
162162- Pending bool // Whether health check is still in progress
158158+ Tags []string
159159+ Platforms []PlatformInfo
160160+ PlatformCount int
161161+ IsManifestList bool
162162+ HasAttestations bool // true if manifest list contains attestation references
163163+ Reachable bool // Whether the hold endpoint is reachable
164164+ Pending bool // Whether health check is still in progress
163165}
-14
pkg/appview/db/oauth_store.go
···212212 return &sessionData, sessionID, nil
213213}
214214215215-// HasSessionForDID checks if an OAuth session exists for the given DID
216216-// This is a lightweight check used by the token handler to verify device auth
217217-func (s *OAuthStore) HasSessionForDID(ctx context.Context, did string) bool {
218218- var count int
219219- err := s.db.QueryRowContext(ctx, `
220220- SELECT COUNT(*) FROM oauth_sessions WHERE account_did = ?
221221- `, did).Scan(&count)
222222- if err != nil {
223223- slog.Debug("Failed to check session existence", "did", did, "error", err)
224224- return false
225225- }
226226- return count > 0
227227-}
228228-229215// CleanupOldSessions removes sessions older than the specified duration
230216func (s *OAuthStore) CleanupOldSessions(ctx context.Context, olderThan time.Duration) error {
231217 cutoff := time.Now().Add(-olderThan)
+25-7
pkg/appview/db/queries.go
···804804 INSERT INTO manifest_references (manifest_id, digest, size, media_type,
805805 platform_architecture, platform_os,
806806 platform_variant, platform_os_version,
807807- reference_index)
808808- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
807807+ is_attestation, reference_index)
808808+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
809809 `, ref.ManifestID, ref.Digest, ref.Size, ref.MediaType,
810810 ref.PlatformArchitecture, ref.PlatformOS,
811811 ref.PlatformVariant, ref.PlatformOSVersion,
812812- ref.ReferenceIndex)
812812+ ref.IsAttestation, ref.ReferenceIndex)
813813 return err
814814}
815815···940940 mr.platform_os,
941941 mr.platform_architecture,
942942 mr.platform_variant,
943943- mr.platform_os_version
943943+ mr.platform_os_version,
944944+ COALESCE(mr.is_attestation, 0) as is_attestation
944945 FROM manifest_references mr
945946 WHERE mr.manifest_id = ?
946947 ORDER BY mr.reference_index
···954955 for platformRows.Next() {
955956 var p PlatformInfo
956957 var os, arch, variant, osVersion sql.NullString
958958+ var isAttestation bool
957959958958- if err := platformRows.Scan(&os, &arch, &variant, &osVersion); err != nil {
960960+ if err := platformRows.Scan(&os, &arch, &variant, &osVersion, &isAttestation); err != nil {
959961 platformRows.Close()
960962 return nil, err
961963 }
962964965965+ // Track if manifest list has attestations
966966+ if isAttestation {
967967+ manifests[i].HasAttestations = true
968968+ // Skip attestation references in platform display
969969+ continue
970970+ }
971971+963972 if os.Valid {
964973 p.OS = os.String
965974 }
···10391048 mr.platform_os,
10401049 mr.platform_architecture,
10411050 mr.platform_variant,
10421042- mr.platform_os_version
10511051+ mr.platform_os_version,
10521052+ COALESCE(mr.is_attestation, 0) as is_attestation
10431053 FROM manifest_references mr
10441054 WHERE mr.manifest_id = ?
10451055 ORDER BY mr.reference_index
···10541064 for platforms.Next() {
10551065 var p PlatformInfo
10561066 var os, arch, variant, osVersion sql.NullString
10671067+ var isAttestation bool
1057106810581058- if err := platforms.Scan(&os, &arch, &variant, &osVersion); err != nil {
10691069+ if err := platforms.Scan(&os, &arch, &variant, &osVersion, &isAttestation); err != nil {
10591070 return nil, err
10711071+ }
10721072+10731073+ // Track if manifest list has attestations
10741074+ if isAttestation {
10751075+ m.HasAttestations = true
10761076+ // Skip attestation references in platform display
10771077+ continue
10601078 }
1061107910621080 if os.Valid {
···360360361361 return nil
362362}
363363+364364+// ValidateSession checks if an OAuth session is usable by attempting to load it.
365365+// This triggers token refresh if needed (via indigo's auto-refresh in DoWithSession).
366366+// Returns nil if session is valid, error if session is invalid/expired/needs re-auth.
367367+//
368368+// This is used by the token handler to validate OAuth sessions before issuing JWTs,
369369+// preventing the flood of errors that occurs when a stale session is discovered
370370+// during parallel layer uploads.
371371+func (r *Refresher) ValidateSession(ctx context.Context, did string) error {
372372+ return r.DoWithSession(ctx, did, func(session *oauth.ClientSession) error {
373373+ // Session loaded and refreshed successfully
374374+ // DoWithSession already handles token refresh if needed
375375+ slog.Debug("OAuth session validated successfully",
376376+ "component", "oauth/refresher",
377377+ "did", did)
378378+ return nil
379379+ })
380380+}
+24-19
pkg/auth/token/handler.go
···2020// without coupling the token package to AppView-specific dependencies.
2121type PostAuthCallback func(ctx context.Context, did, handle, pdsEndpoint, accessToken string) error
22222323-// OAuthSessionChecker checks if an OAuth session exists for a DID
2424-// This interface allows the token handler to verify OAuth sessions without
2525-// depending directly on the OAuth store implementation.
2626-type OAuthSessionChecker interface {
2727- HasSessionForDID(ctx context.Context, did string) bool
2323+// OAuthSessionValidator validates OAuth sessions before issuing tokens
2424+// This interface allows the token handler to verify OAuth sessions are usable
2525+// (not just that they exist) without depending directly on the OAuth implementation.
2626+type OAuthSessionValidator interface {
2727+ // ValidateSession checks if OAuth session is usable by attempting to load/refresh it
2828+ // Returns nil if session is valid, error if session is invalid/expired/needs re-auth
2929+ ValidateSession(ctx context.Context, did string) error
2830}
29313032// Handler handles /auth/token requests
3133type Handler struct {
3232- issuer *Issuer
3333- validator *auth.SessionValidator
3434- deviceStore *db.DeviceStore // For validating device secrets
3535- postAuthCallback PostAuthCallback
3636- oauthSessionChecker OAuthSessionChecker
3434+ issuer *Issuer
3535+ validator *auth.SessionValidator
3636+ deviceStore *db.DeviceStore // For validating device secrets
3737+ postAuthCallback PostAuthCallback
3838+ oauthSessionValidator OAuthSessionValidator
3739}
38403941// NewHandler creates a new token handler
···5153 h.postAuthCallback = callback
5254}
53555454-// SetOAuthSessionChecker sets the OAuth session checker for validating device auth
5555-// When set, the handler will verify OAuth sessions exist before issuing tokens for device auth
5656-func (h *Handler) SetOAuthSessionChecker(checker OAuthSessionChecker) {
5757- h.oauthSessionChecker = checker
5656+// SetOAuthSessionValidator sets the OAuth session validator for validating device auth
5757+// When set, the handler will validate OAuth sessions are usable before issuing tokens for device auth
5858+// This prevents the flood of errors that occurs when a stale session is discovered during push
5959+func (h *Handler) SetOAuthSessionValidator(validator OAuthSessionValidator) {
6060+ h.oauthSessionValidator = validator
5861}
59626063// TokenResponse represents the response from /auth/token
···169172 return
170173 }
171174172172- // Check if OAuth session exists for this device's DID
173173- // Device secrets are permanent, but they require an active OAuth session to work
174174- if h.oauthSessionChecker != nil {
175175- if !h.oauthSessionChecker.HasSessionForDID(r.Context(), device.DID) {
176176- slog.Debug("No OAuth session for device", "did", device.DID)
175175+ // Validate OAuth session is usable (not just exists)
176176+ // Device secrets are permanent, but they require a working OAuth session to push
177177+ // By validating here, we prevent the flood of errors that occurs when a stale
178178+ // session is discovered during parallel layer uploads
179179+ if h.oauthSessionValidator != nil {
180180+ if err := h.oauthSessionValidator.ValidateSession(r.Context(), device.DID); err != nil {
181181+ slog.Debug("OAuth session validation failed", "did", device.DID, "error", err)
177182 sendOAuthSessionExpiredError(w, r)
178183 return
179184 }
+24-1
pkg/hold/oci/xrpc.go
···230230 Size int64 `json:"size"`
231231 MediaType string `json:"mediaType"`
232232 } `json:"layers"`
233233+ Manifests []struct {
234234+ Digest string `json:"digest"`
235235+ Size int64 `json:"size"`
236236+ MediaType string `json:"mediaType"`
237237+ Platform *struct {
238238+ OS string `json:"os"`
239239+ Architecture string `json:"architecture"`
240240+ } `json:"platform"`
241241+ } `json:"manifests"`
233242 } `json:"manifest"`
234243 }
235244···276285 }
277286 }
278287279279- // Calculate total size from all layers
288288+ // Check if this is a multi-arch image (has manifests instead of layers)
289289+ isMultiArch := len(req.Manifest.Manifests) > 0
290290+291291+ // Calculate total size from all layers (for single-arch images)
280292 var totalSize int64
281293 for _, layer := range req.Manifest.Layers {
282294 totalSize += layer.Size
283295 }
284296 totalSize += req.Manifest.Config.Size // Add config blob size
297297+298298+ // Extract platforms for multi-arch images
299299+ var platforms []string
300300+ if isMultiArch {
301301+ for _, m := range req.Manifest.Manifests {
302302+ if m.Platform != nil {
303303+ platforms = append(platforms, m.Platform.OS+"/"+m.Platform.Architecture)
304304+ }
305305+ }
306306+ }
285307286308 // Create Bluesky post if enabled
287309 var postURI string
···301323 req.UserDID,
302324 manifestDigest,
303325 totalSize,
326326+ platforms,
304327 )
305328 if err != nil {
306329 slog.Error("Failed to create manifest post", "error", err)
+13-3
pkg/hold/pds/manifest_post.go
···12121313// CreateManifestPost creates a Bluesky post announcing a manifest upload
1414// Includes facets for clickable mentions and links
1515+// For multi-arch images (platforms non-empty), shows platforms instead of size
1516func (p *HoldPDS) CreateManifestPost(
1617 ctx context.Context,
1718 repository, tag, userHandle, userDID, digest string,
1819 totalSize int64,
2020+ platforms []string,
1921) (string, error) {
2022 now := time.Now()
2123···24262527 // Format post text components
2628 digestShort := formatDigest(digest)
2727- sizeStr := formatSize(totalSize)
2829 repoWithTag := fmt.Sprintf("%s:%s", repository, tag)
29303030- // Build text: "@alice.bsky.social just pushed hsm-secrets-operator:latest\nDigest: sha256:abc...def Size: 12.2 MB"
3131- text := fmt.Sprintf("@%s just pushed %s\nDigest: %s Size: %s", userHandle, repoWithTag, digestShort, sizeStr)
3131+ // Build text based on whether this is multi-arch or single-arch
3232+ var text string
3333+ if len(platforms) > 0 {
3434+ // Multi-arch: show platforms
3535+ platformsStr := strings.Join(platforms, ", ")
3636+ text = fmt.Sprintf("@%s just pushed %s\nDigest: %s Platforms: %s", userHandle, repoWithTag, digestShort, platformsStr)
3737+ } else {
3838+ // Single-arch: show size
3939+ sizeStr := formatSize(totalSize)
4040+ text = fmt.Sprintf("@%s just pushed %s\nDigest: %s Size: %s", userHandle, repoWithTag, digestShort, sizeStr)
4141+ }
32423343 // Create facets for mentions and links
3444 facets := buildFacets(text, userHandle, userDID, repoWithTag, appViewURL)
+56
pkg/hold/pds/manifest_post_test.go
···341341 }
342342 }
343343}
344344+345345+func TestBuildFacets_MultiArchExample(t *testing.T) {
346346+ // Test with a multi-arch manifest (platforms instead of size)
347347+ repository := "myapp"
348348+ tag := "latest"
349349+ userHandle := "alice.bsky.social"
350350+ userDID := "did:plc:alice123"
351351+ digest := "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f"
352352+ platforms := []string{"linux/amd64", "linux/arm64"}
353353+354354+ repoWithTag := repository + ":" + tag
355355+ digestShort := formatDigest(digest)
356356+ platformsStr := strings.Join(platforms, ", ")
357357+358358+ text := "@" + userHandle + " just pushed " + repoWithTag + "\nDigest: " + digestShort + " Platforms: " + platformsStr
359359+ appViewURL := "https://atcr.io/r/" + userHandle + "/" + repository
360360+361361+ facets := buildFacets(text, userHandle, userDID, repoWithTag, appViewURL)
362362+363363+ // Should have 2 facets: mention and link
364364+ if len(facets) != 2 {
365365+ t.Fatalf("expected 2 facets, got %d", len(facets))
366366+ }
367367+368368+ // Verify the complete post structure
369369+ post := &bsky.FeedPost{
370370+ LexiconTypeID: "app.bsky.feed.post",
371371+ Text: text,
372372+ Facets: facets,
373373+ }
374374+375375+ if post.Text == "" {
376376+ t.Error("post text is empty")
377377+ }
378378+379379+ // Verify text contains expected components
380380+ expectedTexts := []string{
381381+ "@" + userHandle,
382382+ repoWithTag,
383383+ digestShort,
384384+ "Platforms:",
385385+ "linux/amd64",
386386+ "linux/arm64",
387387+ }
388388+389389+ for _, expected := range expectedTexts {
390390+ if !strings.Contains(post.Text, expected) {
391391+ t.Errorf("post text missing expected component: %q", expected)
392392+ }
393393+ }
394394+395395+ // Verify Size is NOT in multi-arch post
396396+ if strings.Contains(post.Text, "Size:") {
397397+ t.Error("multi-arch post should not contain Size:")
398398+ }
399399+}