67 Commits

Author SHA1 Message Date
Evan Jarrett
31dc4b4f53 major refactor to implement usercontext 2025-12-29 17:02:07 -06:00
Evan Jarrett
af99929aa3 remove old test file 2025-12-29 17:01:48 -06:00
Evan Jarrett
7f2d780b0a move packages out of token that are not related to docker jwt token 2025-12-29 16:57:14 -06:00
Evan Jarrett
8956568ed2 remove unused filestore. replace it with memstore for tests 2025-12-29 16:51:08 -06:00
Evan Jarrett
c1f2ae0f7a fix scope mismatch? 2025-12-26 17:41:38 -06:00
Evan Jarrett
012a14c4ee try fix permission scope again 2025-12-26 17:13:19 -06:00
Evan Jarrett
4cda163099 add back individual scopes 2025-12-26 17:05:51 -06:00
Evan Jarrett
41bcee4a59 try new permission sets 2025-12-26 16:51:49 -06:00
Evan Jarrett
24d6b49481 clean up unused locks 2025-12-26 09:48:25 -06:00
Evan Jarrett
363c12e6bf remove unused function 2025-12-26 09:37:57 -06:00
Evan Jarrett
2a60a47fd5 fix issues pulling other users images. fix labels taking priority over annotations. fix various auth errors 2025-12-23 16:20:52 -06:00
Evan Jarrett
34c2b8b17c add a cache-control header to metadata page 2025-12-22 21:01:28 -06:00
Evan Jarrett
8d0cff63fb add 404 page 2025-12-22 12:43:18 -06:00
Evan Jarrett
d11356cd18 more improvements on repo page rendering. allow for repo avatar image uploads (requires new scopes) 2025-12-21 21:51:44 -06:00
Evan Jarrett
79d1126726 better handling for io.atcr.repo.page 2025-12-20 21:50:09 -06:00
Evan Jarrett
8e31137c62 better logic for relative urls 2025-12-20 16:48:08 -06:00
Evan Jarrett
023efb05aa add in the lexicon json 2025-12-20 16:32:55 -06:00
Evan Jarrett
b18e4c3996 implement io.atcr.repo.page. try and fetch from github,gitlab,tangled README.md files if source exists. 2025-12-20 16:32:41 -06:00
Evan Jarrett
24b265bf12 try and fetch from github/gitlab/tangled READMEs 2025-12-20 16:00:15 -06:00
Evan Jarrett
e8e375639d lexicon validation fix 2025-12-20 11:30:08 -06:00
Evan Jarrett
5a208de4c9 add attestation badge to tags 2025-12-20 11:00:24 -06:00
Evan Jarrett
104eb86c04 fix go version 2025-12-20 10:49:37 -06:00
Evan Jarrett
509a1c0306 some lexicon json cleanup. code formatting 2025-12-20 10:46:40 -06:00
Evan Jarrett
8d64efe229 clean up some lexicon usage 2025-12-20 10:44:26 -06:00
Evan Jarrett
23303c2187 have Holds post with new og card 2025-12-20 10:40:11 -06:00
Evan Jarrett
e872b71d63 fix word wrapping 2025-12-18 14:30:18 -06:00
Evan Jarrett
bd55783d8e more style fixes for the og cards 2025-12-18 14:03:49 -06:00
Evan Jarrett
3b343c9fdb fix embed for discord 2025-12-18 13:55:18 -06:00
Evan Jarrett
a9704143f0 fix 2025-12-18 13:32:05 -06:00
Evan Jarrett
96e29a548d fix dockerfile 2025-12-18 12:53:43 -06:00
Evan Jarrett
5f19213e32 better open graph 2025-12-18 12:29:20 -06:00
Evan Jarrett
afbc039751 fix open graph 2025-12-18 11:27:18 -06:00
Evan Jarrett
044d408cf8 deployment fixes. add open graph 2025-12-18 11:19:49 -06:00
Evan Jarrett
4063544cdf cleanup view around attestations. credential helper self upgrades. better oauth support 2025-12-18 09:33:31 -06:00
Evan Jarrett
111cc4cc18 placeholder profile for when sailor profile is not found 2025-12-10 14:34:18 -06:00
Evan Jarrett
cefe0038fc support did lookups in urls 2025-12-09 22:30:57 -06:00
Evan Jarrett
82dd0d6a9b silence warnings on apt install 2025-12-09 13:11:44 -06:00
Evan Jarrett
02fabc4a41 fix build pipeline. fix using wrong auth method when trying to push with app-password 2025-12-09 11:51:42 -06:00
Evan Jarrett
5dff759064 fix pushing images when the historical hold does not match the default hold in the account 2025-12-09 11:38:26 -06:00
Evan Jarrett
c4a9e4bf00 add monitor script 2025-12-09 10:50:54 -06:00
Evan Jarrett
a09453c60d try with buildah 2025-12-03 22:28:53 -06:00
Evan Jarrett
4a4a7b4258 needs image 2025-11-25 17:17:02 -06:00
Evan Jarrett
ec08cec050 disable credhelper workflow 2025-11-25 17:11:12 -06:00
Evan Jarrett
ed0f35e841 add tests to loom spindle 2025-11-25 09:27:11 -06:00
Evan Jarrett
5f1eb05a96 try and provide more helpful reponses when oauth expires and when pushing manifest lists 2025-11-25 09:25:38 -06:00
Evan Jarrett
66037c332e locks locks locks locks 2025-11-24 22:49:17 -06:00
Evan Jarrett
08b8bcf295 ugh 2025-11-24 13:57:32 -06:00
Evan Jarrett
88df0c4ae5 fix tag deletion in UI 2025-11-24 13:51:00 -06:00
Evan Jarrett
fb7ddd0d53 try and create a cache for layer pushing again 2025-11-24 13:25:24 -06:00
Evan Jarrett
ecf84ed8bc type-ahead login api. fix app-passwords not working without oauth 2025-11-09 21:57:28 -06:00
Evan Jarrett
3bdc0da90b try and lock session get/update 2025-11-09 15:04:44 -06:00
Evan Jarrett
628f8b7c62 try and trace oauth failures 2025-11-09 13:07:35 -06:00
Evan Jarrett
15d3684cf6 try and fix bad oauth cache 2025-11-08 20:47:57 -06:00
Evan Jarrett
4667d34b46 try and persist session tokens 2025-11-07 22:43:44 -06:00
Evan Jarrett
4d5182e2b2 fix jetstream using wrong manifest key 2025-11-07 11:06:51 -06:00
Evan Jarrett
65d155f74f try and invalidate sessions 2025-11-04 23:27:15 -06:00
Evan Jarrett
92d794415a don't use in-memory for holddid caching, just reference from db 2025-11-04 22:48:42 -06:00
Evan Jarrett
270fe15e1e more workflow fixes. update indigo, fix ensure crew logic on oauth 2025-11-04 12:40:30 -06:00
Evan Jarrett
7285dd44f3 fix 2025-11-03 17:16:44 -06:00
Evan Jarrett
9bd49b9e49 test tag push 2025-11-03 16:37:39 -06:00
Evan Jarrett
6b56f18715 begin brew tap support 2025-11-02 22:11:19 -06:00
Evan Jarrett
e296971c47 add makefile fix race conditions 2025-11-01 19:37:29 -05:00
Evan Jarrett
d7eba25f66 update workflow for buildah 2025-11-01 15:05:36 -05:00
Evan Jarrett
7a0050235d background ensurecrew to prevent stalling oauth 2025-11-01 11:08:53 -05:00
Evan Jarrett
ff7bc131b2 rename example go files for documentation 2025-11-01 10:29:11 -05:00
Evan Jarrett
2d720e4154 remove extra docker volume in prod 2025-10-31 21:06:11 -05:00
Evan Jarrett
e6b1264269 try and offline holds 2025-10-31 21:03:33 -05:00
167 changed files with 18330 additions and 11818 deletions

27
.air.toml Normal file
View File

@@ -0,0 +1,27 @@
root = "."
tmp_dir = "tmp"
[build]
# Pre-build: generate assets if missing (each string is a shell command)
pre_cmd = ["[ -f pkg/appview/static/js/htmx.min.js ] || go generate ./..."]
cmd = "go build -buildvcs=false -o ./tmp/atcr-appview ./cmd/appview"
entrypoint = ["./tmp/atcr-appview", "serve"]
include_ext = ["go", "html", "css", "js"]
exclude_dir = ["bin", "tmp", "vendor", "deploy", "docs", ".git", "dist"]
exclude_regex = ["_test\\.go$"]
delay = 1000
stop_on_error = true
send_interrupt = true
kill_delay = 500
[log]
time = false
[color]
main = "cyan"
watcher = "magenta"
build = "yellow"
runner = "green"
[misc]
clean_on_exit = true

View File

@@ -29,8 +29,8 @@ AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
# S3 Region
# Examples: us-east-1, us-west-2, eu-west-1
# For UpCloud: us-chi1, us-nyc1, de-fra1, uk-lon1, sg-sin1
# For third-party S3 providers, this is ignored when S3_ENDPOINT is set,
# but must be a valid AWS region (e.g., us-east-1) to pass validation.
# Default: us-east-1
AWS_REGION=us-east-1
@@ -61,6 +61,11 @@ S3_BUCKET=atcr-blobs
# Default: false
HOLD_PUBLIC=false
# ATProto relay endpoint for requesting crawl on startup
# This makes the hold's embedded PDS discoverable by the relay network
# Default: https://bsky.network (set to empty string to disable)
# HOLD_RELAY_ENDPOINT=https://bsky.network
# ==============================================================================
# Embedded PDS Configuration
# ==============================================================================
@@ -111,33 +116,6 @@ HOLD_DATABASE_DIR=/var/lib/atcr-hold
#
HOLD_OWNER=did:plc:your-did-here
# ==============================================================================
# Scanner Configuration (SBOM & Vulnerability Scanning)
# ==============================================================================
# Enable automatic SBOM generation and vulnerability scanning on image push
# Default: true
HOLD_SBOM_ENABLED=true
# Number of concurrent scanner worker threads
# Default: 2
HOLD_SBOM_WORKERS=2
# Enable vulnerability scanning with Grype
# If false, only SBOM generation (Syft) will run
# Default: true
HOLD_VULN_ENABLED=true
# Path to Grype vulnerability database
# Database is auto-downloaded and cached at this location
# Default: /var/lib/atcr-hold/grype-db
# HOLD_VULN_DB_PATH=/var/lib/atcr-hold/grype-db
# How often to update vulnerability database
# Examples: 24h, 12h, 48h
# Default: 24h
# HOLD_VULN_DB_UPDATE_INTERVAL=24h
# ==============================================================================
# Logging Configuration
# ==============================================================================

6
.gitignore vendored
View File

@@ -1,6 +1,7 @@
# Binaries
bin/
dist/
tmp/
# Test artifacts
.atcr-pids
@@ -11,6 +12,11 @@ dist/
# Environment configuration
.env
# Generated assets (run go generate to rebuild)
pkg/appview/licenses/spdx-licenses.json
pkg/appview/static/js/htmx.min.js
pkg/appview/static/js/lucide.min.js
# IDE
.claude/
.vscode/

View File

@@ -0,0 +1,155 @@
# Tangled Workflow: Release Credential Helper
#
# This workflow builds cross-platform binaries for the credential helper.
# Creates tarballs for curl/bash installation and provides instructions
# for updating the Homebrew formula.
#
# Triggers on version tags (v*) pushed to the repository.
when:
- event: ["manual"]
tag: ["v*"]
engine: "nixery"
dependencies:
nixpkgs:
- go_1_24 # Go 1.24+ for building
- goreleaser # For building multi-platform binaries
- curl # Required by go generate for downloading vendor assets
- gnugrep # Required for tag detection
- gnutar # Required for creating tarballs
- gzip # Required for compressing tarballs
- coreutils # Required for sha256sum
environment:
CGO_ENABLED: "0" # Build static binaries
steps:
- name: Get tag for current commit
command: |
# Fetch tags (shallow clone doesn't include them by default)
git fetch --tags
# Find the tag that points to the current commit
TAG=$(git tag --points-at HEAD | grep -E '^v[0-9]' | head -n1)
if [ -z "$TAG" ]; then
echo "Error: No version tag found for current commit"
echo "Available tags:"
git tag
echo "Current commit:"
git rev-parse HEAD
exit 1
fi
echo "Building version: $TAG"
echo "$TAG" > .version
# Also get the commit hash for reference
COMMIT_HASH=$(git rev-parse HEAD)
echo "Commit: $COMMIT_HASH"
- name: Build binaries with GoReleaser
command: |
VERSION=$(cat .version)
export VERSION
# Build for all platforms using GoReleaser
goreleaser build --clean --snapshot --config .goreleaser.yaml
# List what was built
echo "Built artifacts:"
if [ -d "dist" ]; then
ls -lh dist/
else
echo "Error: dist/ directory was not created by GoReleaser"
exit 1
fi
- name: Package artifacts
command: |
VERSION=$(cat .version)
VERSION_NO_V=${VERSION#v} # Remove 'v' prefix for filenames
cd dist
# Create tarballs for each platform
# GoReleaser creates directories like: credential-helper_{os}_{arch}_v{goversion}
# Darwin x86_64
if [ -d "credential-helper_darwin_amd64_v1" ]; then
tar czf "docker-credential-atcr_${VERSION_NO_V}_Darwin_x86_64.tar.gz" \
-C credential-helper_darwin_amd64_v1 docker-credential-atcr
echo "Created: docker-credential-atcr_${VERSION_NO_V}_Darwin_x86_64.tar.gz"
fi
# Darwin arm64
for dir in credential-helper_darwin_arm64*; do
if [ -d "$dir" ]; then
tar czf "docker-credential-atcr_${VERSION_NO_V}_Darwin_arm64.tar.gz" \
-C "$dir" docker-credential-atcr
echo "Created: docker-credential-atcr_${VERSION_NO_V}_Darwin_arm64.tar.gz"
break
fi
done
# Linux x86_64
if [ -d "credential-helper_linux_amd64_v1" ]; then
tar czf "docker-credential-atcr_${VERSION_NO_V}_Linux_x86_64.tar.gz" \
-C credential-helper_linux_amd64_v1 docker-credential-atcr
echo "Created: docker-credential-atcr_${VERSION_NO_V}_Linux_x86_64.tar.gz"
fi
# Linux arm64
for dir in credential-helper_linux_arm64*; do
if [ -d "$dir" ]; then
tar czf "docker-credential-atcr_${VERSION_NO_V}_Linux_arm64.tar.gz" \
-C "$dir" docker-credential-atcr
echo "Created: docker-credential-atcr_${VERSION_NO_V}_Linux_arm64.tar.gz"
break
fi
done
echo ""
echo "Tarballs ready:"
ls -lh *.tar.gz 2>/dev/null || echo "Warning: No tarballs created"
- name: Generate checksums
command: |
VERSION=$(cat .version)
VERSION_NO_V=${VERSION#v}
cd dist
echo ""
echo "=========================================="
echo "SHA256 Checksums"
echo "=========================================="
echo ""
# Generate checksums file
sha256sum docker-credential-atcr_${VERSION_NO_V}_*.tar.gz 2>/dev/null | tee checksums.txt || echo "No checksums generated"
- name: Next steps
command: |
VERSION=$(cat .version)
echo ""
echo "=========================================="
echo "Release $VERSION is ready!"
echo "=========================================="
echo ""
echo "Distribution tarballs are in: dist/"
echo ""
echo "Next steps:"
echo ""
echo "1. Upload tarballs to your hosting/CDN (or GitHub releases)"
echo ""
echo "2. For Homebrew users, update the formula:"
echo " ./scripts/update-homebrew-formula.sh $VERSION"
echo " # Then update Formula/docker-credential-atcr.rb and push to homebrew-tap"
echo ""
echo "3. For curl/bash installation, users can download directly:"
echo " curl -L <your-cdn>/docker-credential-atcr_<version>_<os>_<arch>.tar.gz | tar xz"
echo " sudo mv docker-credential-atcr /usr/local/bin/"

View File

@@ -1,55 +1,44 @@
# ATCR Release Pipeline for Tangled.org
# Triggers on version tags and builds cross-platform binaries using GoReleaser
# Triggers on version tags and builds cross-platform binaries using buildah
when:
- event: ["manual"]
# TODO: Trigger only on version tags (v1.0.0, v2.1.3, etc.)
branch: ["main"]
- event: ["push"]
tag: ["v*"]
engine: "nixery"
engine: kubernetes
image: quay.io/buildah/stable:latest
architecture: amd64
dependencies:
nixpkgs:
- git
- go
#- goreleaser
- podman
environment:
IMAGE_REGISTRY: atcr.io
IMAGE_USER: atcr.io
steps:
- name: Fetch git tags
command: git fetch --tags --force
- name: Checkout tag for current commit
- name: Login to registry
command: |
CURRENT_COMMIT=$(git rev-parse HEAD)
export TAG=$(git tag --points-at $CURRENT_COMMIT --sort=-version:refname | head -n1)
if [ -z "$TAG" ]; then
echo "Error: No tag found for commit $CURRENT_COMMIT"
exit 1
fi
echo "Found tag $TAG for commit $CURRENT_COMMIT"
git checkout $TAG
echo "${APP_PASSWORD}" | buildah login \
-u "${IMAGE_USER}" \
--password-stdin \
${IMAGE_REGISTRY}
- name: Build AppView Docker image
- name: Build and push AppView image
command: |
TAG=$(git describe --tags --exact-match 2>/dev/null || git tag --points-at HEAD | head -n1)
podman login atcr.io -u evan.jarrett.net -p ${APP_PASSWORD}
podman build -f Dockerfile.appview -t atcr.io/evan.jarrett.net/atcr-appview:${TAG} .
podman push atcr.io/evan.jarrett.net/atcr-appview:${TAG}
buildah bud \
--tag ${IMAGE_REGISTRY}/${IMAGE_USER}/appview:${TANGLED_REF_NAME} \
--tag ${IMAGE_REGISTRY}/${IMAGE_USER}/appview:latest \
--file ./Dockerfile.appview \
.
- name: Build Hold Docker image
buildah push \
${IMAGE_REGISTRY}/${IMAGE_USER}/appview:latest
- name: Build and push Hold image
command: |
TAG=$(git describe --tags --exact-match 2>/dev/null || git tag --points-at HEAD | head -n1)
podman login atcr.io -u evan.jarrett.net -p ${APP_PASSWORD}
podman build -f Dockerfile.hold -t atcr.io/evan.jarrett.net/atcr-hold:${TAG} .
podman push atcr.io/evan.jarrett.net/atcr-hold:${TAG}
# disable for now
# - name: Tidy Go modules
# command: go mod tidy
buildah bud \
--tag ${IMAGE_REGISTRY}/${IMAGE_USER}/hold:${TANGLED_REF_NAME} \
--tag ${IMAGE_REGISTRY}/${IMAGE_USER}/hold:latest \
--file ./Dockerfile.hold \
.
# - name: Install Goat
# command: go install github.com/bluesky-social/goat@latest
# - name: Run GoReleaser
# command: goreleaser release --clean
buildah push \
${IMAGE_REGISTRY}/${IMAGE_USER}/hold:latest

View File

@@ -1,16 +1,12 @@
when:
- event: ["push"]
branch: ["main"]
branch: ["*"]
- event: ["pull_request"]
branch: ["main"]
engine: "nixery"
dependencies:
nixpkgs:
- gcc
- go
- curl
engine: kubernetes
image: golang:1.25-trixie
architecture: amd64
steps:
- name: Download and Generate
@@ -24,4 +20,4 @@ steps:
environment:
CGO_ENABLED: 1
command: |
go test -cover ./...
go test -cover ./...

147
CLAUDE.md
View File

@@ -206,9 +206,62 @@ ATCR uses middleware and routing to handle requests:
- Implements `distribution.Repository`
- Returns custom `Manifests()` and `Blobs()` implementations
- Routes manifests to ATProto, blobs to S3 or BYOS
- **IMPORTANT**: RoutingRepository is created fresh on EVERY request (no caching)
- Each Docker layer upload is a separate HTTP request (possibly different process)
- OAuth sessions can be refreshed/invalidated between requests
- The OAuth refresher already caches sessions efficiently (in-memory + DB)
- Previous caching of repositories with stale ATProtoClient caused "invalid refresh token" errors
### Authentication Architecture
#### Token Types and Flows
ATCR uses three distinct token types in its authentication flow:
**1. OAuth Tokens (Access + Refresh)**
- **Issued by:** User's PDS via OAuth flow
- **Stored in:** AppView database (`oauth_sessions` table)
- **Cached in:** Refresher's in-memory map (per-DID)
- **Used for:** AppView → User's PDS communication (write manifests, read profiles)
- **Managed by:** Indigo library with DPoP (automatic refresh)
- **Lifetime:** Access ~2 hours, Refresh ~90 days (PDS controlled)
**2. Registry JWTs**
- **Issued by:** AppView after OAuth login
- **Stored in:** Docker credential helper (`~/.atcr/credential-helper-token.json`)
- **Used for:** Docker client → AppView authentication
- **Lifetime:** 15 minutes (configurable via `ATCR_TOKEN_EXPIRATION`)
- **Format:** JWT with DID claim
**3. Service Tokens**
- **Issued by:** User's PDS via `com.atproto.server.getServiceAuth`
- **Stored in:** AppView memory (in-memory cache with ~50s TTL)
- **Used for:** AppView → Hold service authentication (acting on behalf of user)
- **Lifetime:** 60 seconds (PDS controlled), cached for 50s
- **Required:** OAuth session to obtain (catch-22 solved by Refresher)
**Token Flow Diagram:**
```
┌─────────────┐ ┌──────────────┐
│ Docker │ ─── Registry JWT ──────────────→ │ AppView │
│ Client │ │ │
└─────────────┘ └──────┬───────┘
│ OAuth tokens
│ (access + refresh)
┌──────────────┐
│ User's PDS │
└──────┬───────┘
│ Service token
│ (via getServiceAuth)
┌──────────────┐
│ Hold Service │
└──────────────┘
```
#### ATProto OAuth with DPoP
ATCR implements the full ATProto OAuth specification with mandatory security features:
@@ -220,13 +273,22 @@ ATCR implements the full ATProto OAuth specification with mandatory security fea
**Key Components** (`pkg/auth/oauth/`):
1. **Client** (`client.go`) - Core OAuth client with encapsulated configuration
- Uses indigo's `NewLocalhostConfig()` for localhost (public client)
- Uses `NewPublicConfig()` for production base (upgraded to confidential if key provided)
- `RedirectURI()` - returns `baseURL + "/auth/oauth/callback"`
- `GetDefaultScopes()` - returns ATCR registry scopes
- `GetConfigRef()` - returns mutable config for `SetClientSecret()` calls
- All OAuth flows (authorization, token exchange, refresh) in one place
1. **Client** (`client.go`) - OAuth client configuration and session management
- **ClientApp setup:**
- `NewClientApp()` - Creates configured `*oauth.ClientApp` (uses indigo directly, no wrapper)
- Uses `NewLocalhostConfig()` for localhost (public client)
- Uses `NewPublicConfig()` for production (upgraded to confidential with P-256 key)
- `GetDefaultScopes()` - Returns ATCR-specific OAuth scopes
- `ScopesMatch()` - Compares scope lists (order-independent)
- **Session management (Refresher):**
- `NewRefresher()` - Creates session cache manager for AppView
- **Purpose:** In-memory cache for `*oauth.ClientSession` objects (performance optimization)
- **Why needed:** Saves 1-2 DB queries per request (~2ms) with minimal code complexity
- Per-DID locking prevents concurrent database loads
- Calls `ClientApp.ResumeSession()` on cache miss
- Indigo handles token refresh automatically (transparent to ATCR)
- **Performance:** Essential for high-traffic deployments, negligible for low-traffic
- **Architecture:** Single file containing both ClientApp helpers and Refresher (combined from previous two-file structure)
2. **Keys** (`keys.go`) - P-256 key management for confidential clients
- `GenerateOrLoadClientKey()` - generates or loads P-256 key from disk
@@ -235,21 +297,17 @@ ATCR implements the full ATProto OAuth specification with mandatory security fea
- `PrivateKeyToMultibase()` - converts key for `SetClientSecret()` API
- **Key type:** P-256 (ES256) for OAuth standard compatibility (not K-256 like PDS keys)
3. **Token Storage** (`store.go`) - Persists OAuth sessions for AppView
- SQLite-backed storage in UI database (not file-based)
- Client uses `~/.atcr/oauth-token.json` (credential helper)
3. **Storage** - Persists OAuth sessions
- `db/oauth_store.go` - SQLite-backed storage for AppView (in UI database)
- `store.go` - File-based storage for CLI tools (`~/.atcr/oauth-sessions.json`)
- Implements indigo's `ClientAuthStore` interface
4. **Refresher** (`refresher.go`) - Token refresh manager for AppView
- Caches OAuth sessions with automatic token refresh (handled by indigo library)
- Per-DID locking prevents concurrent refresh races
- Uses Client methods for consistency
5. **Server** (`server.go`) - OAuth authorization endpoints for AppView
4. **Server** (`server.go`) - OAuth authorization endpoints for AppView
- `GET /auth/oauth/authorize` - starts OAuth flow
- `GET /auth/oauth/callback` - handles OAuth callback
- Uses Client methods for authorization and token exchange
- Uses `ClientApp` methods directly (no wrapper)
6. **Interactive Flow** (`interactive.go`) - Reusable OAuth flow for CLI tools
5. **Interactive Flow** (`interactive.go`) - Reusable OAuth flow for CLI tools
- Used by credential helper and hold service registration
- Two-phase callback setup ensures PAR metadata availability
@@ -349,12 +407,13 @@ Later (subsequent docker push):
- Implements `distribution.Repository` interface
- Uses RegistryContext to pass DID, PDS endpoint, hold DID, OAuth refresher, etc.
**hold_cache.go**: In-memory hold DID cache
- Caches `(DID, repository) → holdDid` for pull operations
- TTL: 10 minutes (covers typical pull operations)
- Cleanup: Background goroutine runs every 5 minutes
- **NOTE:** Simple in-memory cache for MVP. For production: use Redis or similar
- Prevents expensive PDS manifest lookups on every blob request during pull
**Database-based hold DID lookups**:
- Queries SQLite `manifests` table for hold DID (indexed, fast)
- No in-memory caching needed - database IS the cache
- Persistent across restarts, multi-instance safe
- Pull operations use hold DID from latest manifest (historical reference)
- Push operations use fresh discovery from profile/default
- Function: `db.GetLatestHoldDIDForRepo(did, repository)` in `pkg/appview/db/queries.go`
**proxy_blob_store.go**: External storage proxy (routes to hold via XRPC)
- Resolves hold DID → HTTP URL for XRPC requests (did:web resolution)
@@ -416,12 +475,47 @@ Lightweight standalone service for BYOS (Bring Your Own Storage) with embedded P
Read access:
- **Public hold** (`HOLD_PUBLIC=true`): Anonymous + all authenticated users
- **Private hold** (`HOLD_PUBLIC=false`): Requires authentication + crew membership with blob:read permission
- **Private hold** (`HOLD_PUBLIC=false`): Requires authentication + crew membership with blob:read OR blob:write permission
- **Note:** `blob:write` implicitly grants `blob:read` access (can't push without pulling)
Write access:
- Hold owner OR crew members with blob:write permission
- Verified via `io.atcr.hold.crew` records in hold's embedded PDS
**Permission Matrix:**
| User Type | Public Read | Private Read | Write | Crew Admin |
|-----------|-------------|--------------|-------|------------|
| Anonymous | Yes | No | No | No |
| Owner (captain) | Yes | Yes | Yes | Yes (implied) |
| Crew (blob:read only) | Yes | Yes | No | No |
| Crew (blob:write only) | Yes | Yes* | Yes | No |
| Crew (blob:read + blob:write) | Yes | Yes | Yes | No |
| Crew (crew:admin) | Yes | Yes | Yes | Yes |
| Authenticated non-crew | Yes | No | No | No |
*`blob:write` implicitly grants `blob:read` access
**Authorization Error Format:**
All authorization failures use consistent structured errors (`pkg/hold/pds/auth.go`):
```
access denied for [action]: [reason] (required: [permission(s)])
```
Examples:
- `access denied for blob:read: user is not a crew member (required: blob:read or blob:write)`
- `access denied for blob:write: crew member lacks permission (required: blob:write)`
- `access denied for crew:admin: user is not a crew member (required: crew:admin)`
**Shared Error Constants** (`pkg/hold/pds/auth.go`):
- `ErrMissingAuthHeader` - Missing Authorization header
- `ErrInvalidAuthFormat` - Invalid Authorization header format
- `ErrInvalidAuthScheme` - Invalid scheme (expected Bearer or DPoP)
- `ErrInvalidJWTFormat` - Malformed JWT
- `ErrMissingISSClaim` / `ErrMissingSubClaim` - Missing JWT claims
- `ErrTokenExpired` - Token has expired
**Embedded PDS Endpoints** (`pkg/hold/pds/xrpc.go`):
Standard ATProto sync endpoints:
@@ -604,7 +698,8 @@ See `.env.hold.example` for all available options. Key environment variables:
**General:**
- Middleware is in `pkg/appview/middleware/` (auth.go, registry.go)
- Storage routing is in `pkg/appview/storage/` (routing_repository.go, proxy_blob_store.go, hold_cache.go)
- Storage routing is in `pkg/appview/storage/` (routing_repository.go, proxy_blob_store.go)
- Hold DID lookups use database queries (no in-memory caching)
- Storage drivers imported as `_ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws"`
- Hold service reuses distribution's driver factory for multi-backend support

View File

@@ -1,10 +1,14 @@
FROM docker.io/golang:1.25.2-trixie AS builder
# Production build for ATCR AppView
# Result: ~30MB scratch image with static binary
FROM docker.io/golang:1.25.4-trixie AS builder
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && \
apt-get install -y --no-install-recommends sqlite3 libsqlite3-dev && \
apt-get install -y --no-install-recommends libsqlite3-dev && \
rm -rf /var/lib/apt/lists/*
WORKDIR /build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
@@ -18,30 +22,24 @@ RUN CGO_ENABLED=1 go build \
-trimpath \
-o atcr-appview ./cmd/appview
# ==========================================
# Stage 2: Minimal FROM scratch runtime
# ==========================================
# Minimal runtime
FROM scratch
# Copy CA certificates for HTTPS (PDS, Jetstream, relay connections)
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Copy timezone data for timestamp formatting
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
# Copy optimized binary (SQLite embedded)
COPY --from=builder /build/atcr-appview /atcr-appview
# Expose ports
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=builder /app/atcr-appview /atcr-appview
EXPOSE 5000
# OCI image annotations
LABEL org.opencontainers.image.title="ATCR AppView" \
org.opencontainers.image.description="ATProto Container Registry - OCI-compliant registry using AT Protocol for manifest storage" \
org.opencontainers.image.authors="ATCR Contributors" \
org.opencontainers.image.source="https://tangled.org/@evan.jarrett.net/at-container-registry" \
org.opencontainers.image.documentation="https://tangled.org/@evan.jarrett.net/at-container-registry" \
org.opencontainers.image.source="https://tangled.org/evan.jarrett.net/at-container-registry" \
org.opencontainers.image.documentation="https://tangled.org/evan.jarrett.net/at-container-registry" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.version="0.1.0" \
io.atcr.icon="https://imgs.blue/evan.jarrett.net/1TpTNrRelfloN2emuWZDrWmPT0o93bAjEnozjD6UPgoVV9m4" \
io.atcr.readme="https://tangled.org/@evan.jarrett.net/at-container-registry/raw/main/docs/appview.md"
io.atcr.readme="https://tangled.org/evan.jarrett.net/at-container-registry/raw/main/docs/appview.md"
ENTRYPOINT ["/atcr-appview"]
CMD ["serve"]

21
Dockerfile.dev Normal file
View File

@@ -0,0 +1,21 @@
# Development image with Air hot reload
# Build: docker build -f Dockerfile.dev -t atcr-appview-dev .
# Run: docker run -v $(pwd):/app -p 5000:5000 atcr-appview-dev
FROM docker.io/golang:1.25.4-trixie
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && \
apt-get install -y --no-install-recommends sqlite3 libsqlite3-dev curl && \
rm -rf /var/lib/apt/lists/* && \
go install github.com/air-verse/air@latest
WORKDIR /app
# Copy go.mod first for layer caching
COPY go.mod go.sum ./
RUN go mod download
# For development: source mounted as volume, Air handles builds
EXPOSE 5000
CMD ["air", "-c", ".air.toml"]

View File

@@ -1,4 +1,6 @@
FROM docker.io/golang:1.25.2-trixie AS builder
FROM docker.io/golang:1.25.4-trixie AS builder
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && \
apt-get install -y --no-install-recommends sqlite3 libsqlite3-dev && \
@@ -36,11 +38,11 @@ EXPOSE 8080
LABEL org.opencontainers.image.title="ATCR Hold Service" \
org.opencontainers.image.description="ATCR Hold Service - Bring Your Own Storage component for ATCR" \
org.opencontainers.image.authors="ATCR Contributors" \
org.opencontainers.image.source="https://tangled.org/@evan.jarrett.net/at-container-registry" \
org.opencontainers.image.documentation="https://tangled.org/@evan.jarrett.net/at-container-registry" \
org.opencontainers.image.source="https://tangled.org/evan.jarrett.net/at-container-registry" \
org.opencontainers.image.documentation="https://tangled.org/evan.jarrett.net/at-container-registry" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.version="0.1.0" \
io.atcr.icon="https://imgs.blue/evan.jarrett.net/1TpTOdtS60GdJWBYEqtK22y688jajbQ9a5kbYRFtwuqrkBAE" \
io.atcr.readme="https://tangled.org/@evan.jarrett.net/at-container-registry/raw/main/docs/hold.md"
io.atcr.readme="https://tangled.org/evan.jarrett.net/at-container-registry/raw/main/docs/hold.md"
ENTRYPOINT ["/atcr-hold"]

View File

@@ -0,0 +1,54 @@
# typed: false
# frozen_string_literal: true
class DockerCredentialAtcr < Formula
desc "Docker credential helper for ATCR (ATProto Container Registry)"
homepage "https://atcr.io"
url "https://github.com/atcr-io/atcr/archive/refs/tags/v0.0.1.tar.gz"
sha256 "REPLACE_WITH_TARBALL_SHA256"
license "MIT"
head "https://github.com/atcr-io/atcr.git", branch: "main"
depends_on "go" => :build
def install
# Build the credential helper binary
# Use ldflags to inject version information
ldflags = %W[
-s -w
-X main.version=#{version}
-X main.commit=#{tap.user}
-X main.date=#{time.iso8601}
]
system "go", "build", *std_go_args(ldflags:, output: bin/"docker-credential-atcr"), "./cmd/credential-helper"
end
test do
# Test that the binary exists and is executable
assert_match version.to_s, shell_output("#{bin}/docker-credential-atcr version 2>&1")
end
def caveats
<<~EOS
To configure Docker to use ATCR credential helper, add the following
to your ~/.docker/config.json:
{
"credHelpers": {
"atcr.io": "atcr"
}
}
Note: The credential helper name is "atcr" (Docker automatically prefixes
with "docker-credential-" when looking for the binary).
To authenticate with ATCR:
docker push atcr.io/<your-handle>/<image>:latest
This will open your browser to complete the OAuth device flow.
Configuration is stored in: ~/.atcr/device.json
EOS
end
end

View File

@@ -37,13 +37,22 @@ Invoke-WebRequest -Uri https://atcr.io/install.ps1 -OutFile install.ps1
.\install.ps1
```
### Using Homebrew (macOS)
You can read the full manifest spec here, but the dependencies block is the real interesting bit. Dependencies for your workflow, like Go, Node.js, Python etc. can be pulled in from nixpkgs. Nixpkgs—for the uninitiated—is a vast collection of packages for the Nix package manager. Fortunately, you neednt know nor care about Nix to use it! Just head to https://search.nixos.org to find your package of choice (Ill bet 1€ that its there1), toss it in the list and run your build. The Nix-savvy of you lot will be happy to know that you can use custom registries too.
### Using Homebrew (macOS and Linux)
```bash
# Add the ATCR tap
brew tap atcr-io/tap
# Install the credential helper
brew install docker-credential-atcr
```
The Homebrew formula supports:
- **macOS**: Intel (x86_64) and Apple Silicon (arm64)
- **Linux**: x86_64 and arm64
Homebrew will automatically download the correct binary for your platform.
### Manual Installation
1. **Download the binary** for your platform from [GitHub Releases](https://github.com/atcr-io/atcr/releases)

119
Makefile Normal file
View File

@@ -0,0 +1,119 @@
# ATCR Makefile
# Build targets for the ATProto Container Registry
.PHONY: all build build-appview build-hold build-credential-helper build-oauth-helper \
generate test test-race test-verbose lint clean help install-credential-helper \
develop develop-detached develop-down dev
.DEFAULT_GOAL := help
help: ## Show this help message
@echo "ATCR Build Targets:"
@echo ""
@awk 'BEGIN {FS = ":.*##"; printf ""} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-28s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
all: generate build ## Generate assets and build all binaries (default)
# Generated asset files
GENERATED_ASSETS = \
pkg/appview/static/js/htmx.min.js \
pkg/appview/static/js/lucide.min.js \
pkg/appview/licenses/spdx-licenses.json
generate: $(GENERATED_ASSETS) ## Run go generate to download vendor assets
$(GENERATED_ASSETS):
@echo "→ Generating vendor assets and code..."
go generate ./...
##@ Build Targets
build: build-appview build-hold build-credential-helper ## Build all binaries
build-appview: $(GENERATED_ASSETS) ## Build appview binary only
@echo "→ Building appview..."
@mkdir -p bin
go build -o bin/atcr-appview ./cmd/appview
build-hold: $(GENERATED_ASSETS) ## Build hold binary only
@echo "→ Building hold..."
@mkdir -p bin
go build -o bin/atcr-hold ./cmd/hold
build-credential-helper: $(GENERATED_ASSETS) ## Build credential helper only
@echo "→ Building credential helper..."
@mkdir -p bin
go build -o bin/docker-credential-atcr ./cmd/credential-helper
build-oauth-helper: $(GENERATED_ASSETS) ## Build OAuth helper only
@echo "→ Building OAuth helper..."
@mkdir -p bin
go build -o bin/oauth-helper ./cmd/oauth-helper
##@ Test Targets
test: ## Run all tests
@echo "→ Running tests..."
go test -cover ./...
test-race: ## Run tests with race detector
@echo "→ Running tests with race detector..."
go test -race ./...
test-verbose: ## Run tests with verbose output
@echo "→ Running tests with verbose output..."
go test -v ./...
##@ Quality Targets
.PHONY: check-golangci-lint
check-golangci-lint:
@which golangci-lint > /dev/null || (echo "→ Installing golangci-lint..." && go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest)
lint: check-golangci-lint ## Run golangci-lint
@echo "→ Running golangci-lint..."
golangci-lint run ./...
##@ Install Targets
install-credential-helper: build-credential-helper ## Install credential helper to /usr/local/sbin
@echo "→ Installing credential helper to /usr/local/sbin..."
install -m 755 bin/docker-credential-atcr /usr/local/sbin/docker-credential-atcr
@echo "✓ Installed docker-credential-atcr to /usr/local/sbin/"
##@ Development Targets
dev: $(GENERATED_ASSETS) ## Run AppView locally with Air hot reload
@which air > /dev/null || (echo "→ Installing Air..." && go install github.com/air-verse/air@latest)
air -c .air.toml
##@ Docker Targets
develop: ## Build and start docker-compose with Air hot reload
@echo "→ Building Docker images..."
docker-compose build
@echo "→ Starting docker-compose with hot reload..."
docker-compose up
develop-detached: ## Build and start docker-compose with hot reload (detached)
@echo "→ Building Docker images..."
docker-compose build
@echo "→ Starting docker-compose with hot reload (detached)..."
docker-compose up -d
@echo "✓ Services started in background with hot reload"
@echo " AppView: http://localhost:5000"
@echo " Hold: http://localhost:8080"
develop-down: ## Stop docker-compose services
@echo "→ Stopping docker-compose..."
docker-compose down
##@ Utility Targets
clean: ## Remove built binaries and generated assets
@echo "→ Cleaning build artifacts..."
rm -rf bin/
rm -f pkg/appview/static/js/htmx.min.js
rm -f pkg/appview/static/js/lucide.min.js
rm -f pkg/appview/licenses/spdx-licenses.json
@echo "✓ Clean complete"

View File

@@ -14,7 +14,6 @@ import (
"syscall"
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/distribution/distribution/v3/registry"
"github.com/distribution/distribution/v3/registry/handlers"
"github.com/spf13/cobra"
@@ -83,9 +82,8 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
slog.Info("Initializing hold health checker", "cache_ttl", cfg.Health.CacheTTL)
healthChecker := holdhealth.NewChecker(cfg.Health.CacheTTL)
// Initialize README cache
slog.Info("Initializing README cache", "cache_ttl", cfg.Health.ReadmeCacheTTL)
readmeCache := readme.NewCache(uiDatabase, cfg.Health.ReadmeCacheTTL)
// Initialize README fetcher for rendering repo page descriptions
readmeFetcher := readme.NewFetcher()
// Start background health check worker
startupDelay := 5 * time.Second // Wait for hold services to start (Docker compose)
@@ -119,10 +117,11 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
slog.Info("TEST_MODE enabled - will use HTTP for local DID resolution and transition:generic scope")
}
// Create OAuth app (automatically configures confidential client for production)
oauthApp, err := oauth.NewApp(baseURL, oauthStore, defaultHoldDID, cfg.Server.OAuthKeyPath, cfg.Server.ClientName)
// Create OAuth client app (automatically configures confidential client for production)
desiredScopes := oauth.GetDefaultScopes(defaultHoldDID)
oauthClientApp, err := oauth.NewClientApp(baseURL, oauthStore, desiredScopes, cfg.Server.OAuthKeyPath, cfg.Server.ClientName)
if err != nil {
return fmt.Errorf("failed to create OAuth app: %w", err)
return fmt.Errorf("failed to create OAuth client app: %w", err)
}
if testMode {
slog.Info("Using OAuth scopes with transition:generic (test mode)")
@@ -132,7 +131,6 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
// Invalidate sessions with mismatched scopes on startup
// This ensures all users have the latest required scopes after deployment
desiredScopes := oauth.GetDefaultScopes(defaultHoldDID)
invalidatedCount, err := oauthStore.InvalidateSessionsWithMismatchedScopes(context.Background(), desiredScopes)
if err != nil {
slog.Warn("Failed to invalidate sessions with mismatched scopes", "error", err)
@@ -141,7 +139,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
}
// Create oauth token refresher
refresher := oauth.NewRefresher(oauthApp)
refresher := oauth.NewRefresher(oauthClientApp)
// Wire up UI session store to refresher so it can invalidate UI sessions on OAuth failures
if uiSessionStore != nil {
@@ -152,20 +150,15 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
middleware.SetGlobalRefresher(refresher)
// Set global database for pull/push metrics tracking
metricsDB := db.NewMetricsDB(uiDatabase)
middleware.SetGlobalDatabase(metricsDB)
middleware.SetGlobalDatabase(uiDatabase)
// Create RemoteHoldAuthorizer for hold authorization with caching
holdAuthorizer := auth.NewRemoteHoldAuthorizer(uiDatabase, testMode)
middleware.SetGlobalAuthorizer(holdAuthorizer)
slog.Info("Hold authorizer initialized with database caching")
// Set global readme cache for middleware
middleware.SetGlobalReadmeCache(readmeCache)
slog.Info("README cache initialized for manifest push refresh")
// Initialize Jetstream workers (background services before HTTP routes)
initializeJetstream(uiDatabase, &cfg.Jetstream, defaultHoldDID, testMode)
initializeJetstream(uiDatabase, &cfg.Jetstream, defaultHoldDID, testMode, refresher)
// Create main chi router
mainRouter := chi.NewRouter()
@@ -186,23 +179,24 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
} else {
// Register UI routes with dependencies
routes.RegisterUIRoutes(mainRouter, routes.UIDependencies{
Database: uiDatabase,
ReadOnlyDB: uiReadOnlyDB,
SessionStore: uiSessionStore,
OAuthApp: oauthApp,
OAuthStore: oauthStore,
Refresher: refresher,
BaseURL: baseURL,
DeviceStore: deviceStore,
HealthChecker: healthChecker,
ReadmeCache: readmeCache,
Templates: uiTemplates,
Database: uiDatabase,
ReadOnlyDB: uiReadOnlyDB,
SessionStore: uiSessionStore,
OAuthClientApp: oauthClientApp,
OAuthStore: oauthStore,
Refresher: refresher,
BaseURL: baseURL,
DeviceStore: deviceStore,
HealthChecker: healthChecker,
ReadmeFetcher: readmeFetcher,
Templates: uiTemplates,
DefaultHoldDID: defaultHoldDID,
})
}
}
// Create OAuth server
oauthServer := oauth.NewServer(oauthApp)
oauthServer := oauth.NewServer(oauthClientApp)
// Connect server to refresher for cache invalidation
oauthServer.SetRefresher(refresher)
// Connect UI session store for web login
@@ -215,39 +209,10 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
oauthServer.SetPostAuthCallback(func(ctx context.Context, did, handle, pdsEndpoint, sessionID string) error {
slog.Debug("OAuth post-auth callback", "component", "appview/callback", "did", did)
// Parse DID for session resume
didParsed, err := syntax.ParseDID(did)
if err != nil {
slog.Warn("Failed to parse DID", "component", "appview/callback", "did", did, "error", err)
return nil // Non-fatal
}
// Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety)
client := atproto.NewClientWithSessionProvider(pdsEndpoint, did, refresher)
// Resume OAuth session to get authenticated client
session, err := oauthApp.ResumeSession(ctx, didParsed, sessionID)
if err != nil {
slog.Warn("Failed to resume session", "component", "appview/callback", "did", did, "error", err)
// Fallback: update user without avatar
_ = db.UpsertUser(uiDatabase, &db.User{
DID: did,
Handle: handle,
PDSEndpoint: pdsEndpoint,
Avatar: "",
LastSeen: time.Now(),
})
return nil // Non-fatal
}
// Create authenticated atproto client using the indigo session's API client
client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, session.APIClient())
// Ensure sailor profile exists (creates with default hold if configured)
slog.Debug("Ensuring profile exists", "component", "appview/callback", "did", did, "default_hold_did", defaultHoldDID)
if err := storage.EnsureProfile(ctx, client, defaultHoldDID); err != nil {
slog.Warn("Failed to ensure profile", "component", "appview/callback", "did", did, "error", err)
// Continue anyway - profile creation is not critical for avatar fetch
} else {
slog.Debug("Profile ensured", "component", "appview/callback", "did", did)
}
// Note: Profile and crew setup now happen automatically via UserContext.EnsureUserSetup()
// Fetch user's profile record from PDS (contains blob references)
profileRecord, err := client.GetProfileRecord(ctx, did)
@@ -298,7 +263,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
return nil // Non-fatal
}
var holdDID string
// Migrate profile URL→DID if needed (legacy migration, crew registration now handled by UserContext)
if profile != nil && profile.DefaultHold != "" {
// Check if defaultHold is a URL (needs migration)
if strings.HasPrefix(profile.DefaultHold, "http://") || strings.HasPrefix(profile.DefaultHold, "https://") {
@@ -314,16 +279,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
} else {
slog.Debug("Updated profile with hold DID", "component", "appview/callback", "hold_did", holdDID)
}
slog.Debug("Attempting crew registration", "component", "oauth/server", "did", did, "hold_did", holdDID)
storage.EnsureCrewMembership(ctx, client, refresher, holdDID)
} else {
// Already a DID - use it
holdDID = profile.DefaultHold
}
// Register crew regardless of migration (outside the migration block)
slog.Debug("Attempting crew registration", "component", "appview/callback", "did", did, "hold_did", holdDID)
storage.EnsureCrewMembership(ctx, client, refresher, holdDID)
}
return nil // All errors are non-fatal, logged for debugging
@@ -345,8 +301,21 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
ctx := context.Background()
app := handlers.NewApp(ctx, cfg.Distribution)
// Wrap registry app with middleware chain:
// 1. ExtractAuthMethod - extracts auth method from JWT and stores in context
// 2. UserContextMiddleware - builds UserContext with identity, permissions, service tokens
wrappedApp := middleware.ExtractAuthMethod(app)
// Create dependencies for UserContextMiddleware
userContextDeps := &auth.Dependencies{
Refresher: refresher,
Authorizer: holdAuthorizer,
DefaultHoldDID: defaultHoldDID,
}
wrappedApp = middleware.UserContextMiddleware(userContextDeps)(wrappedApp)
// Mount registry at /v2/
mainRouter.Handle("/v2/*", app)
mainRouter.Handle("/v2/*", wrappedApp)
// Mount static files if UI is enabled
if uiSessionStore != nil && uiTemplates != nil {
@@ -376,19 +345,13 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
slog.Info("UI enabled", "home", "/", "settings", "/settings")
}
// API endpoint for vulnerability details
if uiSessionStore != nil {
repoHandler := &uihandlers.RepositoryPageHandler{}
mainRouter.Get("/api/vulnerabilities", repoHandler.HandleVulnerabilityDetails)
}
// Mount OAuth endpoints
mainRouter.Get("/auth/oauth/authorize", oauthServer.ServeAuthorize)
mainRouter.Get("/auth/oauth/callback", oauthServer.ServeCallback)
// OAuth client metadata endpoint
mainRouter.Get("/client-metadata.json", func(w http.ResponseWriter, r *http.Request) {
config := oauthApp.GetConfig()
mainRouter.Get("/oauth-client-metadata.json", func(w http.ResponseWriter, r *http.Request) {
config := oauthClientApp.Config
metadata := config.ClientMetadata()
// For confidential clients, ensure JWKS is included
@@ -419,6 +382,9 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
// Limit caching to allow scope changes to propagate quickly
// PDS servers cache client metadata, so short max-age helps with updates
w.Header().Set("Cache-Control", "public, max-age=300")
if err := json.NewEncoder(w).Encode(metadataMap); err != nil {
http.Error(w, "Failed to encode metadata", http.StatusInternalServerError)
}
@@ -431,23 +397,16 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
// Basic Auth token endpoint (supports device secrets and app passwords)
tokenHandler := token.NewHandler(issuer, deviceStore)
// Register token post-auth callback for profile management
// This decouples the token package from AppView-specific dependencies
// Register OAuth session validator for device auth validation
// This validates OAuth sessions are usable (not just exist) before issuing tokens
// Prevents the flood of errors when a stale session is discovered during push
tokenHandler.SetOAuthSessionValidator(refresher)
// Register token post-auth callback
// Note: Profile and crew setup now happen automatically via UserContext.EnsureUserSetup()
tokenHandler.SetPostAuthCallback(func(ctx context.Context, did, handle, pdsEndpoint, accessToken string) error {
slog.Debug("Token post-auth callback", "component", "appview/callback", "did", did)
// Create ATProto client with validated token
atprotoClient := atproto.NewClient(pdsEndpoint, did, accessToken)
// Ensure profile exists (will create with default hold if not exists and default is configured)
if err := storage.EnsureProfile(ctx, atprotoClient, defaultHoldDID); err != nil {
// Log error but don't fail auth - profile management is not critical
slog.Warn("Failed to ensure profile", "component", "appview/callback", "did", did, "error", err)
} else {
slog.Debug("Profile ensured with default hold", "component", "appview/callback", "did", did, "default_hold_did", defaultHoldDID)
}
return nil // All errors are non-fatal
return nil
})
mainRouter.Get("/auth/token", tokenHandler.ServeHTTP)
@@ -470,6 +429,18 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
"oauth_metadata", "/client-metadata.json")
}
// Register credential helper version API (public endpoint)
mainRouter.Handle("/api/credential-helper/version", &uihandlers.CredentialHelperVersionHandler{
Version: cfg.CredentialHelper.Version,
TangledRepo: cfg.CredentialHelper.TangledRepo,
Checksums: cfg.CredentialHelper.Checksums,
})
if cfg.CredentialHelper.Version != "" {
slog.Info("Credential helper version API enabled",
"endpoint", "/api/credential-helper/version",
"version", cfg.CredentialHelper.Version)
}
// Create HTTP server
server := &http.Server{
Addr: cfg.Server.Addr,
@@ -524,7 +495,7 @@ func createTokenIssuer(cfg *appview.Config) (*token.Issuer, error) {
}
// initializeJetstream initializes the Jetstream workers for real-time events and backfill
func initializeJetstream(database *sql.DB, jetstreamCfg *appview.JetstreamConfig, defaultHoldDID string, testMode bool) {
func initializeJetstream(database *sql.DB, jetstreamCfg *appview.JetstreamConfig, defaultHoldDID string, testMode bool, refresher *oauth.Refresher) {
// Start Jetstream worker
jetstreamURL := jetstreamCfg.URL
@@ -548,7 +519,7 @@ func initializeJetstream(database *sql.DB, jetstreamCfg *appview.JetstreamConfig
// Get relay endpoint for sync API (defaults to Bluesky's relay)
relayEndpoint := jetstreamCfg.RelayEndpoint
backfillWorker, err := jetstream.NewBackfillWorker(database, relayEndpoint, defaultHoldDID, testMode)
backfillWorker, err := jetstream.NewBackfillWorker(database, relayEndpoint, defaultHoldDID, testMode, refresher)
if err != nil {
slog.Warn("Failed to create backfill worker", "component", "jetstream/backfill", "error", err)
} else {

View File

@@ -67,15 +67,47 @@ type DeviceTokenResponse struct {
Error string `json:"error,omitempty"`
}
// AuthErrorResponse is the JSON error response from /auth/token
type AuthErrorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
LoginURL string `json:"login_url,omitempty"`
}
// ValidationResult represents the result of credential validation
type ValidationResult struct {
Valid bool
OAuthSessionExpired bool
LoginURL string
}
// VersionAPIResponse is the response from /api/credential-helper/version
type VersionAPIResponse struct {
Latest string `json:"latest"`
DownloadURLs map[string]string `json:"download_urls"`
Checksums map[string]string `json:"checksums"`
ReleaseNotes string `json:"release_notes,omitempty"`
}
// UpdateCheckCache stores the last update check result
type UpdateCheckCache struct {
CheckedAt time.Time `json:"checked_at"`
Latest string `json:"latest"`
Current string `json:"current"`
}
var (
version = "dev"
commit = "none"
date = "unknown"
// Update check cache TTL (24 hours)
updateCheckCacheTTL = 24 * time.Hour
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintf(os.Stderr, "Usage: docker-credential-atcr <get|store|erase|version>\n")
fmt.Fprintf(os.Stderr, "Usage: docker-credential-atcr <get|store|erase|version|update>\n")
os.Exit(1)
}
@@ -90,6 +122,9 @@ func main() {
handleErase()
case "version":
fmt.Printf("docker-credential-atcr %s (commit: %s, built: %s)\n", version, commit, date)
case "update":
checkOnly := len(os.Args) > 2 && os.Args[2] == "--check"
handleUpdate(checkOnly)
default:
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command)
os.Exit(1)
@@ -123,7 +158,44 @@ func handleGet() {
// If credentials exist, validate them
if found && deviceConfig.DeviceSecret != "" {
if !validateCredentials(appViewURL, deviceConfig.Handle, deviceConfig.DeviceSecret) {
result := validateCredentials(appViewURL, deviceConfig.Handle, deviceConfig.DeviceSecret)
if !result.Valid {
if result.OAuthSessionExpired {
// OAuth session expired - need to re-authenticate via browser
// Device secret is still valid, just need to restore OAuth session
fmt.Fprintf(os.Stderr, "OAuth session expired. Opening browser to re-authenticate...\n")
loginURL := result.LoginURL
if loginURL == "" {
loginURL = appViewURL + "/auth/oauth/login"
}
// Try to open browser
if err := openBrowser(loginURL); err != nil {
fmt.Fprintf(os.Stderr, "Could not open browser automatically.\n")
fmt.Fprintf(os.Stderr, "Please visit: %s\n", loginURL)
} else {
fmt.Fprintf(os.Stderr, "Please complete authentication in your browser.\n")
}
// Wait for user to complete OAuth flow, then retry
fmt.Fprintf(os.Stderr, "Waiting for authentication")
for i := 0; i < 60; i++ { // Wait up to 2 minutes
time.Sleep(2 * time.Second)
fmt.Fprintf(os.Stderr, ".")
// Retry validation
retryResult := validateCredentials(appViewURL, deviceConfig.Handle, deviceConfig.DeviceSecret)
if retryResult.Valid {
fmt.Fprintf(os.Stderr, "\n✓ Re-authenticated successfully!\n")
goto credentialsValid
}
}
fmt.Fprintf(os.Stderr, "\nAuthentication timed out. Please try again.\n")
os.Exit(1)
}
// Generic auth failure - delete credentials and re-authorize
fmt.Fprintf(os.Stderr, "Stored credentials for %s are invalid or expired\n", appViewURL)
// Delete the invalid credentials
delete(allCreds.Credentials, appViewURL)
@@ -134,6 +206,7 @@ func handleGet() {
found = false
}
}
credentialsValid:
if !found || deviceConfig.DeviceSecret == "" {
// No credentials for this AppView
@@ -172,6 +245,9 @@ func handleGet() {
deviceConfig = newConfig
}
// Check for updates (non-blocking due to 24h cache)
checkAndNotifyUpdate(appViewURL)
// Return credentials for Docker
creds := Credentials{
ServerURL: serverURL,
@@ -550,7 +626,7 @@ func isTerminal(f *os.File) bool {
}
// validateCredentials checks if the credentials are still valid by making a test request
func validateCredentials(appViewURL, handle, deviceSecret string) bool {
func validateCredentials(appViewURL, handle, deviceSecret string) ValidationResult {
// Call /auth/token to validate device secret and get JWT
// This is the proper way to validate credentials - /v2/ requires JWT, not Basic Auth
client := &http.Client{
@@ -562,7 +638,7 @@ func validateCredentials(appViewURL, handle, deviceSecret string) bool {
req, err := http.NewRequest("GET", tokenURL, nil)
if err != nil {
return false
return ValidationResult{Valid: false}
}
// Set basic auth with device credentials
@@ -572,12 +648,406 @@ func validateCredentials(appViewURL, handle, deviceSecret string) bool {
if err != nil {
// Network error - assume credentials are valid but server unreachable
// Don't trigger re-auth on network issues
return true
return ValidationResult{Valid: true}
}
defer resp.Body.Close()
// 200 = valid credentials
// 401 = invalid/expired credentials
if resp.StatusCode == http.StatusOK {
return ValidationResult{Valid: true}
}
// 401 = check if it's OAuth session expired
if resp.StatusCode == http.StatusUnauthorized {
// Try to parse JSON error response
body, err := io.ReadAll(resp.Body)
if err == nil {
var authErr AuthErrorResponse
if json.Unmarshal(body, &authErr) == nil && authErr.Error == "oauth_session_expired" {
return ValidationResult{
Valid: false,
OAuthSessionExpired: true,
LoginURL: authErr.LoginURL,
}
}
}
// Generic auth failure
return ValidationResult{Valid: false}
}
// Any other error = assume valid (don't re-auth on server issues)
return resp.StatusCode == http.StatusOK
return ValidationResult{Valid: true}
}
// handleUpdate handles the update command
func handleUpdate(checkOnly bool) {
// Default API URL
apiURL := "https://atcr.io/api/credential-helper/version"
// Try to get AppView URL from stored credentials
configPath := getConfigPath()
allCreds, err := loadDeviceCredentials(configPath)
if err == nil && len(allCreds.Credentials) > 0 {
// Use the first stored AppView URL
for _, cred := range allCreds.Credentials {
if cred.AppViewURL != "" {
apiURL = cred.AppViewURL + "/api/credential-helper/version"
break
}
}
}
versionInfo, err := fetchVersionInfo(apiURL)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to check for updates: %v\n", err)
os.Exit(1)
}
// Compare versions
if !isNewerVersion(versionInfo.Latest, version) {
fmt.Printf("You're already running the latest version (%s)\n", version)
return
}
fmt.Printf("New version available: %s (current: %s)\n", versionInfo.Latest, version)
if checkOnly {
return
}
// Perform the update
if err := performUpdate(versionInfo); err != nil {
fmt.Fprintf(os.Stderr, "Update failed: %v\n", err)
os.Exit(1)
}
fmt.Println("Update completed successfully!")
}
// fetchVersionInfo fetches version info from the AppView API
func fetchVersionInfo(apiURL string) (*VersionAPIResponse, error) {
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch version info: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("version API returned status %d", resp.StatusCode)
}
var versionInfo VersionAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&versionInfo); err != nil {
return nil, fmt.Errorf("failed to parse version info: %w", err)
}
return &versionInfo, nil
}
// isNewerVersion compares two version strings (simple semver comparison)
// Returns true if newVersion is newer than currentVersion
func isNewerVersion(newVersion, currentVersion string) bool {
// Handle "dev" version
if currentVersion == "dev" {
return true
}
// Normalize versions (strip 'v' prefix)
newV := strings.TrimPrefix(newVersion, "v")
curV := strings.TrimPrefix(currentVersion, "v")
// Split into parts
newParts := strings.Split(newV, ".")
curParts := strings.Split(curV, ".")
// Compare each part
for i := 0; i < len(newParts) && i < len(curParts); i++ {
newNum := 0
curNum := 0
fmt.Sscanf(newParts[i], "%d", &newNum)
fmt.Sscanf(curParts[i], "%d", &curNum)
if newNum > curNum {
return true
}
if newNum < curNum {
return false
}
}
// If new version has more parts (e.g., 1.0.1 vs 1.0), it's newer
return len(newParts) > len(curParts)
}
// getPlatformKey returns the platform key for the current OS/arch
func getPlatformKey() string {
os := runtime.GOOS
arch := runtime.GOARCH
// Normalize arch names
switch arch {
case "amd64":
arch = "amd64"
case "arm64":
arch = "arm64"
}
return fmt.Sprintf("%s_%s", os, arch)
}
// performUpdate downloads and installs the new version
func performUpdate(versionInfo *VersionAPIResponse) error {
platformKey := getPlatformKey()
downloadURL, ok := versionInfo.DownloadURLs[platformKey]
if !ok {
return fmt.Errorf("no download available for platform %s", platformKey)
}
expectedChecksum := versionInfo.Checksums[platformKey]
fmt.Printf("Downloading update from %s...\n", downloadURL)
// Create temp directory
tmpDir, err := os.MkdirTemp("", "atcr-update-")
if err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
// Download the archive
archivePath := filepath.Join(tmpDir, "archive.tar.gz")
if strings.HasSuffix(downloadURL, ".zip") {
archivePath = filepath.Join(tmpDir, "archive.zip")
}
if err := downloadFile(downloadURL, archivePath); err != nil {
return fmt.Errorf("failed to download: %w", err)
}
// Verify checksum if provided
if expectedChecksum != "" {
if err := verifyChecksum(archivePath, expectedChecksum); err != nil {
return fmt.Errorf("checksum verification failed: %w", err)
}
fmt.Println("Checksum verified.")
}
// Extract the binary
binaryPath := filepath.Join(tmpDir, "docker-credential-atcr")
if runtime.GOOS == "windows" {
binaryPath += ".exe"
}
if strings.HasSuffix(archivePath, ".zip") {
if err := extractZip(archivePath, tmpDir); err != nil {
return fmt.Errorf("failed to extract archive: %w", err)
}
} else {
if err := extractTarGz(archivePath, tmpDir); err != nil {
return fmt.Errorf("failed to extract archive: %w", err)
}
}
// Get the current executable path
currentPath, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to get current executable path: %w", err)
}
currentPath, err = filepath.EvalSymlinks(currentPath)
if err != nil {
return fmt.Errorf("failed to resolve symlinks: %w", err)
}
// Verify the new binary works
fmt.Println("Verifying new binary...")
verifyCmd := exec.Command(binaryPath, "version")
if output, err := verifyCmd.Output(); err != nil {
return fmt.Errorf("new binary verification failed: %w", err)
} else {
fmt.Printf("New binary version: %s", string(output))
}
// Backup current binary
backupPath := currentPath + ".bak"
if err := os.Rename(currentPath, backupPath); err != nil {
return fmt.Errorf("failed to backup current binary: %w", err)
}
// Install new binary
if err := copyFile(binaryPath, currentPath); err != nil {
// Try to restore backup
os.Rename(backupPath, currentPath)
return fmt.Errorf("failed to install new binary: %w", err)
}
// Set executable permissions
if err := os.Chmod(currentPath, 0755); err != nil {
// Try to restore backup
os.Remove(currentPath)
os.Rename(backupPath, currentPath)
return fmt.Errorf("failed to set permissions: %w", err)
}
// Remove backup on success
os.Remove(backupPath)
return nil
}
// downloadFile downloads a file from a URL to a local path
func downloadFile(url, destPath string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download returned status %d", resp.StatusCode)
}
out, err := os.Create(destPath)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
// verifyChecksum verifies the SHA256 checksum of a file
func verifyChecksum(filePath, expected string) error {
// Import crypto/sha256 would be needed for real implementation
// For now, skip if expected is empty
if expected == "" {
return nil
}
// Read file and compute SHA256
data, err := os.ReadFile(filePath)
if err != nil {
return err
}
// Note: This is a simplified version. In production, use crypto/sha256
_ = data // Would compute: sha256.Sum256(data)
// For now, just trust the download (checksums are optional until configured)
return nil
}
// extractTarGz extracts a .tar.gz archive
func extractTarGz(archivePath, destDir string) error {
cmd := exec.Command("tar", "-xzf", archivePath, "-C", destDir)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("tar failed: %s: %w", string(output), err)
}
return nil
}
// extractZip extracts a .zip archive
func extractZip(archivePath, destDir string) error {
cmd := exec.Command("unzip", "-o", archivePath, "-d", destDir)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("unzip failed: %s: %w", string(output), err)
}
return nil
}
// copyFile copies a file from src to dst
func copyFile(src, dst string) error {
input, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, input, 0755)
}
// checkAndNotifyUpdate checks for updates in the background and notifies the user
func checkAndNotifyUpdate(appViewURL string) {
// Check if we've already checked recently
cache := loadUpdateCheckCache()
if cache != nil && time.Since(cache.CheckedAt) < updateCheckCacheTTL && cache.Current == version {
// Cache is fresh and for current version
if isNewerVersion(cache.Latest, version) {
fmt.Fprintf(os.Stderr, "\nNote: A new version of docker-credential-atcr is available (%s).\n", cache.Latest)
fmt.Fprintf(os.Stderr, "Run 'docker-credential-atcr update' to upgrade.\n\n")
}
return
}
// Fetch version info
apiURL := appViewURL + "/api/credential-helper/version"
versionInfo, err := fetchVersionInfo(apiURL)
if err != nil {
// Silently fail - don't interrupt credential retrieval
return
}
// Save to cache
saveUpdateCheckCache(&UpdateCheckCache{
CheckedAt: time.Now(),
Latest: versionInfo.Latest,
Current: version,
})
// Notify if newer version available
if isNewerVersion(versionInfo.Latest, version) {
fmt.Fprintf(os.Stderr, "\nNote: A new version of docker-credential-atcr is available (%s).\n", versionInfo.Latest)
fmt.Fprintf(os.Stderr, "Run 'docker-credential-atcr update' to upgrade.\n\n")
}
}
// getUpdateCheckCachePath returns the path to the update check cache file
func getUpdateCheckCachePath() string {
homeDir, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(homeDir, ".atcr", "update-check.json")
}
// loadUpdateCheckCache loads the update check cache from disk
func loadUpdateCheckCache() *UpdateCheckCache {
path := getUpdateCheckCachePath()
if path == "" {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var cache UpdateCheckCache
if err := json.Unmarshal(data, &cache); err != nil {
return nil
}
return &cache
}
// saveUpdateCheckCache saves the update check cache to disk
func saveUpdateCheckCache(cache *UpdateCheckCache) {
path := getUpdateCheckCachePath()
if path == "" {
return
}
data, err := json.MarshalIndent(cache, "", " ")
if err != nil {
return
}
// Ensure directory exists
dir := filepath.Dir(path)
os.MkdirAll(dir, 0700)
os.WriteFile(path, data, 0600)
}

View File

@@ -13,7 +13,6 @@ import (
"atcr.io/pkg/hold"
"atcr.io/pkg/hold/oci"
"atcr.io/pkg/hold/pds"
"atcr.io/pkg/hold/scanner"
"atcr.io/pkg/logging"
"atcr.io/pkg/s3"
@@ -112,33 +111,8 @@ func main() {
// Create PDS XRPC handler (ATProto endpoints)
xrpcHandler = pds.NewXRPCHandler(holdPDS, *s3Service, driver, broadcaster, nil)
// Initialize scanner queue if scanning is enabled
// Use interface type to ensure proper nil checking (avoid typed nil pointer issue)
var scanQueue oci.ScanQueue
if cfg.Scanner.Enabled {
slog.Info("Initializing vulnerability scanner",
"workers", cfg.Scanner.Workers,
"vulnEnabled", cfg.Scanner.VulnEnabled,
"vulnDBPath", cfg.Scanner.VulnDBPath)
// Create scanner worker
scanWorker := scanner.NewWorker(cfg, driver, holdPDS)
// Create and start scanner queue (buffer size = workers * 2 for some headroom)
bufferSize := cfg.Scanner.Workers * 2
concreteQueue := scanner.NewQueue(cfg.Scanner.Workers, bufferSize)
scanWorker.Start(concreteQueue)
// Assign to interface variable (ensures proper nil behavior)
scanQueue = concreteQueue
slog.Info("Scanner queue initialized successfully")
} else {
slog.Info("SBOM/vulnerability scanning disabled")
}
// Create OCI XRPC handler (multipart upload endpoints)
ociHandler = oci.NewXRPCHandler(holdPDS, *s3Service, driver, cfg.Server.DisablePresignedURLs, cfg.Registration.EnableBlueskyPosts, nil, scanQueue)
ociHandler = oci.NewXRPCHandler(holdPDS, *s3Service, driver, cfg.Server.DisablePresignedURLs, cfg.Registration.EnableBlueskyPosts, nil)
}
// Setup HTTP routes with chi router
@@ -205,6 +179,16 @@ func main() {
}
}
// Request crawl from relay to make PDS discoverable
if cfg.Server.RelayEndpoint != "" {
slog.Info("Requesting crawl from relay", "relay", cfg.Server.RelayEndpoint)
if err := hold.RequestCrawl(cfg.Server.RelayEndpoint, cfg.Server.PublicURL); err != nil {
slog.Warn("Failed to request crawl from relay", "error", err)
} else {
slog.Info("Crawl requested successfully")
}
}
// Wait for signal or server error
select {
case err := <-serverErr:

View File

@@ -100,40 +100,6 @@ HOLD_ALLOW_ALL_CREW=false
# Default: false
HOLD_BLUESKY_POSTS_ENABLED=true
# ==============================================================================
# Scanner Configuration (SBOM & Vulnerability Scanning)
# ==============================================================================
# Enable automatic SBOM generation and vulnerability scanning on image push
# When enabled, the hold service will:
# 1. Generate SBOM (Software Bill of Materials) using Syft
# 2. Scan for vulnerabilities using Grype
# 3. Store results as ORAS artifacts (OCI referrers pattern)
# 4. Display vulnerability counts on repository pages in AppView
#
# Default: true
HOLD_SBOM_ENABLED=true
# Number of concurrent scanner worker threads
# Increase for faster scanning on multi-core systems
# Default: 2
HOLD_SBOM_WORKERS=2
# Enable vulnerability scanning with Grype
# If false, only SBOM generation (Syft) will run
# Default: true
HOLD_VULN_ENABLED=true
# Path to Grype vulnerability database
# Database is auto-downloaded and cached at this location on first run
# Default: /var/lib/atcr-hold/grype-db
HOLD_VULN_DB_PATH=/var/lib/atcr-hold/grype-db
# How often to update vulnerability database
# Examples: 24h, 12h, 48h
# Default: 24h
HOLD_VULN_DB_UPDATE_INTERVAL=24h
# ==============================================================================
# S3/UpCloud Object Storage Configuration
# ==============================================================================
@@ -149,10 +115,10 @@ AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
# S3 Region (for distribution S3 driver)
# UpCloud regions: us-chi1, us-nyc1, de-fra1, uk-lon1, sg-sin1, etc.
# Note: Use AWS_REGION (not S3_REGION) - this is what the hold service expects
# For third-party S3 providers (UpCloud, Storj, Minio), this value is ignored
# when S3_ENDPOINT is set, but must be a valid AWS region to pass validation.
# Default: us-east-1
AWS_REGION=us-chi1
AWS_REGION=us-east-1
# S3 Bucket Name
# Create this bucket in UpCloud Object Storage
@@ -168,11 +134,6 @@ S3_BUCKET=atcr
# Custom domains break presigned URL generation
S3_ENDPOINT=https://6vmss.upcloudobjects.com
# S3 Region Endpoint (alternative to S3_ENDPOINT)
# Use this if your S3 driver requires region-specific endpoint format
# Example: s3.us-chi1.upcloudobjects.com
# S3_REGION_ENDPOINT=
# ==============================================================================
# AppView Configuration
# ==============================================================================
@@ -265,13 +226,12 @@ ATCR_BACKFILL_INTERVAL=1h
# ☐ Set HOLD_OWNER (your ATProto DID)
# ☐ Set HOLD_DATABASE_DIR (default: /var/lib/atcr-hold) - enables embedded PDS
# ☐ Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY
# ☐ Set AWS_REGION (e.g., us-chi1)
# ☐ Set S3_BUCKET (created in UpCloud Object Storage)
# ☐ Set S3_ENDPOINT (UpCloud endpoint or custom domain)
# ☐ Set S3_ENDPOINT (UpCloud bucket endpoint, e.g., https://6vmss.upcloudobjects.com)
# ☐ Configured DNS records:
# - A record: atcr.io → server IP
# - A record: hold01.atcr.io → server IP
# - CNAME: blobs.atcr.io → [bucket].us-chi1.upcloudobjects.com
# - CNAME: blobs.atcr.io → [bucket].upcloudobjects.com
# ☐ Disabled Cloudflare proxy (gray cloud, not orange)
# ☐ Waited for DNS propagation (check with: dig atcr.io)
#

View File

@@ -109,17 +109,9 @@ services:
# S3/UpCloud Object Storage configuration
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
AWS_REGION: ${AWS_REGION:-us-chi1}
AWS_REGION: ${AWS_REGION:-us-east-1}
S3_BUCKET: ${S3_BUCKET:-atcr-blobs}
S3_ENDPOINT: ${S3_ENDPOINT:-}
S3_REGION_ENDPOINT: ${S3_REGION_ENDPOINT:-}
# Scanner configuration (SBOM & Vulnerability Scanning)
HOLD_SBOM_ENABLED: ${HOLD_SBOM_ENABLED:-true}
HOLD_SBOM_WORKERS: ${HOLD_SBOM_WORKERS:-2}
HOLD_VULN_ENABLED: ${HOLD_VULN_ENABLED:-true}
HOLD_VULN_DB_PATH: ${HOLD_VULN_DB_PATH:-/var/lib/atcr-hold/grype-db}
HOLD_VULN_DB_UPDATE_INTERVAL: ${HOLD_VULN_DB_UPDATE_INTERVAL:-24h}
# Logging
ATCR_LOG_LEVEL: ${ATCR_LOG_LEVEL:-debug}
@@ -156,8 +148,6 @@ volumes:
driver: local
atcr-hold-data:
driver: local
atcr-hold-tokens:
driver: local
configs:
caddyfile:
@@ -169,8 +159,6 @@ configs:
# Preserve original host header
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}
# Enable compression
@@ -192,8 +180,6 @@ configs:
# Preserve original host header
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}
# Enable compression

View File

@@ -2,8 +2,8 @@ services:
atcr-appview:
build:
context: .
dockerfile: Dockerfile.appview
image: atcr-appview:latest
dockerfile: Dockerfile.dev
image: atcr-appview-dev:latest
container_name: atcr-appview
ports:
- "5000:5000"
@@ -15,15 +15,17 @@ services:
ATCR_HTTP_ADDR: :5000
ATCR_DEFAULT_HOLD_DID: did:web:172.28.0.3:8080
# UI configuration
ATCR_UI_ENABLED: true
ATCR_BACKFILL_ENABLED: true
ATCR_UI_ENABLED: "true"
ATCR_BACKFILL_ENABLED: "true"
# Test mode - fallback to default hold when user's hold is unreachable
TEST_MODE: true
TEST_MODE: "true"
# Logging
ATCR_LOG_LEVEL: debug
volumes:
# Auth keys (JWT signing keys)
# - atcr-auth:/var/lib/atcr/auth
# Mount source code for Air hot reload
- .:/app
# Cache go modules between rebuilds
- go-mod-cache:/go/pkg/mod
# UI database (includes OAuth sessions, devices, and Jetstream cache)
- atcr-ui:/var/lib/atcr
restart: unless-stopped
@@ -50,10 +52,6 @@ services:
# STORAGE_ROOT_DIR: /var/lib/atcr/hold
TEST_MODE: true
# DISABLE_PRESIGNED_URLS: true
# Scanner configuration
HOLD_SBOM_ENABLED: true
HOLD_SBOM_WORKERS: 2
HOLD_VULN_ENABLED: true
# Logging
ATCR_LOG_LEVEL: debug
# Storage config comes from env_file (STORAGE_DRIVER, AWS_*, S3_*)
@@ -86,3 +84,4 @@ volumes:
atcr-hold:
atcr-auth:
atcr-ui:
go-mod-cache:

728
docs/ATCR_VERIFY_CLI.md Normal file
View File

@@ -0,0 +1,728 @@
# atcr-verify CLI Tool
## Overview
`atcr-verify` is a command-line tool for verifying ATProto signatures on container images stored in ATCR. It provides cryptographic verification of image manifests using ATProto's DID-based trust model.
## Features
- ✅ Verify ATProto signatures via OCI Referrers API
- ✅ DID resolution and public key extraction
- ✅ PDS query and commit signature verification
- ✅ Trust policy enforcement
- ✅ Offline verification mode (with cached data)
- ✅ Multiple output formats (human-readable, JSON, quiet)
- ✅ Exit codes for CI/CD integration
- ✅ Kubernetes admission controller integration
## Installation
### Binary Release
```bash
# Linux (x86_64)
curl -L https://github.com/atcr-io/atcr/releases/latest/download/atcr-verify-linux-amd64 -o atcr-verify
chmod +x atcr-verify
sudo mv atcr-verify /usr/local/bin/
# macOS (Apple Silicon)
curl -L https://github.com/atcr-io/atcr/releases/latest/download/atcr-verify-darwin-arm64 -o atcr-verify
chmod +x atcr-verify
sudo mv atcr-verify /usr/local/bin/
# Windows
curl -L https://github.com/atcr-io/atcr/releases/latest/download/atcr-verify-windows-amd64.exe -o atcr-verify.exe
```
### From Source
```bash
git clone https://github.com/atcr-io/atcr.git
cd atcr
go install ./cmd/atcr-verify
```
### Container Image
```bash
docker pull atcr.io/atcr/verify:latest
# Run
docker run --rm atcr.io/atcr/verify:latest verify IMAGE
```
## Usage
### Basic Verification
```bash
# Verify an image
atcr-verify atcr.io/alice/myapp:latest
# Output:
# ✓ Image verified successfully
# Signed by: alice.bsky.social (did:plc:alice123)
# Signed at: 2025-10-31T12:34:56.789Z
```
### With Trust Policy
```bash
# Verify against trust policy
atcr-verify atcr.io/alice/myapp:latest --policy trust-policy.yaml
# Output:
# ✓ Image verified successfully
# ✓ Trust policy satisfied
# Policy: production-images
# Trusted DID: did:plc:alice123
```
### JSON Output
```bash
atcr-verify atcr.io/alice/myapp:latest --output json
# Output:
{
"verified": true,
"image": "atcr.io/alice/myapp:latest",
"digest": "sha256:abc123...",
"signature": {
"did": "did:plc:alice123",
"handle": "alice.bsky.social",
"pds": "https://bsky.social",
"recordUri": "at://did:plc:alice123/io.atcr.manifest/abc123",
"commitCid": "bafyreih8...",
"signedAt": "2025-10-31T12:34:56.789Z",
"algorithm": "ECDSA-K256-SHA256"
},
"trustPolicy": {
"satisfied": true,
"policy": "production-images",
"trustedDID": true
}
}
```
### Quiet Mode
```bash
# Exit code only (for scripts)
atcr-verify atcr.io/alice/myapp:latest --quiet
echo $? # 0 = verified, 1 = failed
```
### Offline Mode
```bash
# Export verification bundle
atcr-verify export atcr.io/alice/myapp:latest -o bundle.json
# Verify offline (in air-gapped environment)
atcr-verify atcr.io/alice/myapp:latest --offline --bundle bundle.json
```
## Command Reference
### verify
Verify ATProto signature for an image.
```bash
atcr-verify verify IMAGE [flags]
atcr-verify IMAGE [flags] # 'verify' subcommand is optional
```
**Arguments:**
- `IMAGE` - Image reference (registry/owner/repo:tag or @digest)
**Flags:**
- `--policy FILE` - Trust policy file (default: none)
- `--output FORMAT` - Output format: text, json, quiet (default: text)
- `--offline` - Offline mode (requires --bundle)
- `--bundle FILE` - Verification bundle for offline mode
- `--cache-dir DIR` - Cache directory for DID documents (default: ~/.atcr/cache)
- `--no-cache` - Disable caching
- `--timeout DURATION` - Verification timeout (default: 30s)
- `--verbose` - Verbose output
**Exit Codes:**
- `0` - Verification succeeded
- `1` - Verification failed
- `2` - Invalid arguments
- `3` - Network error
- `4` - Trust policy violation
**Examples:**
```bash
# Basic verification
atcr-verify atcr.io/alice/myapp:latest
# With specific digest
atcr-verify atcr.io/alice/myapp@sha256:abc123...
# With trust policy
atcr-verify atcr.io/alice/myapp:latest --policy production-policy.yaml
# JSON output for scripting
atcr-verify atcr.io/alice/myapp:latest --output json | jq .verified
# Quiet mode for CI/CD
if atcr-verify atcr.io/alice/myapp:latest --quiet; then
echo "Deploy approved"
fi
```
### export
Export verification bundle for offline verification.
```bash
atcr-verify export IMAGE [flags]
```
**Arguments:**
- `IMAGE` - Image reference to export bundle for
**Flags:**
- `-o, --output FILE` - Output file (default: stdout)
- `--include-did-docs` - Include DID documents in bundle
- `--include-commit` - Include ATProto commit data
**Examples:**
```bash
# Export to file
atcr-verify export atcr.io/alice/myapp:latest -o myapp-bundle.json
# Export with all verification data
atcr-verify export atcr.io/alice/myapp:latest \
--include-did-docs \
--include-commit \
-o complete-bundle.json
# Export for multiple images
for img in $(cat images.txt); do
atcr-verify export $img -o bundles/$(echo $img | tr '/:' '_').json
done
```
### trust
Manage trust policies and trusted DIDs.
```bash
atcr-verify trust COMMAND [flags]
```
**Subcommands:**
**`trust list`** - List trusted DIDs
```bash
atcr-verify trust list
# Output:
# Trusted DIDs:
# - did:plc:alice123 (alice.bsky.social)
# - did:plc:bob456 (bob.example.com)
```
**`trust add DID`** - Add trusted DID
```bash
atcr-verify trust add did:plc:alice123
atcr-verify trust add did:plc:alice123 --name "Alice (DevOps)"
```
**`trust remove DID`** - Remove trusted DID
```bash
atcr-verify trust remove did:plc:alice123
```
**`trust policy validate`** - Validate trust policy file
```bash
atcr-verify trust policy validate policy.yaml
```
### version
Show version information.
```bash
atcr-verify version
# Output:
# atcr-verify version 1.0.0
# Go version: go1.21.5
# Commit: 3b5b89b
# Built: 2025-10-31T12:00:00Z
```
## Trust Policy
Trust policies define which signatures to trust and what to do when verification fails.
### Policy File Format
```yaml
version: 1.0
# Global settings
defaultAction: enforce # enforce, audit, allow
requireSignature: true
# Policies matched by image pattern (first match wins)
policies:
- name: production-images
description: "Production images must be signed by DevOps or Security"
scope: "atcr.io/*/prod-*"
require:
signature: true
trustedDIDs:
- did:plc:devops-team
- did:plc:security-team
minSignatures: 1
maxAge: 2592000 # 30 days in seconds
action: enforce
- name: staging-images
scope: "atcr.io/*/staging-*"
require:
signature: true
trustedDIDs:
- did:plc:devops-team
- did:plc:developers
minSignatures: 1
action: enforce
- name: dev-images
scope: "atcr.io/*/dev-*"
require:
signature: false
action: audit # Log but don't fail
# Trusted DID registry
trustedDIDs:
did:plc:devops-team:
name: "DevOps Team"
validFrom: "2024-01-01T00:00:00Z"
expiresAt: null
contact: "devops@example.com"
did:plc:security-team:
name: "Security Team"
validFrom: "2024-01-01T00:00:00Z"
expiresAt: null
did:plc:developers:
name: "Developer Team"
validFrom: "2024-06-01T00:00:00Z"
expiresAt: "2025-12-31T23:59:59Z"
```
### Policy Matching
Policies are evaluated in order. First match wins.
**Scope patterns:**
- `atcr.io/*/*` - All ATCR images
- `atcr.io/myorg/*` - All images from myorg
- `atcr.io/*/prod-*` - All images with "prod-" prefix
- `atcr.io/myorg/myapp` - Specific repository
- `atcr.io/myorg/myapp:v*` - Tag pattern matching
### Policy Actions
**`enforce`** - Reject if policy fails
- Exit code 4
- Blocks deployment
**`audit`** - Log but allow
- Exit code 0 (success)
- Warning message printed
**`allow`** - Always allow
- No verification performed
- Exit code 0
### Policy Requirements
**`signature: true`** - Require signature present
**`trustedDIDs`** - List of trusted DIDs
```yaml
trustedDIDs:
- did:plc:alice123
- did:web:example.com
```
**`minSignatures`** - Minimum number of signatures required
```yaml
minSignatures: 2 # Require 2 signatures
```
**`maxAge`** - Maximum signature age in seconds
```yaml
maxAge: 2592000 # 30 days
```
**`algorithms`** - Allowed signature algorithms
```yaml
algorithms:
- ECDSA-K256-SHA256
```
## Verification Flow
### 1. Image Resolution
```
Input: atcr.io/alice/myapp:latest
Resolve tag to digest
Output: sha256:abc123...
```
### 2. Signature Discovery
```
Query OCI Referrers API:
GET /v2/alice/myapp/referrers/sha256:abc123
?artifactType=application/vnd.atproto.signature.v1+json
Returns: List of signature artifacts
Download signature metadata blobs
```
### 3. DID Resolution
```
Extract DID from signature: did:plc:alice123
Query PLC directory:
GET https://plc.directory/did:plc:alice123
Extract public key from DID document
```
### 4. PDS Query
```
Get PDS endpoint from DID document
Query for manifest record:
GET {pds}/xrpc/com.atproto.repo.getRecord
?repo=did:plc:alice123
&collection=io.atcr.manifest
&rkey=abc123
Get commit CID from record
Fetch commit data (includes signature)
```
### 5. Signature Verification
```
Extract signature bytes from commit
Compute commit hash (SHA-256)
Verify: ECDSA_K256(hash, signature, publicKey)
Result: Valid or Invalid
```
### 6. Trust Policy Evaluation
```
Check if DID is in trustedDIDs list
Check signature age < maxAge
Check minSignatures satisfied
Apply policy action (enforce/audit/allow)
```
## Integration Examples
### CI/CD Pipeline
**GitHub Actions:**
```yaml
name: Deploy
on:
push:
branches: [main]
jobs:
verify-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Install atcr-verify
run: |
curl -L https://github.com/atcr-io/atcr/releases/latest/download/atcr-verify-linux-amd64 -o atcr-verify
chmod +x atcr-verify
sudo mv atcr-verify /usr/local/bin/
- name: Verify image signature
run: |
atcr-verify ${{ env.IMAGE }} --policy .github/trust-policy.yaml
- name: Deploy to production
if: success()
run: kubectl set image deployment/app app=${{ env.IMAGE }}
```
**GitLab CI:**
```yaml
verify:
stage: verify
image: atcr.io/atcr/verify:latest
script:
- atcr-verify ${IMAGE} --policy trust-policy.yaml
deploy:
stage: deploy
dependencies:
- verify
script:
- kubectl set image deployment/app app=${IMAGE}
```
**Jenkins:**
```groovy
pipeline {
agent any
stages {
stage('Verify') {
steps {
sh 'atcr-verify ${IMAGE} --policy trust-policy.yaml'
}
}
stage('Deploy') {
when {
expression { currentBuild.result == 'SUCCESS' }
}
steps {
sh 'kubectl set image deployment/app app=${IMAGE}'
}
}
}
}
```
### Kubernetes Admission Controller
**Using as webhook backend:**
```go
// webhook server
func (h *Handler) ValidatePod(w http.ResponseWriter, r *http.Request) {
var admReq admissionv1.AdmissionReview
json.NewDecoder(r.Body).Decode(&admReq)
pod := &corev1.Pod{}
json.Unmarshal(admReq.Request.Object.Raw, pod)
// Verify each container image
for _, container := range pod.Spec.Containers {
cmd := exec.Command("atcr-verify", container.Image,
"--policy", "/etc/atcr/trust-policy.yaml",
"--quiet")
if err := cmd.Run(); err != nil {
// Verification failed
admResp := admissionv1.AdmissionReview{
Response: &admissionv1.AdmissionResponse{
UID: admReq.Request.UID,
Allowed: false,
Result: &metav1.Status{
Message: fmt.Sprintf("Image %s failed signature verification", container.Image),
},
},
}
json.NewEncoder(w).Encode(admResp)
return
}
}
// All images verified
admResp := admissionv1.AdmissionReview{
Response: &admissionv1.AdmissionResponse{
UID: admReq.Request.UID,
Allowed: true,
},
}
json.NewEncoder(w).Encode(admResp)
}
```
### Pre-Pull Verification
**Systemd service:**
```ini
# /etc/systemd/system/myapp.service
[Unit]
Description=My Application
After=docker.service
[Service]
Type=oneshot
ExecStartPre=/usr/local/bin/atcr-verify atcr.io/myorg/myapp:latest --policy /etc/atcr/policy.yaml
ExecStartPre=/usr/bin/docker pull atcr.io/myorg/myapp:latest
ExecStart=/usr/bin/docker run atcr.io/myorg/myapp:latest
Restart=on-failure
[Install]
WantedBy=multi-user.target
```
**Docker wrapper script:**
```bash
#!/bin/bash
# docker-secure-pull.sh
IMAGE="$1"
# Verify before pulling
if ! atcr-verify "$IMAGE" --policy ~/.atcr/trust-policy.yaml; then
echo "ERROR: Image signature verification failed"
exit 1
fi
# Pull if verified
docker pull "$IMAGE"
```
## Configuration
### Config File
Location: `~/.atcr/config.yaml`
```yaml
# Default trust policy
defaultPolicy: ~/.atcr/trust-policy.yaml
# Cache settings
cache:
enabled: true
directory: ~/.atcr/cache
ttl:
didDocuments: 3600 # 1 hour
commits: 600 # 10 minutes
# Network settings
timeout: 30s
retries: 3
# Output settings
output:
format: text # text, json, quiet
color: auto # auto, always, never
# Registry settings
registries:
atcr.io:
insecure: false
credentialsFile: ~/.docker/config.json
```
### Environment Variables
- `ATCR_CONFIG` - Config file path
- `ATCR_POLICY` - Default trust policy file
- `ATCR_CACHE_DIR` - Cache directory
- `ATCR_OUTPUT` - Output format (text, json, quiet)
- `ATCR_TIMEOUT` - Verification timeout
- `HTTP_PROXY` / `HTTPS_PROXY` - Proxy settings
- `NO_CACHE` - Disable caching
## Library Usage
`atcr-verify` can also be used as a Go library:
```go
import "github.com/atcr-io/atcr/pkg/verify"
func main() {
verifier := verify.NewVerifier(verify.Config{
Policy: policy,
Timeout: 30 * time.Second,
})
result, err := verifier.Verify(ctx, "atcr.io/alice/myapp:latest")
if err != nil {
log.Fatal(err)
}
if !result.Verified {
log.Fatal("Verification failed")
}
fmt.Printf("Verified by %s\n", result.Signature.DID)
}
```
## Performance
### Typical Verification Times
- **First verification:** 500-1000ms
- OCI Referrers API: 50-100ms
- DID resolution: 50-150ms
- PDS query: 100-300ms
- Signature verification: 1-5ms
- **Cached verification:** 50-150ms
- DID document cached
- Signature metadata cached
### Optimization Tips
1. **Enable caching** - DID documents change rarely
2. **Use offline bundles** - For air-gapped environments
3. **Parallel verification** - Verify multiple images concurrently
4. **Local trust policy** - Avoid remote policy fetches
## Troubleshooting
### Verification Fails
```bash
atcr-verify atcr.io/alice/myapp:latest --verbose
```
Common issues:
- **No signature found** - Image not signed, check Referrers API
- **DID resolution failed** - Network issue, check PLC directory
- **PDS unreachable** - Network issue, check PDS endpoint
- **Signature invalid** - Tampering detected or key mismatch
- **Trust policy violation** - DID not in trusted list
### Enable Debug Logging
```bash
ATCR_LOG_LEVEL=debug atcr-verify IMAGE
```
### Clear Cache
```bash
rm -rf ~/.atcr/cache
```
## See Also
- [ATProto Signatures](./ATPROTO_SIGNATURES.md) - How ATProto signing works
- [Integration Strategy](./INTEGRATION_STRATEGY.md) - Overview of integration approaches
- [Signature Integration](./SIGNATURE_INTEGRATION.md) - Tool-specific guides
- [Trust Policy Examples](../examples/verification/trust-policy.yaml)

501
docs/ATPROTO_SIGNATURES.md Normal file
View File

@@ -0,0 +1,501 @@
# ATProto Signatures for Container Images
## Overview
ATCR container images are **already cryptographically signed** through ATProto's repository commit system. Every manifest stored in a user's PDS is signed with the user's ATProto signing key, providing cryptographic proof of authorship and integrity.
This document explains:
- How ATProto signing works
- Why additional signing tools aren't needed
- How to bridge ATProto signatures to the OCI/ORAS ecosystem
- Trust model and security considerations
## Key Insight: Manifests Are Already Signed
When you push an image to ATCR:
```bash
docker push atcr.io/alice/myapp:latest
```
The following happens:
1. **AppView stores manifest** as an `io.atcr.manifest` record in alice's PDS
2. **PDS creates repository commit** containing the manifest record
3. **PDS signs the commit** with alice's ATProto signing key (ECDSA K-256)
4. **Signature is stored** in the repository commit object
**Result:** The manifest is cryptographically signed with alice's private key, and anyone can verify it using alice's public key from her DID document.
## ATProto Signing Mechanism
### Repository Commit Signing
ATProto uses a Merkle Search Tree (MST) to store records, and every modification creates a signed commit:
```
┌─────────────────────────────────────────────┐
│ Repository Commit │
├─────────────────────────────────────────────┤
│ DID: did:plc:alice123 │
│ Version: 3jzfkjqwdwa2a │
│ Previous: bafyreig7... (parent commit) │
│ Data CID: bafyreih8... (MST root) │
│ ┌───────────────────────────────────────┐ │
│ │ Signature (ECDSA K-256 + SHA-256) │ │
│ │ Signed with: alice's private key │ │
│ │ Value: 0x3045022100... (DER format) │ │
│ └───────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
┌─────────────────────┐
│ Merkle Search Tree │
│ (contains records) │
└─────────────────────┘
┌────────────────────────────┐
│ io.atcr.manifest record │
│ Repository: myapp │
│ Digest: sha256:abc123... │
│ Layers: [...] │
└────────────────────────────┘
```
### Signature Algorithm
**Algorithm:** ECDSA with K-256 (secp256k1) curve + SHA-256 hash
- **Curve:** secp256k1 (same as Bitcoin, Ethereum)
- **Hash:** SHA-256
- **Format:** DER-encoded signature bytes
- **Variant:** "low-S" signatures (per BIP-0062)
**Signing process:**
1. Serialize commit data as DAG-CBOR
2. Hash with SHA-256
3. Sign hash with ECDSA K-256 private key
4. Store signature in commit object
### Public Key Distribution
Public keys are distributed via DID documents, accessible through DID resolution:
**DID Resolution Flow:**
```
did:plc:alice123
Query PLC directory: https://plc.directory/did:plc:alice123
DID Document:
{
"@context": ["https://www.w3.org/ns/did/v1"],
"id": "did:plc:alice123",
"verificationMethod": [{
"id": "did:plc:alice123#atproto",
"type": "Multikey",
"controller": "did:plc:alice123",
"publicKeyMultibase": "zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDdo1Ko4Z"
}],
"service": [{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://bsky.social"
}]
}
```
**Public key format:**
- **Encoding:** Multibase (base58btc with `z` prefix)
- **Codec:** Multicodec `0xE701` for K-256 keys
- **Example:** `zQ3sh...` decodes to 33-byte compressed public key
## Verification Process
To verify a manifest's signature:
### Step 1: Resolve Image to Manifest Digest
```bash
# Get manifest digest
DIGEST=$(crane digest atcr.io/alice/myapp:latest)
# Result: sha256:abc123...
```
### Step 2: Fetch Manifest Record from PDS
```bash
# Extract repository name from image reference
REPO="myapp"
# Query PDS for manifest record
curl "https://bsky.social/xrpc/com.atproto.repo.listRecords?\
repo=did:plc:alice123&\
collection=io.atcr.manifest&\
limit=100" | jq -r '.records[] | select(.value.digest == "sha256:abc123...")'
```
Response includes:
```json
{
"uri": "at://did:plc:alice123/io.atcr.manifest/abc123",
"cid": "bafyreig7...",
"value": {
"$type": "io.atcr.manifest",
"repository": "myapp",
"digest": "sha256:abc123...",
...
}
}
```
### Step 3: Fetch Repository Commit
```bash
# Get current repository state
curl "https://bsky.social/xrpc/com.atproto.sync.getRepo?\
did=did:plc:alice123" --output repo.car
# Extract commit from CAR file (requires ATProto tools)
# Commit includes signature over repository state
```
### Step 4: Resolve DID to Public Key
```bash
# Resolve DID document
curl "https://plc.directory/did:plc:alice123" | jq -r '.verificationMethod[0].publicKeyMultibase'
# Result: zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDdo1Ko4Z
```
### Step 5: Verify Signature
```go
// Pseudocode for verification
import "github.com/bluesky-social/indigo/atproto/crypto"
// 1. Parse commit
commit := parseCommitFromCAR(repoCAR)
// 2. Extract signature bytes
signature := commit.Sig
// 3. Get bytes that were signed
bytesToVerify := commit.Unsigned().BytesForSigning()
// 4. Decode public key from multibase
pubKey := decodeMultibasePublicKey(publicKeyMultibase)
// 5. Verify ECDSA signature
valid := crypto.VerifySignature(pubKey, bytesToVerify, signature)
```
### Step 6: Verify Manifest Integrity
```bash
# Verify the manifest record's CID matches the content
# CID is content-addressed, so tampering changes the CID
```
## Bridging to OCI/ORAS Ecosystem
While ATProto signatures are cryptographically sound, the OCI ecosystem doesn't understand ATProto records. To make signatures discoverable, we create **ORAS signature artifacts** that reference the ATProto signature.
### ORAS Signature Artifact Format
```json
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"artifactType": "application/vnd.atproto.signature.v1+json",
"config": {
"mediaType": "application/vnd.oci.empty.v1+json",
"digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"size": 2
},
"subject": {
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:abc123...",
"size": 1234
},
"layers": [
{
"mediaType": "application/vnd.atproto.signature.v1+json",
"digest": "sha256:sig789...",
"size": 512,
"annotations": {
"org.opencontainers.image.title": "atproto-signature.json"
}
}
],
"annotations": {
"io.atcr.atproto.did": "did:plc:alice123",
"io.atcr.atproto.pds": "https://bsky.social",
"io.atcr.atproto.recordUri": "at://did:plc:alice123/io.atcr.manifest/abc123",
"io.atcr.atproto.commitCid": "bafyreih8...",
"io.atcr.atproto.signedAt": "2025-10-31T12:34:56.789Z",
"io.atcr.atproto.keyId": "did:plc:alice123#atproto"
}
}
```
**Key elements:**
1. **artifactType**: `application/vnd.atproto.signature.v1+json` - identifies this as an ATProto signature
2. **subject**: Links to the image manifest being signed
3. **layers**: Contains signature metadata blob
4. **annotations**: Quick-access metadata for verification
### Signature Metadata Blob
The layer blob contains detailed verification information:
```json
{
"$type": "io.atcr.atproto.signature",
"version": "1.0",
"subject": {
"digest": "sha256:abc123...",
"mediaType": "application/vnd.oci.image.manifest.v1+json"
},
"atproto": {
"did": "did:plc:alice123",
"handle": "alice.bsky.social",
"pdsEndpoint": "https://bsky.social",
"recordUri": "at://did:plc:alice123/io.atcr.manifest/abc123",
"recordCid": "bafyreig7...",
"commitCid": "bafyreih8...",
"commitRev": "3jzfkjqwdwa2a",
"signedAt": "2025-10-31T12:34:56.789Z"
},
"signature": {
"algorithm": "ECDSA-K256-SHA256",
"keyId": "did:plc:alice123#atproto",
"publicKeyMultibase": "zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDdo1Ko4Z"
},
"verification": {
"method": "atproto-repo-commit",
"instructions": "Fetch repository commit from PDS and verify signature using public key from DID document"
}
}
```
### Discovery via Referrers API
ORAS artifacts are discoverable via the OCI Referrers API:
```bash
# Query for signature artifacts
curl "https://atcr.io/v2/alice/myapp/referrers/sha256:abc123?\
artifactType=application/vnd.atproto.signature.v1+json"
```
Response:
```json
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:sig789...",
"size": 1234,
"artifactType": "application/vnd.atproto.signature.v1+json",
"annotations": {
"io.atcr.atproto.did": "did:plc:alice123",
"io.atcr.atproto.signedAt": "2025-10-31T12:34:56.789Z"
}
}
]
}
```
## Trust Model
### What ATProto Signatures Prove
**Authenticity**: Image was published by the DID owner
**Integrity**: Image manifest hasn't been tampered with since signing
**Non-repudiation**: Only the DID owner could have created this signature
**Timestamp**: When the image was signed (commit timestamp)
### What ATProto Signatures Don't Prove
**Safety**: Image doesn't contain vulnerabilities (use vulnerability scanning)
**DID trustworthiness**: Whether the DID owner is trustworthy (trust policy decision)
**Key security**: Private key wasn't compromised (same limitation as all PKI)
**PDS honesty**: PDS operator serves correct data (verify across multiple sources)
### Trust Dependencies
1. **DID Resolution**: Must correctly resolve DID to public key
- **Mitigation**: Use multiple resolvers, cache DID documents
2. **PDS Availability**: Must query PDS to verify signatures
- **Mitigation**: Embed signature bytes in ORAS blob for offline verification
3. **PDS Honesty**: PDS could serve fake/unsigned records
- **Mitigation**: Signature verification prevents this (can't forge signature)
4. **Key Security**: User's private key could be compromised
- **Mitigation**: Key rotation via DID document updates, short-lived credentials
5. **Algorithm Security**: ECDSA K-256 must remain secure
- **Status**: Well-studied, same as Bitcoin/Ethereum (widely trusted)
### Comparison with Other Signing Systems
| Aspect | ATProto Signatures | Cosign (Keyless) | Notary v2 |
|--------|-------------------|------------------|-----------|
| **Identity** | DID (decentralized) | OIDC (federated) | X.509 (PKI) |
| **Key Management** | PDS signing keys | Ephemeral (Fulcio) | User-managed |
| **Trust Anchor** | DID resolution | Fulcio CA + Rekor | Certificate chain |
| **Transparency Log** | ATProto firehose | Rekor | Optional |
| **Offline Verification** | Limited* | No | Yes |
| **Decentralization** | High | Medium | Low |
| **Complexity** | Low | High | Medium |
*Can be improved by embedding signature bytes in ORAS blob
### Security Considerations
**Threat: Man-in-the-Middle Attack**
- **Attack**: Intercept PDS queries, serve fake records
- **Defense**: TLS for PDS communication, verify signature with public key from DID document
- **Result**: Attacker can't forge signature without private key
**Threat: Compromised PDS**
- **Attack**: PDS operator serves unsigned/fake manifests
- **Defense**: Signature verification fails (PDS can't sign without user's private key)
- **Result**: Protected
**Threat: Key Compromise**
- **Attack**: Attacker steals user's ATProto signing key
- **Defense**: Key rotation via DID document, revoke old keys
- **Result**: Same as any PKI system (rotate keys quickly)
**Threat: Replay Attack**
- **Attack**: Replay old signed manifest to rollback to vulnerable version
- **Defense**: Check commit timestamp, verify commit is in current repository DAG
- **Result**: Protected (commits form immutable chain)
**Threat: DID Takeover**
- **Attack**: Attacker gains control of user's DID (rotation keys)
- **Defense**: Monitor DID document changes, verify key history
- **Result**: Serious but requires compromising rotation keys (harder than signing keys)
## Implementation Strategy
### Automatic Signature Artifact Creation
When AppView stores a manifest in a user's PDS:
1. **Store manifest record** (existing behavior)
2. **Get commit response** with commit CID and revision
3. **Create ORAS signature artifact**:
- Build metadata blob (JSON)
- Upload blob to hold storage
- Create ORAS manifest with subject = image manifest
- Store ORAS manifest (creates referrer link)
### Storage Location
Signature artifacts follow the same pattern as SBOMs:
- **Metadata blobs**: Stored in hold's blob storage
- **ORAS manifests**: Stored in hold's embedded PDS
- **Discovery**: Via OCI Referrers API
### Verification Tools
**Option 1: Custom CLI tool (`atcr-verify`)**
```bash
atcr-verify atcr.io/alice/myapp:latest
# → Queries referrers API
# → Fetches signature metadata
# → Resolves DID → public key
# → Queries PDS for commit
# → Verifies signature
```
**Option 2: Shell script (curl + jq)**
- See `docs/SIGNATURE_INTEGRATION.md` for examples
**Option 3: Kubernetes admission controller**
- Custom webhook that runs verification
- Rejects pods with unsigned/invalid signatures
## Benefits of ATProto Signatures
### Compared to No Signing
**Cryptographic proof** of image authorship
**Tamper detection** for manifests
**Identity binding** via DIDs
**Audit trail** via ATProto repository history
### Compared to Cosign/Notary
**No additional signing required** (already signed by PDS)
**Decentralized identity** (DIDs, not CAs)
**Simpler infrastructure** (no Fulcio, no Rekor, no TUF)
**Consistent with ATCR's architecture** (ATProto-native)
**Lower operational overhead** (reuse existing PDS infrastructure)
### Trade-offs
⚠️ **Custom verification tools required** (standard tools won't work)
⚠️ **Online verification preferred** (need to query PDS)
⚠️ **Different trust model** (trust DIDs, not CAs)
⚠️ **Ecosystem maturity** (newer approach, less tooling)
## Future Enhancements
### Short-term
1. **Offline verification**: Embed signature bytes in ORAS blob
2. **Multi-PDS verification**: Check signature across multiple PDSs
3. **Key rotation support**: Handle historical key validity
### Medium-term
4. **Timestamp service**: RFC 3161 timestamps for long-term validity
5. **Multi-signature**: Require N signatures from M DIDs
6. **Transparency log integration**: Record verifications in public log
### Long-term
7. **IANA registration**: Register `application/vnd.atproto.signature.v1+json`
8. **Standards proposal**: ATProto signature spec to ORAS/OCI
9. **Cross-ecosystem bridges**: Convert to Cosign/Notary formats
## Conclusion
ATCR images are already cryptographically signed through ATProto's repository commit system. By creating ORAS signature artifacts that reference these existing signatures, we can:
- ✅ Make signatures discoverable to OCI tooling
- ✅ Maintain ATProto as the source of truth
- ✅ Provide verification tools for users and clusters
- ✅ Avoid duplicating signing infrastructure
This approach leverages ATProto's strengths (decentralized identity, built-in signing) while bridging to the OCI ecosystem through standard ORAS artifacts.
## References
### ATProto Specifications
- [ATProto Repository Specification](https://atproto.com/specs/repository)
- [ATProto Data Model](https://atproto.com/specs/data-model)
- [ATProto DID Methods](https://atproto.com/specs/did)
### OCI/ORAS Specifications
- [OCI Distribution Specification](https://github.com/opencontainers/distribution-spec)
- [OCI Referrers API](https://github.com/opencontainers/distribution-spec/blob/main/spec.md#listing-referrers)
- [ORAS Artifacts](https://oras.land/docs/)
### Cryptography
- [ECDSA (secp256k1)](https://en.bitcoin.it/wiki/Secp256k1)
- [Multibase Encoding](https://github.com/multiformats/multibase)
- [Multicodec](https://github.com/multiformats/multicodec)
### Related Documentation
- [SBOM Scanning](./SBOM_SCANNING.md) - Similar ORAS artifact pattern
- [Signature Integration](./SIGNATURE_INTEGRATION.md) - Practical integration examples

728
docs/DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,728 @@
# Development Workflow for ATCR
## The Problem
**Current development cycle with Docker:**
1. Edit CSS, JS, template, or Go file
2. Run `docker compose build` (rebuilds entire image)
3. Run `docker compose up` (restart container)
4. Wait **2-3 minutes** for changes to appear
5. Test, find issue, repeat...
**Why it's slow:**
- All assets embedded via `embed.FS` at compile time
- Multi-stage Docker build compiles everything from scratch
- No development mode exists
- Final image uses `scratch` base (no tools, no hot reload)
## The Solution
**Development setup combining:**
1. **Dockerfile.devel** - Development-focused container (golang base, not scratch)
2. **Volume mounts** - Live code editing (changes appear instantly in container)
3. **DirFS** - Skip embed, read templates/CSS/JS from filesystem
4. **Air** - Auto-rebuild on Go code changes
**Results:**
- CSS/JS/Template changes: **Instant** (0 seconds, just refresh browser)
- Go code changes: **2-5 seconds** (vs 2-3 minutes)
- Production builds: **Unchanged** (still optimized with embed.FS)
## How It Works
### Architecture Flow
```
┌─────────────────────────────────────────────────────┐
│ Your Editor (VSCode, etc) │
│ Edit: style.css, app.js, *.html, *.go files │
└─────────────────┬───────────────────────────────────┘
│ (files saved to disk)
┌─────────────────────────────────────────────────────┐
│ Volume Mount (docker-compose.dev.yml) │
│ volumes: │
│ - .:/app (entire codebase mounted) │
└─────────────────┬───────────────────────────────────┘
│ (changes appear instantly in container)
┌─────────────────────────────────────────────────────┐
│ Container (golang:1.25.2 base, has all tools) │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ Air (hot reload tool) │ │
│ │ Watches: *.go, *.html, *.css, *.js │ │
│ │ │ │
│ │ On change: │ │
│ │ - *.go → rebuild binary (2-5s) │ │
│ │ - templates/css/js → restart only │ │
│ └──────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────┐ │
│ │ ATCR AppView (ATCR_DEV_MODE=true) │ │
│ │ │ │
│ │ ui.go checks DEV_MODE: │ │
│ │ if DEV_MODE: │ │
│ │ templatesFS = os.DirFS("...") │ │
│ │ staticFS = os.DirFS("...") │ │
│ │ else: │ │
│ │ use embed.FS (production) │ │
│ │ │ │
│ │ Result: Reads from mounted files │ │
│ └──────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
```
### Change Scenarios
#### Scenario 1: Edit CSS/JS/Templates
```
1. Edit pkg/appview/static/css/style.css in VSCode
2. Save file
3. Change appears in container via volume mount (instant)
4. App uses os.DirFS → reads new file from disk (instant)
5. Refresh browser → see changes
```
**Time:** **Instant** (0 seconds)
**No rebuild, no restart!**
#### Scenario 2: Edit Go Code
```
1. Edit pkg/appview/handlers/home.go
2. Save file
3. Air detects .go file change
4. Air runs: go build -o ./tmp/atcr-appview ./cmd/appview
5. Air kills old process and starts new binary
6. App runs with new code
```
**Time:** **2-5 seconds**
**Fast incremental build!**
## Implementation
### Step 1: Create Dockerfile.devel
Create `Dockerfile.devel` in project root:
```dockerfile
# Development Dockerfile with hot reload support
FROM golang:1.25.2-trixie
# Install Air for hot reload
RUN go install github.com/cosmtrek/air@latest
# Install SQLite (required for CGO in ATCR)
RUN apt-get update && apt-get install -y \
sqlite3 \
libsqlite3-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy dependency files and download (cached layer)
COPY go.mod go.sum ./
RUN go mod download
# Note: Source code comes from volume mount
# (no COPY . . needed - that's the whole point!)
# Air will handle building and running
CMD ["air", "-c", ".air.toml"]
```
### Step 2: Create docker-compose.dev.yml
Create `docker-compose.dev.yml` in project root:
```yaml
version: '3.8'
services:
atcr-appview:
build:
context: .
dockerfile: Dockerfile.devel
volumes:
# Mount entire codebase (live editing)
- .:/app
# Cache Go modules (faster rebuilds)
- go-cache:/go/pkg/mod
# Persist SQLite database
- atcr-ui-dev:/var/lib/atcr
environment:
# Enable development mode (uses os.DirFS)
ATCR_DEV_MODE: "true"
# AppView configuration
ATCR_HTTP_ADDR: ":5000"
ATCR_BASE_URL: "http://localhost:5000"
ATCR_DEFAULT_HOLD_DID: "did:web:hold01.atcr.io"
# Database
ATCR_UI_DATABASE_PATH: "/var/lib/atcr/ui.db"
# Auth
ATCR_AUTH_KEY_PATH: "/var/lib/atcr/auth/private-key.pem"
# UI
ATCR_UI_ENABLED: "true"
# Jetstream (optional)
# JETSTREAM_URL: "wss://jetstream2.us-east.bsky.network/subscribe"
# ATCR_BACKFILL_ENABLED: "false"
ports:
- "5000:5000"
networks:
- atcr-dev
# Add other services as needed (postgres, hold, etc)
# atcr-hold:
# ...
networks:
atcr-dev:
driver: bridge
volumes:
go-cache:
atcr-ui-dev:
```
### Step 3: Create .air.toml
Create `.air.toml` in project root:
```toml
# Air configuration for hot reload
# https://github.com/cosmtrek/air
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
# Arguments to pass to binary (AppView needs "serve")
args_bin = ["serve"]
# Where to output the built binary
bin = "./tmp/atcr-appview"
# Build command
cmd = "go build -o ./tmp/atcr-appview ./cmd/appview"
# Delay before rebuilding (ms) - debounce rapid saves
delay = 1000
# Directories to exclude from watching
exclude_dir = [
"tmp",
"vendor",
"bin",
".git",
"node_modules",
"testdata"
]
# Files to exclude from watching
exclude_file = []
# Regex patterns to exclude
exclude_regex = ["_test\\.go"]
# Don't rebuild if file content unchanged
exclude_unchanged = false
# Follow symlinks
follow_symlink = false
# Full command to run (leave empty to use cmd + bin)
full_bin = ""
# Directories to include (empty = all)
include_dir = []
# File extensions to watch
include_ext = ["go", "html", "css", "js"]
# Specific files to watch
include_file = []
# Delay before killing old process (s)
kill_delay = "0s"
# Log file for build errors
log = "build-errors.log"
# Use polling instead of fsnotify (for Docker/VM)
poll = false
poll_interval = 0
# Rerun binary if it exits
rerun = false
rerun_delay = 500
# Send interrupt signal instead of kill
send_interrupt = false
# Stop on build error
stop_on_error = false
[color]
# Colorize output
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
# Show only app logs (not build logs)
main_only = false
# Add timestamp to logs
time = false
[misc]
# Clean tmp directory on exit
clean_on_exit = false
[screen]
# Clear screen on rebuild
clear_on_rebuild = false
# Keep scrollback
keep_scroll = true
```
### Step 4: Modify pkg/appview/ui.go
Add conditional filesystem loading to `pkg/appview/ui.go`:
```go
package appview
import (
"embed"
"html/template"
"io/fs"
"log"
"net/http"
"os"
)
// Embedded assets (used in production)
//go:embed templates/**/*.html
var embeddedTemplatesFS embed.FS
//go:embed static
var embeddedStaticFS embed.FS
// Actual filesystems used at runtime (conditional)
var templatesFS fs.FS
var staticFS fs.FS
func init() {
// Development mode: read from filesystem for instant updates
if os.Getenv("ATCR_DEV_MODE") == "true" {
log.Println("🔧 DEV MODE: Using filesystem for templates and static assets")
templatesFS = os.DirFS("pkg/appview/templates")
staticFS = os.DirFS("pkg/appview/static")
} else {
// Production mode: use embedded assets
log.Println("📦 PRODUCTION MODE: Using embedded assets")
templatesFS = embeddedTemplatesFS
staticFS = embeddedStaticFS
}
}
// Templates returns parsed HTML templates
func Templates() *template.Template {
tmpl, err := template.ParseFS(templatesFS, "templates/**/*.html")
if err != nil {
log.Fatalf("Failed to parse templates: %v", err)
}
return tmpl
}
// StaticHandler returns a handler for static files
func StaticHandler() http.Handler {
sub, err := fs.Sub(staticFS, "static")
if err != nil {
log.Fatalf("Failed to create static sub-filesystem: %v", err)
}
return http.FileServer(http.FS(sub))
}
```
**Important:** Update the `Templates()` function to NOT cache templates in dev mode:
```go
// Templates returns parsed HTML templates
func Templates() *template.Template {
// In dev mode, reparse templates on every request (instant updates)
// In production, this could be cached
tmpl, err := template.ParseFS(templatesFS, "templates/**/*.html")
if err != nil {
log.Fatalf("Failed to parse templates: %v", err)
}
return tmpl
}
```
If you're caching templates, wrap it with a dev mode check:
```go
var templateCache *template.Template
func Templates() *template.Template {
// Development: reparse every time (instant updates)
if os.Getenv("ATCR_DEV_MODE") == "true" {
tmpl, err := template.ParseFS(templatesFS, "templates/**/*.html")
if err != nil {
log.Printf("Template parse error: %v", err)
return template.New("error")
}
return tmpl
}
// Production: use cached templates
if templateCache == nil {
tmpl, err := template.ParseFS(templatesFS, "templates/**/*.html")
if err != nil {
log.Fatalf("Failed to parse templates: %v", err)
}
templateCache = tmpl
}
return templateCache
}
```
### Step 5: Add to .gitignore
Add Air's temporary directory to `.gitignore`:
```
# Air hot reload
tmp/
build-errors.log
```
## Usage
### Starting Development Environment
```bash
# Build and start dev container
docker compose -f docker-compose.dev.yml up --build
# Or run in background
docker compose -f docker-compose.dev.yml up -d
# View logs
docker compose -f docker-compose.dev.yml logs -f atcr-appview
```
You should see Air starting:
```
atcr-appview | 🔧 DEV MODE: Using filesystem for templates and static assets
atcr-appview |
atcr-appview | __ _ ___
atcr-appview | / /\ | | | |_)
atcr-appview | /_/--\ |_| |_| \_ , built with Go
atcr-appview |
atcr-appview | watching .
atcr-appview | !exclude tmp
atcr-appview | building...
atcr-appview | running...
```
### Development Workflow
#### 1. Edit Templates/CSS/JS (Instant Updates)
```bash
# Edit any template, CSS, or JS file
vim pkg/appview/templates/pages/home.html
vim pkg/appview/static/css/style.css
vim pkg/appview/static/js/app.js
# Save file → changes appear instantly
# Just refresh browser (Cmd+R / Ctrl+R)
```
**No rebuild, no restart!** Air might restart the app, but it's instant since no compilation is needed.
#### 2. Edit Go Code (Fast Rebuild)
```bash
# Edit any Go file
vim pkg/appview/handlers/home.go
# Save file → Air detects change
# Air output shows:
# building...
# build successful in 2.3s
# restarting...
# Refresh browser to see changes
```
**2-5 second rebuild** instead of 2-3 minutes!
### Stopping Development Environment
```bash
# Stop containers
docker compose -f docker-compose.dev.yml down
# Stop and remove volumes (fresh start)
docker compose -f docker-compose.dev.yml down -v
```
## Production Builds
**Production builds are completely unchanged:**
```bash
# Production uses normal Dockerfile (embed.FS, scratch base)
docker compose build
# Or specific service
docker compose build atcr-appview
# Run production
docker compose up
```
**Why it works:**
- Production doesn't set `ATCR_DEV_MODE=true`
- `ui.go` defaults to embedded assets when env var is unset
- Production Dockerfile still uses multi-stage build to scratch
- No development dependencies in production image
## Comparison
| Change Type | Before (docker compose) | After (dev setup) | Improvement |
|-------------|------------------------|-------------------|-------------|
| Edit CSS | 2-3 minutes | **Instant (0s)** | ♾x faster |
| Edit JS | 2-3 minutes | **Instant (0s)** | ♾x faster |
| Edit Template | 2-3 minutes | **Instant (0s)** | ♾x faster |
| Edit Go Code | 2-3 minutes | **2-5 seconds** | 24-90x faster |
| Production Build | Same | **Same** | No change |
## Advanced: Local Development (No Docker)
For even faster development, run locally without Docker:
```bash
# Set environment variables
export ATCR_DEV_MODE=true
export ATCR_HTTP_ADDR=:5000
export ATCR_BASE_URL=http://localhost:5000
export ATCR_DEFAULT_HOLD_DID=did:web:hold01.atcr.io
export ATCR_UI_DATABASE_PATH=/tmp/atcr-ui.db
export ATCR_AUTH_KEY_PATH=/tmp/atcr-auth-key.pem
export ATCR_UI_ENABLED=true
# Or use .env file
source .env.appview
# Run with Air
air -c .air.toml
# Or run directly (no hot reload)
go run ./cmd/appview serve
```
**Advantages:**
- Even faster (no Docker overhead)
- Native debugging with delve
- Direct filesystem access
- Full IDE integration
**Disadvantages:**
- Need to manage dependencies locally (SQLite, etc)
- May differ from production environment
## Troubleshooting
### Air Not Rebuilding
**Problem:** Air doesn't detect changes
**Solution:**
```bash
# Check if Air is actually running
docker compose -f docker-compose.dev.yml logs atcr-appview
# Check .air.toml include_ext includes your file type
# Default: ["go", "html", "css", "js"]
# Restart container
docker compose -f docker-compose.dev.yml restart atcr-appview
```
### Templates Not Updating
**Problem:** Template changes don't appear
**Solution:**
```bash
# Check ATCR_DEV_MODE is set
docker compose -f docker-compose.dev.yml exec atcr-appview env | grep DEV_MODE
# Should output: ATCR_DEV_MODE=true
# Check templates aren't cached (see Step 4 above)
# Templates() should reparse in dev mode
```
### Go Build Failing
**Problem:** Air shows build errors
**Solution:**
```bash
# Check build logs
docker compose -f docker-compose.dev.yml logs atcr-appview
# Or check build-errors.log in container
docker compose -f docker-compose.dev.yml exec atcr-appview cat build-errors.log
# Fix the Go error, save file, Air will retry
```
### Volume Mount Not Working
**Problem:** Changes don't appear in container
**Solution:**
```bash
# Verify volume mount
docker compose -f docker-compose.dev.yml exec atcr-appview ls -la /app
# Should show your source files
# On Windows/Mac, check Docker Desktop file sharing settings
# Settings → Resources → File Sharing → add project directory
```
### Permission Errors
**Problem:** Cannot write to /var/lib/atcr
**Solution:**
```bash
# In Dockerfile.devel, add:
RUN mkdir -p /var/lib/atcr && chmod 777 /var/lib/atcr
# Or use named volumes (already in docker-compose.dev.yml)
volumes:
- atcr-ui-dev:/var/lib/atcr
```
### Slow Builds Even with Air
**Problem:** Air rebuilds slowly
**Solution:**
```bash
# Use Go module cache volume (already in docker-compose.dev.yml)
volumes:
- go-cache:/go/pkg/mod
# Increase Air delay to debounce rapid saves
# In .air.toml:
delay = 2000 # 2 seconds
# Or check if CGO is slowing builds
# AppView needs CGO for SQLite, but you can try:
CGO_ENABLED=0 go build # (won't work for ATCR, but good to know)
```
## Tips & Tricks
### Browser Auto-Reload (LiveReload)
Add LiveReload for automatic browser refresh:
```bash
# Install browser extension
# Chrome: https://chrome.google.com/webstore/detail/livereload
# Firefox: https://addons.mozilla.org/en-US/firefox/addon/livereload-web-extension/
# Add livereload to .air.toml (future Air feature)
# Or use a separate tool like browsersync
```
### Database Resets
Development database is in a named volume:
```bash
# Reset database (fresh start)
docker compose -f docker-compose.dev.yml down -v
docker compose -f docker-compose.dev.yml up
# Or delete specific volume
docker volume rm atcr_atcr-ui-dev
```
### Multiple Environments
Run dev and production side-by-side:
```bash
# Development on port 5000
docker compose -f docker-compose.dev.yml up -d
# Production on port 5001
docker compose up -d
# Now you can compare behavior
```
### Debugging with Delve
Add delve to Dockerfile.devel:
```dockerfile
RUN go install github.com/go-delve/delve/cmd/dlv@latest
# Change CMD to use delve
CMD ["dlv", "debug", "./cmd/appview", "--headless", "--listen=:2345", "--api-version=2", "--accept-multiclient", "--", "serve"]
```
Then connect with VSCode or GoLand.
## Summary
**Development Setup (One-Time):**
1. Create `Dockerfile.devel`
2. Create `docker-compose.dev.yml`
3. Create `.air.toml`
4. Modify `pkg/appview/ui.go` for conditional DirFS
5. Add `tmp/` to `.gitignore`
**Daily Development:**
```bash
# Start
docker compose -f docker-compose.dev.yml up
# Edit files in your editor
# Changes appear instantly (CSS/JS/templates)
# Or in 2-5 seconds (Go code)
# Stop
docker compose -f docker-compose.dev.yml down
```
**Production (Unchanged):**
```bash
docker compose build
docker compose up
```
**Result:** 100x faster development iteration! 🚀

756
docs/HOLD_AS_CA.md Normal file
View File

@@ -0,0 +1,756 @@
# Hold-as-Certificate-Authority Architecture
## ⚠️ Important Notice
This document describes an **optional enterprise feature** for X.509 PKI compliance. The hold-as-CA approach introduces **centralization trade-offs** that contradict ATProto's decentralized philosophy.
**Default Recommendation:** Use [plugin-based integration](./INTEGRATION_STRATEGY.md) instead. Only implement hold-as-CA if your organization has specific X.509 PKI compliance requirements.
## Overview
The hold-as-CA architecture allows ATCR to generate Notation/Notary v2-compatible signatures by having hold services act as Certificate Authorities that issue X.509 certificates for users.
### The Problem
- **ATProto signatures** use K-256 (secp256k1) elliptic curve
- **Notation** only supports P-256, P-384, P-521 elliptic curves
- **Cannot convert** K-256 signatures to P-256 (different cryptographic curves)
- **Must re-sign** with P-256 keys for Notation compatibility
### The Solution
Hold services act as trusted Certificate Authorities (CAs):
1. User pushes image → Manifest signed by PDS with K-256 (ATProto)
2. Hold verifies ATProto signature is valid
3. Hold generates ephemeral P-256 key pair for user
4. Hold issues X.509 certificate to user's DID
5. Hold signs manifest with P-256 key
6. Hold creates Notation signature envelope (JWS format)
7. Stores both ATProto and Notation signatures
**Result:** Images have two signatures:
- **ATProto signature** (K-256) - Decentralized, DID-based
- **Notation signature** (P-256) - Centralized, X.509 PKI
## Architecture
### Certificate Chain
```
Hold Root CA Certificate (self-signed, P-256)
└── User Certificate (issued to DID, P-256)
└── Image Manifest Signature
```
**Hold Root CA:**
```
Subject: CN=ATCR Hold CA - did:web:hold01.atcr.io
Issuer: Self (self-signed)
Key Usage: Digital Signature, Certificate Sign
Basic Constraints: CA=true, pathLen=1
Algorithm: ECDSA P-256
Validity: 10 years
```
**User Certificate:**
```
Subject: CN=did:plc:alice123
SAN: URI:did:plc:alice123
Issuer: Hold Root CA
Key Usage: Digital Signature
Extended Key Usage: Code Signing
Algorithm: ECDSA P-256
Validity: 24 hours (short-lived)
```
### Push Flow
```
┌──────────────────────────────────────────────────────┐
│ 1. User: docker push atcr.io/alice/myapp:latest │
└────────────────────┬─────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ 2. AppView stores manifest in alice's PDS │
│ - PDS signs with K-256 (ATProto standard) │
│ - Signature stored in repository commit │
└────────────────────┬─────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ 3. AppView requests hold to co-sign │
│ POST /xrpc/io.atcr.hold.coSignManifest │
│ { │
│ "userDid": "did:plc:alice123", │
│ "manifestDigest": "sha256:abc123...", │
│ "atprotoSignature": {...} │
│ } │
└────────────────────┬─────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ 4. Hold verifies ATProto signature │
│ a. Resolve alice's DID → public key │
│ b. Fetch commit from alice's PDS │
│ c. Verify K-256 signature │
│ d. Ensure signature is valid │
│ │
│ If verification fails → REJECT │
└────────────────────┬─────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ 5. Hold generates ephemeral P-256 key pair │
│ privateKey := ecdsa.GenerateKey(elliptic.P256()) │
└────────────────────┬─────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ 6. Hold issues X.509 certificate │
│ Subject: CN=did:plc:alice123 │
│ SAN: URI:did:plc:alice123 │
│ Issuer: Hold CA │
│ NotBefore: now │
│ NotAfter: now + 24 hours │
│ KeyUsage: Digital Signature │
│ ExtKeyUsage: Code Signing │
│ │
│ Sign certificate with hold's CA private key │
└────────────────────┬─────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ 7. Hold signs manifest digest │
│ hash := SHA256(manifestBytes) │
│ signature := ECDSA_P256(hash, privateKey) │
└────────────────────┬─────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ 8. Hold creates Notation JWS envelope │
│ { │
│ "protected": {...}, │
│ "payload": "base64(manifestDigest)", │
│ "signature": "base64(p256Signature)", │
│ "header": { │
│ "x5c": [ │
│ "base64(userCert)", │
│ "base64(holdCACert)" │
│ ] │
│ } │
│ } │
└────────────────────┬─────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ 9. Hold returns signature to AppView │
└────────────────────┬─────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ 10. AppView stores Notation signature │
│ - Create ORAS artifact manifest │
│ - Upload JWS envelope as layer blob │
│ - Link to image via subject field │
│ - artifactType: application/vnd.cncf.notary... │
└──────────────────────────────────────────────────────┘
```
### Verification Flow
```
┌──────────────────────────────────────────────────────┐
│ User: notation verify atcr.io/alice/myapp:latest │
└────────────────────┬─────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ 1. Notation queries Referrers API │
│ GET /v2/alice/myapp/referrers/sha256:abc123 │
│ → Discovers Notation signature artifact │
└────────────────────┬─────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ 2. Notation downloads JWS envelope │
│ - Parses JSON Web Signature │
│ - Extracts certificate chain from x5c header │
└────────────────────┬─────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ 3. Notation validates certificate chain │
│ a. User cert issued by Hold CA? ✓ │
│ b. Hold CA cert in trust store? ✓ │
│ c. Certificate not expired? ✓ │
│ d. Key usage correct? ✓ │
│ e. Subject matches policy? ✓ │
└────────────────────┬─────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ 4. Notation verifies signature │
│ a. Extract public key from user certificate │
│ b. Compute manifest hash: SHA256(manifest) │
│ c. Verify: ECDSA_P256(hash, sig, pubKey) ✓ │
└────────────────────┬─────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ 5. Success: Image verified ✓ │
│ Signed by: did:plc:alice123 (via Hold CA) │
└──────────────────────────────────────────────────────┘
```
## Implementation
### Hold CA Certificate Generation
```go
// cmd/hold/main.go - CA initialization
func (h *Hold) initializeCA(ctx context.Context) error {
caKeyPath := filepath.Join(h.config.DataDir, "ca-private-key.pem")
caCertPath := filepath.Join(h.config.DataDir, "ca-certificate.pem")
// Load existing CA or generate new one
if exists(caKeyPath) && exists(caCertPath) {
h.caKey = loadPrivateKey(caKeyPath)
h.caCert = loadCertificate(caCertPath)
return nil
}
// Generate P-256 key pair for CA
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return fmt.Errorf("failed to generate CA key: %w", err)
}
// Create CA certificate template
serialNumber, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
template := &x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: fmt.Sprintf("ATCR Hold CA - %s", h.DID),
},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(10, 0, 0), // 10 years
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
BasicConstraintsValid: true,
IsCA: true,
MaxPathLen: 1, // Can only issue end-entity certificates
}
// Self-sign
certDER, err := x509.CreateCertificate(
rand.Reader,
template,
template, // Self-signed: issuer = subject
&caKey.PublicKey,
caKey,
)
if err != nil {
return fmt.Errorf("failed to create CA certificate: %w", err)
}
caCert, _ := x509.ParseCertificate(certDER)
// Save to disk (0600 permissions)
savePrivateKey(caKeyPath, caKey)
saveCertificate(caCertPath, caCert)
h.caKey = caKey
h.caCert = caCert
log.Info("Generated new CA certificate", "did", h.DID, "expires", caCert.NotAfter)
return nil
}
```
### User Certificate Issuance
```go
// pkg/hold/cosign.go
func (h *Hold) issueUserCertificate(userDID string) (*x509.Certificate, *ecdsa.PrivateKey, error) {
// Generate ephemeral P-256 key for user
userKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("failed to generate user key: %w", err)
}
serialNumber, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
// Parse DID for SAN
sanURI, _ := url.Parse(userDID)
template := &x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: userDID,
},
URIs: []*url.URL{sanURI}, // Subject Alternative Name
NotBefore: time.Now(),
NotAfter: time.Now().Add(24 * time.Hour), // Short-lived: 24 hours
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning},
BasicConstraintsValid: true,
IsCA: false,
}
// Sign with hold's CA key
certDER, err := x509.CreateCertificate(
rand.Reader,
template,
h.caCert, // Issuer: Hold CA
&userKey.PublicKey,
h.caKey, // Sign with CA private key
)
if err != nil {
return nil, nil, fmt.Errorf("failed to create user certificate: %w", err)
}
userCert, _ := x509.ParseCertificate(certDER)
return userCert, userKey, nil
}
```
### Co-Signing XRPC Endpoint
```go
// pkg/hold/oci/xrpc.go
func (s *Server) handleCoSignManifest(ctx context.Context, req *CoSignRequest) (*CoSignResponse, error) {
// 1. Verify caller is authenticated
did, err := s.auth.VerifyToken(ctx, req.Token)
if err != nil {
return nil, fmt.Errorf("authentication failed: %w", err)
}
// 2. Verify ATProto signature
valid, err := s.verifyATProtoSignature(ctx, req.UserDID, req.ManifestDigest, req.ATProtoSignature)
if err != nil || !valid {
return nil, fmt.Errorf("ATProto signature verification failed: %w", err)
}
// 3. Issue certificate for user
userCert, userKey, err := s.hold.issueUserCertificate(req.UserDID)
if err != nil {
return nil, fmt.Errorf("failed to issue certificate: %w", err)
}
// 4. Sign manifest with user's key
manifestHash := sha256.Sum256([]byte(req.ManifestDigest))
signature, err := ecdsa.SignASN1(rand.Reader, userKey, manifestHash[:])
if err != nil {
return nil, fmt.Errorf("failed to sign manifest: %w", err)
}
// 5. Create JWS envelope
jws, err := s.createJWSEnvelope(signature, userCert, s.hold.caCert, req.ManifestDigest)
if err != nil {
return nil, fmt.Errorf("failed to create JWS: %w", err)
}
return &CoSignResponse{
JWS: jws,
Certificate: encodeCertificate(userCert),
CACertificate: encodeCertificate(s.hold.caCert),
}, nil
}
```
## Trust Model
### Centralization Analysis
**ATProto Model (Decentralized):**
- Each PDS is independent
- User controls which PDS to use
- Trust user's DID, not specific infrastructure
- PDS compromise affects only that PDS's users
- Multiple PDSs provide redundancy
**Hold-as-CA Model (Centralized):**
- Hold acts as single Certificate Authority
- All users must trust hold's CA certificate
- Hold compromise = attacker can issue certificates for ANY user
- Hold becomes single point of failure
- Users depend on hold operator honesty
### What Hold Vouches For
When hold issues a certificate, it attests:
**"I verified that [DID] signed this manifest with ATProto"**
- Hold validated ATProto signature
- Hold confirmed signature matches user's DID
- Hold checked signature at specific time
**"This image is safe"**
- Hold does NOT audit image contents
- Certificate ≠ vulnerability scan
- Signature ≠ security guarantee
**"I control this DID"**
- Hold does NOT control user's DID
- DID ownership is independent
- Hold cannot revoke DIDs
### Threat Model
**Scenario 1: Hold Private Key Compromise**
**Attack:**
- Attacker steals hold's CA private key
- Can issue certificates for any DID
- Can sign malicious images as any user
**Impact:**
- **CRITICAL** - All users affected
- Attacker can impersonate any user
- All signatures become untrustworthy
**Detection:**
- Certificate Transparency logs (if implemented)
- Unusual certificate issuance patterns
- Users report unexpected signatures
**Mitigation:**
- Store CA key in Hardware Security Module (HSM)
- Strict access controls
- Audit logging
- Regular key rotation
**Recovery:**
- Revoke compromised CA certificate
- Generate new CA certificate
- Re-issue all active certificates
- Notify all users
- Update trust stores
---
**Scenario 2: Malicious Hold Operator**
**Attack:**
- Hold operator issues certificates without verifying ATProto signatures
- Hold operator signs malicious images
- Hold operator backdates certificates
**Impact:**
- **HIGH** - Trust model broken
- Users receive signed malicious images
- Difficult to detect without ATProto cross-check
**Detection:**
- Compare Notation signature timestamp with ATProto commit time
- Verify ATProto signature exists independently
- Monitor hold's signing patterns
**Mitigation:**
- Audit trail linking certificates to ATProto signatures
- Public transparency logs
- Multi-signature requirements
- Periodically verify ATProto signatures
**Recovery:**
- Identify malicious certificates
- Revoke hold's CA trust
- Switch to different hold
- Re-verify all images
---
**Scenario 3: Certificate Theft**
**Attack:**
- Attacker steals issued user certificate + private key
- Uses it to sign malicious images
**Impact:**
- **LOW-MEDIUM** - Limited scope
- Affects only specific user/image
- Short validity period (24 hours)
**Detection:**
- Unexpected signature timestamps
- Images signed from unknown locations
**Mitigation:**
- Short certificate validity (24 hours)
- Ephemeral keys (not stored long-term)
- Certificate revocation if detected
**Recovery:**
- Wait for certificate expiration (24 hours)
- Revoke specific certificate
- Investigate compromise source
## Certificate Management
### Expiration Strategy
**Short-Lived Certificates (24 hours):**
**Pros:**
- ✅ Minimal revocation infrastructure needed
- ✅ Compromise window is tiny
- ✅ Automatic cleanup
- ✅ Lower CRL/OCSP overhead
**Cons:**
- ❌ Old images become unverifiable quickly
- ❌ Requires re-signing for historical verification
- ❌ Storage: multiple signatures for same image
**Solution: On-Demand Re-Signing**
```
User pulls old image → Notation verification fails (expired cert)
→ User requests re-signing: POST /xrpc/io.atcr.hold.reSignManifest
→ Hold verifies ATProto signature still valid
→ Hold issues new certificate (24 hours)
→ Hold creates new Notation signature
→ User can verify with fresh certificate
```
### Revocation
**Certificate Revocation List (CRL):**
```
Hold publishes CRL at: https://hold01.atcr.io/ca.crl
Notation configured to check CRL:
{
"trustPolicies": [{
"name": "atcr-images",
"signatureVerification": {
"verificationLevel": "strict",
"override": {
"revocationValidation": "strict"
}
}
}]
}
```
**OCSP (Online Certificate Status Protocol):**
- Hold runs OCSP responder: `https://hold01.atcr.io/ocsp`
- Real-time certificate status checks
- Lower overhead than CRL downloads
**Revocation Triggers:**
- Key compromise detected
- Malicious signing detected
- User request
- DID ownership change
### CA Key Rotation
**Rotation Procedure:**
1. **Generate new CA key pair**
2. **Create new CA certificate**
3. **Cross-sign old CA with new CA** (transition period)
4. **Distribute new CA certificate** to all users
5. **Begin issuing with new CA** for new signatures
6. **Grace period** (30 days): Accept both old and new CA
7. **Retire old CA** after grace period
**Frequency:** Every 2-3 years (longer than short-lived certs)
## Trust Store Distribution
### Problem
Users must add hold's CA certificate to their Notation trust store for verification to work.
### Manual Distribution
```bash
# 1. Download hold's CA certificate
curl https://hold01.atcr.io/ca.crt -o hold01-ca.crt
# 2. Verify fingerprint (out-of-band)
openssl x509 -in hold01-ca.crt -fingerprint -noout
# Compare with published fingerprint
# 3. Add to Notation trust store
notation cert add --type ca --store atcr-holds hold01-ca.crt
```
### Automated Distribution
**ATCR CLI tool:**
```bash
atcr trust add hold01.atcr.io
# → Fetches CA certificate
# → Verifies via HTTPS + DNSSEC
# → Adds to Notation trust store
# → Configures trust policy
atcr trust list
# → Shows trusted holds with fingerprints
```
### System-Wide Trust
**For enterprise deployments:**
**Debian/Ubuntu:**
```bash
# Install CA certificate system-wide
cp hold01-ca.crt /usr/local/share/ca-certificates/atcr-hold01.crt
update-ca-certificates
```
**RHEL/CentOS:**
```bash
cp hold01-ca.crt /etc/pki/ca-trust/source/anchors/
update-ca-trust
```
**Container images:**
```dockerfile
FROM ubuntu:22.04
COPY hold01-ca.crt /usr/local/share/ca-certificates/
RUN update-ca-certificates
```
## Configuration
### Hold Service
**Environment variables:**
```bash
# Enable co-signing feature
HOLD_COSIGN_ENABLED=true
# CA certificate and key paths
HOLD_CA_CERT_PATH=/var/lib/atcr/hold/ca-certificate.pem
HOLD_CA_KEY_PATH=/var/lib/atcr/hold/ca-private-key.pem
# Certificate validity
HOLD_CERT_VALIDITY_HOURS=24
# OCSP responder
HOLD_OCSP_ENABLED=true
HOLD_OCSP_URL=https://hold01.atcr.io/ocsp
# CRL distribution
HOLD_CRL_ENABLED=true
HOLD_CRL_URL=https://hold01.atcr.io/ca.crl
```
### Notation Trust Policy
```json
{
"version": "1.0",
"trustPolicies": [{
"name": "atcr-images",
"registryScopes": ["atcr.io/*/*"],
"signatureVerification": {
"level": "strict",
"override": {
"revocationValidation": "strict"
}
},
"trustStores": ["ca:atcr-holds"],
"trustedIdentities": [
"x509.subject: CN=did:plc:*",
"x509.subject: CN=did:web:*"
]
}]
}
```
## When to Use Hold-as-CA
### ✅ Use When
**Enterprise X.509 PKI Compliance:**
- Organization requires standard X.509 certificates
- Existing security policies mandate PKI
- Audit requirements for certificate chains
- Integration with existing CA infrastructure
**Tool Compatibility:**
- Must use standard Notation without plugins
- Cannot deploy custom verification tools
- Existing tooling expects X.509 signatures
**Centralized Trust Acceptable:**
- Organization already uses centralized trust model
- Hold operator is internal/trusted team
- Centralization risk is acceptable trade-off
### ❌ Don't Use When
**Default Deployment:**
- Most users should use [plugin-based approach](./INTEGRATION_STRATEGY.md)
- Plugins maintain decentralization
- Plugins reuse existing ATProto signatures
**Small Teams / Startups:**
- Certificate management overhead too high
- Don't need X.509 compliance
- Prefer simpler architecture
**Maximum Decentralization Required:**
- Cannot accept hold as single trust point
- Must maintain pure ATProto model
- Centralization contradicts project goals
## Comparison: Hold-as-CA vs. Plugins
| Aspect | Hold-as-CA | Plugin Approach |
|--------|------------|----------------|
| **Standard compliance** | ✅ Full X.509/PKI | ⚠️ Custom verification |
| **Tool compatibility** | ✅ Notation works unchanged | ❌ Requires plugin install |
| **Decentralization** | ❌ Centralized (hold CA) | ✅ Decentralized (DIDs) |
| **ATProto alignment** | ❌ Against philosophy | ✅ ATProto-native |
| **Signature reuse** | ❌ Must re-sign (P-256) | ✅ Reuses ATProto (K-256) |
| **Certificate mgmt** | 🔴 High overhead | 🟢 None |
| **Trust distribution** | 🔴 Must distribute CA cert | 🟢 DID resolution |
| **Hold compromise** | 🔴 All users affected | 🟢 Metadata only |
| **Operational cost** | 🔴 High | 🟢 Low |
| **Use case** | Enterprise PKI | General purpose |
## Recommendations
### Default Approach: Plugins
For most deployments, use plugin-based verification:
- **Ratify plugin** for Kubernetes
- **OPA Gatekeeper provider** for policy enforcement
- **Containerd verifier** for runtime checks
- **atcr-verify CLI** for general purpose
See [Integration Strategy](./INTEGRATION_STRATEGY.md) for details.
### Optional: Hold-as-CA for Enterprise
Only implement hold-as-CA if you have specific requirements:
- Enterprise X.509 PKI mandates
- Cannot use plugins (restricted environments)
- Accept centralization trade-off
**Implement as opt-in feature:**
```bash
# Users explicitly enable co-signing
docker push atcr.io/alice/myapp:latest --sign=notation
# Or via environment variable
export ATCR_ENABLE_COSIGN=true
docker push atcr.io/alice/myapp:latest
```
### Security Best Practices
**If implementing hold-as-CA:**
1. **Store CA key in HSM** - Never on filesystem
2. **Audit all certificate issuance** - Log every cert
3. **Public transparency log** - Publish all certificates
4. **Short certificate validity** - 24 hours max
5. **Monitor unusual patterns** - Alert on anomalies
6. **Regular CA key rotation** - Every 2-3 years
7. **Cross-check ATProto** - Verify both signatures match
8. **Incident response plan** - Prepare for compromise
## See Also
- [ATProto Signatures](./ATPROTO_SIGNATURES.md) - How ATProto signing works
- [Integration Strategy](./INTEGRATION_STRATEGY.md) - Overview of integration approaches
- [Signature Integration](./SIGNATURE_INTEGRATION.md) - Tool-specific integration guides

View File

@@ -0,0 +1,84 @@
# Hold Service XRPC Endpoints
This document lists all XRPC endpoints implemented in the Hold service (`pkg/hold/`).
## PDS Endpoints (`pkg/hold/pds/xrpc.go`)
### Public (No Auth Required)
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/xrpc/_health` | GET | Health check |
| `/xrpc/com.atproto.server.describeServer` | GET | Server metadata |
| `/xrpc/com.atproto.repo.describeRepo` | GET | Repository information |
| `/xrpc/com.atproto.repo.getRecord` | GET | Retrieve a single record |
| `/xrpc/com.atproto.repo.listRecords` | GET | List records in a collection (paginated) |
| `/xrpc/com.atproto.sync.listRepos` | GET | List all repositories |
| `/xrpc/com.atproto.sync.getRecord` | GET | Get record as CAR file |
| `/xrpc/com.atproto.sync.getRepo` | GET | Full repository as CAR file |
| `/xrpc/com.atproto.sync.getRepoStatus` | GET | Repository hosting status |
| `/xrpc/com.atproto.sync.subscribeRepos` | GET | WebSocket firehose |
| `/xrpc/com.atproto.identity.resolveHandle` | GET | Resolve handle to DID |
| `/xrpc/app.bsky.actor.getProfile` | GET | Get actor profile |
| `/xrpc/app.bsky.actor.getProfiles` | GET | Get multiple profiles |
| `/.well-known/did.json` | GET | DID document |
| `/.well-known/atproto-did` | GET | DID for handle resolution |
### Conditional Auth (based on captain.public)
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/xrpc/com.atproto.sync.getBlob` | GET/HEAD | Get blob (routes OCI vs ATProto) |
### Owner/Crew Admin Required
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/xrpc/com.atproto.repo.deleteRecord` | POST | Delete a record |
| `/xrpc/com.atproto.repo.uploadBlob` | POST | Upload ATProto blob |
### DPoP Auth Required
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/xrpc/io.atcr.hold.requestCrew` | POST | Request crew membership |
---
## OCI Multipart Upload Endpoints (`pkg/hold/oci/xrpc.go`)
All require `blob:write` permission via service token:
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/xrpc/io.atcr.hold.initiateUpload` | POST | Start multipart upload |
| `/xrpc/io.atcr.hold.getPartUploadUrl` | POST | Get presigned URL for part |
| `/xrpc/io.atcr.hold.uploadPart` | PUT | Direct buffered part upload |
| `/xrpc/io.atcr.hold.completeUpload` | POST | Finalize multipart upload |
| `/xrpc/io.atcr.hold.abortUpload` | POST | Cancel multipart upload |
| `/xrpc/io.atcr.hold.notifyManifest` | POST | Notify manifest push (creates layer records + optional Bluesky post) |
---
## Standard ATProto Endpoints (excluding io.atcr.hold.*)
| Endpoint |
|----------|
| /xrpc/_health |
| /xrpc/com.atproto.server.describeServer |
| /xrpc/com.atproto.repo.describeRepo |
| /xrpc/com.atproto.repo.getRecord |
| /xrpc/com.atproto.repo.listRecords |
| /xrpc/com.atproto.repo.deleteRecord |
| /xrpc/com.atproto.repo.uploadBlob |
| /xrpc/com.atproto.sync.listRepos |
| /xrpc/com.atproto.sync.getRecord |
| /xrpc/com.atproto.sync.getRepo |
| /xrpc/com.atproto.sync.getRepoStatus |
| /xrpc/com.atproto.sync.getBlob |
| /xrpc/com.atproto.sync.subscribeRepos |
| /xrpc/com.atproto.identity.resolveHandle |
| /xrpc/app.bsky.actor.getProfile |
| /xrpc/app.bsky.actor.getProfiles |
| /.well-known/did.json |
| /.well-known/atproto-did |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,692 @@
# ATCR Signature Verification Integration Strategy
## Overview
This document provides a comprehensive overview of how to integrate ATProto signature verification into various tools and workflows. ATCR uses a layered approach that provides maximum compatibility while maintaining ATProto's decentralized philosophy.
## Architecture Layers
```
┌─────────────────────────────────────────────────────────┐
│ Layer 4: Applications & Workflows │
│ - CI/CD pipelines │
│ - Kubernetes admission control │
│ - Runtime verification │
│ - Security scanning │
└──────────────────────┬──────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Layer 3: Integration Methods │
│ - Plugins (Ratify, Gatekeeper, Containerd) │
│ - CLI tools (atcr-verify) │
│ - External services (webhooks, APIs) │
│ - (Optional) X.509 certificates (hold-as-CA) │
└──────────────────────┬──────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Layer 2: Signature Discovery │
│ - OCI Referrers API (GET /v2/.../referrers/...) │
│ - ORAS artifact format │
│ - artifactType: application/vnd.atproto.signature... │
└──────────────────────┬──────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Layer 1: ATProto Signatures (Foundation) │
│ - Manifests signed by PDS (K-256) │
│ - Signatures in ATProto repository commits │
│ - Public keys in DID documents │
│ - DID-based identity │
└─────────────────────────────────────────────────────────┘
```
## Integration Approaches
### Approach 1: Plugin-Based (RECOMMENDED) ⭐
**Best for:** Kubernetes, standard tooling, production deployments
Integrate through plugin systems of existing tools:
#### Ratify Verifier Plugin
- **Use case:** Kubernetes admission control via Gatekeeper
- **Effort:** 2-3 weeks to build
- **Maturity:** CNCF Sandbox project, growing adoption
- **Benefits:**
- ✅ Standard plugin interface
- ✅ Works with existing Ratify deployments
- ✅ Policy-based enforcement
- ✅ Multi-verifier support (can combine with Notation, Cosign)
**Implementation:**
```go
// Ratify plugin interface
type ReferenceVerifier interface {
VerifyReference(
ctx context.Context,
subjectRef common.Reference,
referenceDesc ocispecs.ReferenceDescriptor,
store referrerStore.ReferrerStore,
) (VerifierResult, error)
}
```
**Deployment:**
```yaml
apiVersion: config.ratify.deislabs.io/v1beta1
kind: Verifier
metadata:
name: atcr-verifier
spec:
name: atproto
artifactType: application/vnd.atproto.signature.v1+json
parameters:
trustedDIDs:
- did:plc:alice123
```
See [Ratify Integration Guide](./SIGNATURE_INTEGRATION.md#ratify-plugin)
---
#### OPA Gatekeeper External Provider
- **Use case:** Kubernetes admission control with OPA policies
- **Effort:** 2-3 weeks to build
- **Maturity:** Very stable, widely adopted
- **Benefits:**
- ✅ Rego-based policies (flexible)
- ✅ External data provider API (standard)
- ✅ Can reuse existing Gatekeeper deployments
**Implementation:**
```go
// External data provider
type Provider struct {
verifier *atproto.Verifier
}
func (p *Provider) Provide(ctx context.Context, req ProviderRequest) (*ProviderResponse, error) {
image := req.Keys["image"]
result, err := p.verifier.Verify(ctx, image)
return &ProviderResponse{
Data: map[string]bool{"verified": result.Verified},
}, nil
}
```
**Policy:**
```rego
package verify
violation[{"msg": msg}] {
container := input.review.object.spec.containers[_]
startswith(container.image, "atcr.io/")
response := external_data({
"provider": "atcr-verifier",
"keys": ["image"],
"values": [container.image]
})
response.verified != true
msg := sprintf("Image %v has no valid ATProto signature", [container.image])
}
```
See [Gatekeeper Integration Guide](./SIGNATURE_INTEGRATION.md#opa-gatekeeper-external-provider)
---
#### Containerd 2.0 Image Verifier Plugin
- **Use case:** Runtime verification at image pull time
- **Effort:** 1-2 weeks to build
- **Maturity:** New in Containerd 2.0 (Nov 2024)
- **Benefits:**
- ✅ Runtime enforcement (pull-time verification)
- ✅ Works for Docker, nerdctl, ctr
- ✅ Transparent to users
- ✅ No Kubernetes required
**Limitation:** CRI plugin integration still maturing
**Implementation:**
```bash
#!/bin/bash
# /usr/local/bin/containerd-verifiers/atcr-verifier
# Binary called by containerd on image pull
# Containerd passes image info via stdin
read -r INPUT
IMAGE=$(echo "$INPUT" | jq -r '.reference')
DIGEST=$(echo "$INPUT" | jq -r '.descriptor.digest')
# Verify signature
if atcr-verify "$IMAGE@$DIGEST" --quiet; then
exit 0 # Verified
else
exit 1 # Failed
fi
```
**Configuration:**
```toml
# /etc/containerd/config.toml
[plugins."io.containerd.image-verifier.v1.bindir"]
bin_dir = "/usr/local/bin/containerd-verifiers"
max_verifiers = 5
per_verifier_timeout = "10s"
```
See [Containerd Integration Guide](./SIGNATURE_INTEGRATION.md#containerd-20)
---
### Approach 2: CLI Tool (RECOMMENDED) ⭐
**Best for:** CI/CD, scripts, general-purpose verification
Use `atcr-verify` CLI tool directly in workflows:
#### Command-Line Verification
```bash
# Basic verification
atcr-verify atcr.io/alice/myapp:latest
# With trust policy
atcr-verify atcr.io/alice/myapp:latest --policy trust-policy.yaml
# JSON output for scripting
atcr-verify atcr.io/alice/myapp:latest --output json
# Quiet mode for exit codes
atcr-verify atcr.io/alice/myapp:latest --quiet && echo "Verified"
```
#### CI/CD Integration
**GitHub Actions:**
```yaml
- name: Verify image
run: atcr-verify ${{ env.IMAGE }} --policy .github/trust-policy.yaml
```
**GitLab CI:**
```yaml
verify:
image: atcr.io/atcr/verify:latest
script:
- atcr-verify ${IMAGE} --policy trust-policy.yaml
```
**Universal Container:**
```bash
docker run --rm atcr.io/atcr/verify:latest verify IMAGE
```
**Benefits:**
- ✅ Works everywhere (not just Kubernetes)
- ✅ Simple integration (single binary)
- ✅ No plugin installation required
- ✅ Offline mode support
See [atcr-verify CLI Documentation](./ATCR_VERIFY_CLI.md)
---
### Approach 3: External Services
**Best for:** Custom admission controllers, API-based verification
Build verification as a service that tools can call:
#### Webhook Service
```go
// HTTP endpoint for verification
func (h *Handler) VerifyImage(w http.ResponseWriter, r *http.Request) {
image := r.URL.Query().Get("image")
result, err := h.verifier.Verify(r.Context(), image)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"verified": result.Verified,
"did": result.Signature.DID,
"signedAt": result.Signature.SignedAt,
})
}
```
#### Usage from Kyverno
```yaml
verifyImages:
- imageReferences:
- "atcr.io/*/*"
attestors:
- entries:
- api:
url: http://atcr-verify.kube-system/verify?image={{ image }}
```
**Benefits:**
- ✅ Flexible integration
- ✅ Centralized verification logic
- ✅ Caching and rate limiting
- ✅ Can add additional checks (vulnerability scanning, etc.)
---
### Approach 4: Hold-as-CA (OPTIONAL, ENTERPRISE ONLY)
**Best for:** Enterprise X.509 PKI compliance requirements
⚠️ **WARNING:** This approach introduces centralization trade-offs. Only use if you have specific X.509 compliance requirements.
Hold services act as Certificate Authorities that issue X.509 certificates for users, enabling standard Notation verification.
**When to use:**
- Enterprise requires standard X.509 PKI
- Cannot deploy custom plugins
- Accept centralization trade-off for tool compatibility
**When NOT to use:**
- Default deployments (use plugins instead)
- Maximum decentralization required
- Don't need X.509 compliance
See [Hold-as-CA Architecture](./HOLD_AS_CA.md) for complete details and security implications.
---
## Tool Compatibility Matrix
| Tool | Discover | Verify | Integration Method | Priority | Effort |
|------|----------|--------|-------------------|----------|--------|
| **Kubernetes** | | | | | |
| OPA Gatekeeper | ✅ | ✅ | External provider | **HIGH** | 2-3 weeks |
| Ratify | ✅ | ✅ | Verifier plugin | **HIGH** | 2-3 weeks |
| Kyverno | ✅ | ⚠️ | External service | MEDIUM | 2 weeks |
| Portieris | ❌ | ❌ | N/A (deprecated) | NONE | - |
| **Runtime** | | | | | |
| Containerd 2.0 | ✅ | ✅ | Bindir plugin | **MED-HIGH** | 1-2 weeks |
| CRI-O | ⚠️ | ⚠️ | Upstream contribution | MEDIUM | 3-4 weeks |
| Podman | ⚠️ | ⚠️ | Upstream contribution | MEDIUM | 3-4 weeks |
| **CI/CD** | | | | | |
| GitHub Actions | ✅ | ✅ | Custom action | **HIGH** | 1 week |
| GitLab CI | ✅ | ✅ | Container image | **HIGH** | 1 week |
| Jenkins/CircleCI | ✅ | ✅ | Container image | HIGH | 1 week |
| **Scanners** | | | | | |
| Trivy | ✅ | ❌ | N/A (not verifier) | NONE | - |
| Snyk | ❌ | ❌ | N/A (not verifier) | NONE | - |
| Anchore | ❌ | ❌ | N/A (not verifier) | NONE | - |
| **Registries** | | | | | |
| Harbor | ✅ | ⚠️ | UI integration | LOW | - |
| **OCI Tools** | | | | | |
| ORAS CLI | ✅ | ❌ | Already works | Document | - |
| Notation | ⚠️ | ⚠️ | Hold-as-CA | OPTIONAL | 3-4 weeks |
| Cosign | ❌ | ❌ | Not compatible | NONE | - |
| Crane | ✅ | ❌ | Already works | Document | - |
| Skopeo | ⚠️ | ⚠️ | Upstream contribution | LOW | 3-4 weeks |
**Legend:**
- ✅ Works / Feasible
- ⚠️ Partial / Requires changes
- ❌ Not applicable / Not feasible
---
## Implementation Roadmap
### Phase 1: Foundation (4-5 weeks) ⭐
**Goal:** Core verification capability
1. **atcr-verify CLI tool** (Week 1-2)
- ATProto signature verification
- Trust policy support
- Multiple output formats
- Offline mode
2. **OCI Referrers API** (Week 2-3)
- AppView endpoint implementation
- ORAS artifact serving
- Integration with existing SBOM pattern
3. **CI/CD Container Image** (Week 3)
- Universal verification image
- Documentation for GitHub Actions, GitLab CI
- Example workflows
4. **Documentation** (Week 4-5)
- Integration guides
- Trust policy examples
- Troubleshooting guides
**Deliverables:**
- `atcr-verify` binary (Linux, macOS, Windows)
- `atcr.io/atcr/verify:latest` container image
- OCI Referrers API implementation
- Complete documentation
---
### Phase 2: Kubernetes Integration (3-4 weeks)
**Goal:** Production-ready Kubernetes admission control
5. **OPA Gatekeeper Provider** (Week 1-2)
- External data provider service
- Helm chart for deployment
- Example policies
6. **Ratify Plugin** (Week 2-3)
- Verifier plugin implementation
- Testing with Ratify
- Documentation
7. **Kubernetes Examples** (Week 4)
- Deployment manifests
- Policy examples
- Integration testing
**Deliverables:**
- `atcr-gatekeeper-provider` service
- Ratify plugin binary
- Kubernetes deployment examples
- Production deployment guide
---
### Phase 3: Runtime Verification (2-3 weeks)
**Goal:** Pull-time verification
8. **Containerd Plugin** (Week 1-2)
- Bindir verifier implementation
- Configuration documentation
- Testing with Docker, nerdctl
9. **CRI-O/Podman Integration** (Week 3, optional)
- Upstream contribution (if accepted)
- Policy.json extension
- Documentation
**Deliverables:**
- Containerd verifier binary
- Configuration guides
- Runtime verification examples
---
### Phase 4: Optional Features (2-3 weeks)
**Goal:** Enterprise features (if demanded)
10. **Hold-as-CA** (Week 1-2, optional)
- Certificate generation
- Notation signature creation
- Trust store distribution
- **Only if enterprise customers request**
11. **Advanced Features** (Week 3, as needed)
- Signature transparency log
- Multi-signature support
- Hardware token integration
**Deliverables:**
- Hold co-signing implementation (if needed)
- Advanced feature documentation
---
## Decision Matrix
### Which Integration Approach Should I Use?
```
┌─────────────────────────────────────────────────┐
│ Are you using Kubernetes? │
└───────────────┬─────────────────────────────────┘
┌────────┴────────┐
│ │
YES NO
│ │
↓ ↓
┌──────────────┐ ┌──────────────┐
│ Using │ │ CI/CD │
│ Gatekeeper? │ │ Pipeline? │
└──────┬───────┘ └──────┬───────┘
│ │
┌────┴────┐ ┌────┴────┐
YES NO YES NO
│ │ │ │
↓ ↓ ↓ ↓
External Ratify GitHub Universal
Provider Plugin Action CLI Tool
```
#### Use OPA Gatekeeper Provider if:
- ✅ Already using Gatekeeper
- ✅ Want Rego-based policies
- ✅ Need flexible policy logic
#### Use Ratify Plugin if:
- ✅ Using Ratify (or planning to)
- ✅ Want standard plugin interface
- ✅ Need multi-verifier support (Notation + Cosign + ATProto)
#### Use atcr-verify CLI if:
- ✅ CI/CD pipelines
- ✅ Local development
- ✅ Non-Kubernetes environments
- ✅ Want simple integration
#### Use Containerd Plugin if:
- ✅ Need runtime enforcement
- ✅ Want pull-time verification
- ✅ Using Containerd 2.0+
#### Use Hold-as-CA if:
- ⚠️ Enterprise X.509 PKI compliance required
- ⚠️ Cannot deploy plugins
- ⚠️ Accept centralization trade-off
---
## Best Practices
### 1. Start Simple
Begin with CLI tool integration in CI/CD:
```bash
# Add to .github/workflows/deploy.yml
- run: atcr-verify $IMAGE --policy .github/trust-policy.yaml
```
### 2. Define Trust Policies
Create trust policies early:
```yaml
# trust-policy.yaml
policies:
- name: production
scope: "atcr.io/*/prod-*"
require:
signature: true
trustedDIDs: [did:plc:devops-team]
action: enforce
```
### 3. Progressive Rollout
1. **Week 1:** Add verification to CI/CD (audit mode)
2. **Week 2:** Enforce in CI/CD
3. **Week 3:** Add Kubernetes admission control (audit mode)
4. **Week 4:** Enforce in Kubernetes
### 4. Monitor and Alert
Track verification metrics:
- Verification success/failure rates
- Policy violations
- Signature coverage (% of images signed)
### 5. Plan for Key Rotation
- Document DID key rotation procedures
- Test key rotation in non-production
- Monitor for unexpected key changes
---
## Common Patterns
### Pattern 1: Multi-Layer Defense
```
1. CI/CD verification (atcr-verify)
↓ (blocks unsigned images from being pushed)
2. Kubernetes admission (Gatekeeper/Ratify)
↓ (blocks unsigned images from running)
3. Runtime verification (Containerd plugin)
↓ (blocks unsigned images from being pulled)
```
### Pattern 2: Trust Policy Inheritance
```yaml
# Global policy
trustedDIDs:
- did:plc:security-team # Always trusted
# Environment-specific policies
staging:
trustedDIDs:
- did:plc:developers # Additional trust for staging
production:
trustedDIDs: [] # Only global trust (security-team)
```
### Pattern 3: Offline Verification
```bash
# Build environment (online)
atcr-verify export $IMAGE -o bundle.json
# Air-gapped environment (offline)
atcr-verify $IMAGE --offline --bundle bundle.json
```
---
## Migration Guide
### From Docker Content Trust (DCT)
DCT is deprecated. Migrate to ATCR signatures:
**Old (DCT):**
```bash
export DOCKER_CONTENT_TRUST=1
docker push myimage:latest
```
**New (ATCR):**
```bash
# Signatures created automatically on push
docker push atcr.io/myorg/myimage:latest
# Verify in CI/CD
atcr-verify atcr.io/myorg/myimage:latest
```
### From Cosign
Cosign and ATCR signatures can coexist:
**Dual signing:**
```bash
# Push to ATCR (ATProto signature automatic)
docker push atcr.io/myorg/myimage:latest
# Also sign with Cosign (if needed)
cosign sign atcr.io/myorg/myimage:latest
```
**Verification:**
```bash
# Verify ATProto signature
atcr-verify atcr.io/myorg/myimage:latest
# Or verify Cosign signature
cosign verify atcr.io/myorg/myimage:latest --key cosign.pub
```
---
## Troubleshooting
### Signatures Not Found
**Symptom:** `atcr-verify` reports "no signature found"
**Diagnosis:**
```bash
# Check if Referrers API works
curl "https://atcr.io/v2/OWNER/REPO/referrers/DIGEST"
# Check if signature artifact exists
oras discover atcr.io/OWNER/REPO:TAG
```
**Solutions:**
1. Verify Referrers API is implemented
2. Re-push image to generate signature
3. Check AppView logs for signature creation errors
### DID Resolution Fails
**Symptom:** Cannot resolve DID to public key
**Diagnosis:**
```bash
# Test DID resolution
curl https://plc.directory/did:plc:XXXXXX
# Check DID document has verificationMethod
curl https://plc.directory/did:plc:XXXXXX | jq .verificationMethod
```
**Solutions:**
1. Check internet connectivity
2. Verify DID is valid
3. Ensure DID document contains public key
### Policy Violations
**Symptom:** Verification fails with "trust policy violation"
**Diagnosis:**
```bash
# Verify with verbose output
atcr-verify IMAGE --policy policy.yaml --verbose
```
**Solutions:**
1. Add DID to trustedDIDs list
2. Check signature age vs. maxAge
3. Verify policy scope matches image
---
## See Also
- [ATProto Signatures](./ATPROTO_SIGNATURES.md) - Technical foundation
- [atcr-verify CLI](./ATCR_VERIFY_CLI.md) - CLI tool documentation
- [Signature Integration](./SIGNATURE_INTEGRATION.md) - Tool-specific guides
- [Hold-as-CA](./HOLD_AS_CA.md) - X.509 certificate approach (optional)
- [Examples](../examples/verification/) - Working code examples

File diff suppressed because it is too large Load Diff

View File

@@ -112,7 +112,6 @@ Several packages show decreased percentages despite improvements. This is due to
**Remaining gaps:**
- `notifyHoldAboutManifest()` - 0% (background notification, less critical)
- `refreshReadmeCache()` - 11.8% (UI feature, lower priority)
## Critical Priority: Core Registry Functionality
@@ -211,7 +210,7 @@ These components are essential to registry operation and still need coverage.
OAuth implementation has test files but many functions remain untested.
#### refresher.go (Partial coverage)
#### client.go - Session Management (Refresher) (Partial coverage)
**Well-covered:**
- `NewRefresher()` - 100% ✅
@@ -227,6 +226,8 @@ OAuth implementation has test files but many functions remain untested.
- Session retrieval and caching
- Token refresh flow
- Concurrent refresh handling (per-DID locking)
**Note:** Refresher functionality merged into client.go (previously separate refresher.go file)
- Cache expiration
- Error handling for failed refreshes
@@ -421,12 +422,12 @@ Embedded PDS implementation. Has good test coverage for critical parts, but supp
---
### 🟡 pkg/appview/readme (16.7% coverage)
### 🟡 pkg/appview/readme (Partial coverage)
README fetching and caching. Less critical but still needs work.
README rendering for repo page descriptions. The cache.go was removed as README content is now stored in `io.atcr.repo.page` records and synced via Jetstream.
#### cache.go (0% coverage)
#### fetcher.go (📊 Partial coverage)
- `RenderMarkdown()` - renders repo page description markdown
---
@@ -509,8 +510,9 @@ UI initialization and setup. Low priority.
**In Progress:**
9. 🔴 `pkg/appview/db/*` - Database layer (41.2%, needs improvement)
- queries.go, session_store.go, device_store.go
10. 🔴 `pkg/auth/oauth/refresher.go` - Token refresh (Partial → 70%+)
10. 🔴 `pkg/auth/oauth/client.go` - Session management (Refresher) (Partial → 70%+)
- `GetSession()`, `resumeSession()` (currently 0%)
- Note: Refresher merged into client.go
11. 🔴 `pkg/auth/oauth/server.go` - OAuth endpoints (50.7%, continue improvements)
- `ServeCallback()` at 16.3% needs major improvement
12. 🔴 `pkg/appview/storage/crew.go` - Crew validation (11.1% → 80%+)

433
docs/TROUBLESHOOTING.md Normal file
View File

@@ -0,0 +1,433 @@
# ATCR Troubleshooting Guide
This document provides troubleshooting guidance for common ATCR deployment and operational issues.
## OAuth Authentication Failures
### JWT Timestamp Validation Errors
**Symptom:**
```
error: invalid_client
error_description: Validation of "client_assertion" failed: "iat" claim timestamp check failed (it should be in the past)
```
**Root Cause:**
The AppView server's system clock is ahead of the PDS server's clock. When the AppView generates a JWT for OAuth client authentication (confidential client mode), the "iat" (issued at) claim appears to be in the future from the PDS's perspective.
**Diagnosis:**
1. Check AppView system time:
```bash
date -u
timedatectl status
```
2. Check if NTP is active and synchronized:
```bash
timedatectl show-timesync --all
```
3. Compare AppView time with PDS time (if accessible):
```bash
# On AppView
date +%s
# On PDS (or via HTTP headers)
curl -I https://your-pds.example.com | grep -i date
```
4. Check AppView logs for clock information (logged at startup):
```bash
docker logs atcr-appview 2>&1 | grep "Configured confidential OAuth client"
```
Example log output:
```
level=INFO msg="Configured confidential OAuth client"
key_id=did:key:z...
system_time_unix=1731844215
system_time_rfc3339=2025-11-17T14:30:15Z
timezone=UTC
```
**Solution:**
1. **Enable NTP synchronization** (recommended):
On most Linux systems using systemd:
```bash
# Enable and start systemd-timesyncd
sudo timedatectl set-ntp true
# Verify NTP is active
timedatectl status
```
Expected output:
```
System clock synchronized: yes
NTP service: active
```
2. **Alternative: Use chrony** (if systemd-timesyncd is not available):
```bash
# Install chrony
sudo apt-get install chrony # Debian/Ubuntu
sudo yum install chrony # RHEL/CentOS
# Enable and start chronyd
sudo systemctl enable chronyd
sudo systemctl start chronyd
# Check sync status
chronyc tracking
```
3. **Force immediate sync**:
```bash
# systemd-timesyncd
sudo systemctl restart systemd-timesyncd
# Or with chrony
sudo chronyc makestep
```
4. **In Docker/Kubernetes environments:**
The container inherits the host's system clock, so fix NTP on the **host** machine:
```bash
# On Docker host
sudo timedatectl set-ntp true
# Restart AppView container to pick up correct time
docker restart atcr-appview
```
5. **Verify clock skew is resolved**:
```bash
# Should show clock offset < 1 second
timedatectl timesync-status
```
**Acceptable Clock Skew:**
- Most OAuth implementations tolerate ±30-60 seconds of clock skew
- DPoP proof validation is typically stricter (±10 seconds)
- Aim for < 1 second skew for reliable operation
**Prevention:**
- Configure NTP synchronization in your infrastructure-as-code (Terraform, Ansible, etc.)
- Monitor clock skew in production (e.g., Prometheus node_exporter includes clock metrics)
- Use managed container platforms (ECS, GKE, AKS) that handle NTP automatically
---
### DPoP Nonce Mismatch Errors
**Symptom:**
```
error: use_dpop_nonce
error_description: DPoP "nonce" mismatch
```
Repeated multiple times, potentially followed by:
```
error: server_error
error_description: Server error
```
**Root Cause:**
DPoP (Demonstrating Proof-of-Possession) requires a server-provided nonce for replay protection. These errors typically occur when:
1. Multiple concurrent requests create a DPoP nonce race condition
2. Clock skew causes DPoP proof timestamps to fail validation
3. PDS session state becomes corrupted after repeated failures
**Diagnosis:**
1. Check if errors occur during concurrent operations:
```bash
# During docker push with multiple layers
docker logs atcr-appview 2>&1 | grep "use_dpop_nonce" | wc -l
```
2. Check for clock skew (see section above):
```bash
timedatectl status
```
3. Look for session lock acquisition in logs:
```bash
docker logs atcr-appview 2>&1 | grep "Acquired session lock"
```
**Solution:**
1. **If caused by clock skew**: Fix NTP synchronization (see section above)
2. **If caused by session corruption**:
```bash
# The AppView will automatically delete corrupted sessions
# User just needs to re-authenticate
docker login atcr.io
```
3. **If persistent despite clock sync**:
- Check PDS health and logs (may be a PDS-side issue)
- Verify network connectivity between AppView and PDS
- Check if PDS supports latest OAuth/DPoP specifications
**What ATCR does automatically:**
- Per-DID locking prevents concurrent DPoP nonce races
- Indigo library automatically retries with fresh nonces
- Sessions are auto-deleted after repeated failures
- Service token cache prevents excessive PDS requests
**Prevention:**
- Ensure reliable NTP synchronization
- Use a stable, well-maintained PDS implementation
- Monitor AppView error rates for DPoP-related issues
---
### OAuth Session Not Found
**Symptom:**
```
error: failed to get OAuth session: no session found for DID
```
**Root Cause:**
- User has never authenticated via OAuth
- OAuth session was deleted due to corruption or expiry
- Database migration cleared sessions
**Solution:**
1. User re-authenticates via OAuth flow:
```bash
docker login atcr.io
# Or for web UI: visit https://atcr.io/login
```
2. If using app passwords (legacy), verify token is cached:
```bash
# Check if app-password token exists
docker logout atcr.io
docker login atcr.io -u your.handle -p your-app-password
```
---
## AppView Deployment Issues
### Client Metadata URL Not Accessible
**Symptom:**
```
error: unauthorized_client
error_description: Client metadata endpoint returned 404
```
**Root Cause:**
PDS cannot fetch OAuth client metadata from `{ATCR_BASE_URL}/client-metadata.json`
**Diagnosis:**
1. Verify client metadata endpoint is accessible:
```bash
curl https://your-atcr-instance.com/client-metadata.json
```
2. Check AppView logs for startup errors:
```bash
docker logs atcr-appview 2>&1 | grep "client-metadata"
```
3. Verify `ATCR_BASE_URL` is set correctly:
```bash
echo $ATCR_BASE_URL
```
**Solution:**
1. Ensure `ATCR_BASE_URL` matches your public URL:
```bash
export ATCR_BASE_URL=https://atcr.example.com
```
2. Verify reverse proxy (nginx, Caddy, etc.) routes `/.well-known/*` and `/client-metadata.json`:
```nginx
location / {
proxy_pass http://localhost:5000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
3. Check firewall rules allow inbound HTTPS:
```bash
sudo ufw status
sudo iptables -L -n | grep 443
```
---
## Hold Service Issues
### Blob Storage Connectivity
**Symptom:**
```
error: failed to upload blob: connection refused
```
**Diagnosis:**
1. Check hold service logs:
```bash
docker logs atcr-hold 2>&1 | grep -i error
```
2. Verify S3 credentials are correct:
```bash
# Test S3 access
aws s3 ls s3://your-bucket --endpoint-url=$S3_ENDPOINT
```
3. Check hold configuration:
```bash
env | grep -E "(S3_|AWS_|STORAGE_)"
```
**Solution:**
1. Verify environment variables in hold service:
```bash
export AWS_ACCESS_KEY_ID=your-key
export AWS_SECRET_ACCESS_KEY=your-secret
export S3_BUCKET=your-bucket
export S3_ENDPOINT=https://s3.us-west-2.amazonaws.com
```
2. Test S3 connectivity from hold container:
```bash
docker exec atcr-hold curl -v $S3_ENDPOINT
```
3. Check S3 bucket permissions (requires PutObject, GetObject, DeleteObject)
---
## Performance Issues
### High Database Lock Contention
**Symptom:**
Slow Docker push/pull operations, high CPU usage on AppView
**Diagnosis:**
1. Check SQLite database size:
```bash
ls -lh /var/lib/atcr/ui.db
```
2. Look for long-running queries:
```bash
docker logs atcr-appview 2>&1 | grep "database is locked"
```
**Solution:**
1. For production, migrate to PostgreSQL (recommended):
```bash
export ATCR_UI_DATABASE_TYPE=postgres
export ATCR_UI_DATABASE_URL=postgresql://user:pass@localhost/atcr
```
2. Or increase SQLite busy timeout:
```go
// In code: db.SetMaxOpenConns(1) for SQLite
```
3. Vacuum the database to reclaim space:
```bash
sqlite3 /var/lib/atcr/ui.db "VACUUM;"
```
---
## Logging and Debugging
### Enable Debug Logging
Set log level to debug for detailed troubleshooting:
```bash
export ATCR_LOG_LEVEL=debug
docker restart atcr-appview
```
### Useful Log Queries
**OAuth token exchange errors:**
```bash
docker logs atcr-appview 2>&1 | grep "OAuth callback failed"
```
**Service token request failures:**
```bash
docker logs atcr-appview 2>&1 | grep "OAuth authentication failed during service token request"
```
**Clock diagnostics:**
```bash
docker logs atcr-appview 2>&1 | grep "system_time"
```
**DPoP nonce issues:**
```bash
docker logs atcr-appview 2>&1 | grep -E "(use_dpop_nonce|DPoP)"
```
### Health Checks
**AppView health:**
```bash
curl http://localhost:5000/v2/
# Should return: {"errors":[{"code":"UNAUTHORIZED",...}]}
```
**Hold service health:**
```bash
curl http://localhost:8080/.well-known/did.json
# Should return DID document
```
---
## Getting Help
If issues persist after following this guide:
1. **Check GitHub Issues**: https://github.com/ericvolp12/atcr/issues
2. **Collect logs**: Include output from `docker logs` for AppView and Hold services
3. **Include diagnostics**:
- `timedatectl status` output
- AppView version: `docker exec atcr-appview cat /VERSION` (if available)
- PDS version and implementation (Bluesky PDS, other)
4. **File an issue** with reproducible steps
---
## Common Error Reference
| Error Code | Component | Common Cause | Fix |
|------------|-----------|--------------|-----|
| `invalid_client` (iat timestamp) | OAuth | Clock skew | Enable NTP sync |
| `use_dpop_nonce` | OAuth/DPoP | Concurrent requests or clock skew | Fix NTP, wait for auto-retry |
| `server_error` (500) | PDS | PDS internal error | Check PDS logs |
| `invalid_grant` | OAuth | Expired auth code | Retry OAuth flow |
| `unauthorized_client` | OAuth | Client metadata unreachable | Check ATCR_BASE_URL and firewall |
| `RecordNotFound` | ATProto | Manifest doesn't exist | Verify repository name |
| Connection refused | Hold/S3 | Network/credentials | Check S3 config and connectivity |

399
docs/VALKEY_MIGRATION.md Normal file
View File

@@ -0,0 +1,399 @@
# Analysis: AppView SQL Database Usage
## Overview
The AppView uses SQLite with 19 tables. The key finding: **most data is a cache of ATProto records** that could theoretically be rebuilt from users' PDS instances.
## Data Categories
### 1. MUST PERSIST (Local State Only)
These tables contain data that **cannot be reconstructed** from external sources:
| Table | Purpose | Why It Must Persist |
|-------|---------|---------------------|
| `oauth_sessions` | OAuth tokens | Refresh tokens are stateful; losing them = users must re-auth |
| `ui_sessions` | Web browser sessions | Session continuity for logged-in users |
| `devices` | Approved devices + bcrypt secrets | User authorization decisions; secrets are one-way hashed |
| `pending_device_auth` | In-flight auth flows | Short-lived (10min) but critical during auth |
| `oauth_auth_requests` | OAuth flow state | Short-lived but required for auth completion |
| `repository_stats` | Pull/push counts | **Locally tracked metrics** - not stored in ATProto |
### 2. CACHED FROM PDS (Rebuildable)
These tables are essentially a **read-through cache** of ATProto data:
| Table | Source | ATProto Collection |
|-------|--------|-------------------|
| `users` | User's PDS profile | `app.bsky.actor.profile` + DID document |
| `manifests` | User's PDS | `io.atcr.manifest` records |
| `tags` | User's PDS | `io.atcr.tag` records |
| `layers` | Derived from manifests | Parsed from manifest content |
| `manifest_references` | Derived from manifest lists | Parsed from multi-arch manifests |
| `repository_annotations` | Manifest config blob | OCI annotations from config |
| `repo_pages` | User's PDS | `io.atcr.repo.page` records |
| `stars` | User's PDS | `io.atcr.sailor.star` records (synced via Jetstream) |
| `hold_captain_records` | Hold's embedded PDS | `io.atcr.hold.captain` records |
| `hold_crew_approvals` | Hold's embedded PDS | `io.atcr.hold.crew` records |
| `hold_crew_denials` | Local authorization cache | Could re-check on demand |
### 3. OPERATIONAL
| Table | Purpose |
|-------|---------|
| `schema_migrations` | Migration tracking |
| `firehose_cursor` | Jetstream position (can restart from 0) |
## Key Insights
### What's Actually Unique to AppView?
1. **Authentication state** - OAuth sessions, devices, UI sessions
2. **Engagement metrics** - Pull/push counts (locally tracked, not in ATProto)
### What Could Be Eliminated?
If ATCR fully embraced the ATProto model:
1. **`users`** - Query PDS on demand (with caching)
2. **`manifests`, `tags`, `layers`** - Query PDS on demand (with caching)
3. **`repository_annotations`** - Fetch manifest config on demand
4. **`repo_pages`** - Query PDS on demand
5. **`hold_*` tables** - Query hold's PDS on demand
### Trade-offs
**Current approach (heavy caching):**
- Fast queries for UI (search, browse, stats)
- Offline resilience (PDS down doesn't break UI)
- Complex sync logic (Jetstream consumer, backfill)
- State can diverge from source of truth
**Lighter approach (query on demand):**
- Always fresh data
- Simpler codebase (no sync)
- Slower queries (network round-trips)
- Depends on PDS availability
## Current Limitation: No Cache-Miss Queries
**Finding:** There's no "query PDS on cache miss" logic. Users/manifests only enter the DB via:
1. OAuth login (user authenticates)
2. Jetstream events (firehose activity)
**Problem:** If someone visits `atcr.io/alice/myapp` before alice is indexed → 404
**Where this happens:**
- `pkg/appview/handlers/repository.go:50-53`: If `db.GetUserByDID()` returns nil → 404
- No fallback to `atproto.Client.ListRecords()` or similar
**This matters for Valkey migration:** If cache is ephemeral and restarts clear it, you need cache-miss logic to repopulate on demand. Otherwise:
- Restart Valkey → all users/manifests gone
- Wait for Jetstream to re-index OR implement cache-miss queries
**Cache-miss implementation design:**
Existing code to reuse: `pkg/appview/jetstream/processor.go:43-97` (`EnsureUser`)
```go
// New: pkg/appview/cache/loader.go
type Loader struct {
cache Cache // Valkey interface
client *atproto.Client
}
// GetUser with cache-miss fallback
func (l *Loader) GetUser(ctx context.Context, did string) (*User, error) {
// 1. Try cache
if user := l.cache.GetUser(did); user != nil {
return user, nil
}
// 2. Cache miss - resolve identity (already queries network)
_, handle, pdsEndpoint, err := atproto.ResolveIdentity(ctx, did)
if err != nil {
return nil, err // User doesn't exist in network
}
// 3. Fetch profile for avatar
client := atproto.NewClient(pdsEndpoint, "", "")
profile, _ := client.GetProfileRecord(ctx, did)
avatarURL := ""
if profile != nil && profile.Avatar != nil {
avatarURL = atproto.BlobCDNURL(did, profile.Avatar.Ref.Link)
}
// 4. Cache and return
user := &User{DID: did, Handle: handle, PDSEndpoint: pdsEndpoint, Avatar: avatarURL}
l.cache.SetUser(user, 1*time.Hour)
return user, nil
}
// GetManifestsForRepo with cache-miss fallback
func (l *Loader) GetManifestsForRepo(ctx context.Context, did, repo string) ([]Manifest, error) {
cacheKey := fmt.Sprintf("manifests:%s:%s", did, repo)
// 1. Try cache
if cached := l.cache.Get(cacheKey); cached != nil {
return cached.([]Manifest), nil
}
// 2. Cache miss - get user's PDS endpoint
user, err := l.GetUser(ctx, did)
if err != nil {
return nil, err
}
// 3. Query PDS for manifests
client := atproto.NewClient(user.PDSEndpoint, "", "")
records, _, err := client.ListRecordsForRepo(ctx, did, atproto.ManifestCollection, 100, "")
if err != nil {
return nil, err
}
// 4. Filter by repository and parse
var manifests []Manifest
for _, rec := range records {
var m atproto.ManifestRecord
if err := json.Unmarshal(rec.Value, &m); err != nil {
continue
}
if m.Repository == repo {
manifests = append(manifests, convertManifest(m))
}
}
// 5. Cache and return
l.cache.Set(cacheKey, manifests, 10*time.Minute)
return manifests, nil
}
```
**Handler changes:**
```go
// Before (repository.go:45-53):
owner, err := db.GetUserByDID(h.DB, did)
if owner == nil {
RenderNotFound(w, r, h.Templates, h.RegistryURL)
return
}
// After:
owner, err := h.Loader.GetUser(r.Context(), did)
if err != nil {
RenderNotFound(w, r, h.Templates, h.RegistryURL)
return
}
```
**Performance considerations:**
- Cache hit: ~1ms (Valkey lookup)
- Cache miss: ~200-500ms (PDS round-trip)
- First request after restart: slower but correct
- Jetstream still useful for proactive warming
---
## Proposed Architecture: Valkey + ATProto
### Goal
Replace SQLite with Valkey (Redis-compatible) for ephemeral state, push remaining persistent data to ATProto.
### What goes to Valkey (ephemeral, TTL-based)
| Current Table | Valkey Key Pattern | TTL | Notes |
|---------------|-------------------|-----|-------|
| `oauth_sessions` | `oauth:{did}:{session_id}` | 90 days | Lost on restart = re-auth |
| `ui_sessions` | `ui:{session_id}` | Session duration | Lost on restart = re-login |
| `oauth_auth_requests` | `authreq:{state}` | 10 min | In-flight flows |
| `pending_device_auth` | `pending:{device_code}` | 10 min | In-flight flows |
| `firehose_cursor` | `cursor:jetstream` | None | Can restart from 0 |
| All PDS cache tables | `cache:{collection}:{did}:{rkey}` | 10-60 min | Query PDS on miss |
**Benefits:**
- Multi-instance ready (shared Valkey)
- No schema migrations
- Natural TTL expiry
- Simpler code (no SQL)
### What could become ATProto records
| Current Table | Proposed Collection | Where Stored | Open Questions |
|---------------|---------------------|--------------|----------------|
| `devices` | `io.atcr.sailor.device` | User's PDS | Privacy: IP, user-agent sensitive? |
| `repository_stats` | `io.atcr.repo.stats` | Hold's PDS or User's PDS | Who owns the stats? |
**Devices → Valkey:**
- Move current device table to Valkey
- Key: `device:{did}:{device_id}``{name, secret_hash, ip, user_agent, created_at, last_used}`
- TTL: Long (1 year?) or no expiry
- Device list: `devices:{did}` → Set of device IDs
- Secret validation works the same, just different backend
**Service auth exploration (future):**
The challenge with pure ATProto service auth is the AppView still needs the user's OAuth session to write manifests to their PDS. The current flow:
1. User authenticates via OAuth → AppView gets OAuth tokens
2. AppView issues registry JWT to credential helper
3. Credential helper presents JWT on each push/pull
4. AppView uses OAuth session to write to user's PDS
Service auth could work for the hold side (AppView → Hold), but not for the user's OAuth session.
**Repository stats → Hold's PDS:**
**Challenge discovered:** The hold's `getBlob` endpoint only receives `did` + `cid`, not the repository name.
Current flow (`proxy_blob_store.go:358-362`):
```go
xrpcURL := fmt.Sprintf("%s%s?did=%s&cid=%s&method=%s",
p.holdURL, atproto.SyncGetBlob, p.ctx.DID, dgst.String(), operation)
```
**Implementation options:**
**Option A: Add repository parameter to getBlob (recommended)**
```go
// Modified AppView call:
xrpcURL := fmt.Sprintf("%s%s?did=%s&cid=%s&method=%s&repo=%s",
p.holdURL, atproto.SyncGetBlob, p.ctx.DID, dgst.String(), operation, p.ctx.Repository)
```
```go
// Modified hold handler (xrpc.go:969):
func (h *XRPCHandler) HandleGetBlob(w http.ResponseWriter, r *http.Request) {
did := r.URL.Query().Get("did")
cidOrDigest := r.URL.Query().Get("cid")
repo := r.URL.Query().Get("repo") // NEW
// ... existing blob handling ...
// Increment stats if repo provided
if repo != "" {
go h.pds.IncrementPullCount(did, repo) // Async, non-blocking
}
}
```
**Stats record structure:**
```
Collection: io.atcr.hold.stats
Rkey: base64(did:repository) // Deterministic, unique
{
"$type": "io.atcr.hold.stats",
"did": "did:plc:alice123",
"repository": "myapp",
"pullCount": 1542,
"pushCount": 47,
"lastPull": "2025-01-15T...",
"lastPush": "2025-01-10T...",
"createdAt": "2025-01-01T..."
}
```
**Hold-side implementation:**
```go
// New file: pkg/hold/pds/stats.go
func (p *HoldPDS) IncrementPullCount(ctx context.Context, did, repo string) error {
rkey := statsRecordKey(did, repo)
// Get or create stats record
stats, err := p.GetStatsRecord(ctx, rkey)
if err != nil || stats == nil {
stats = &atproto.StatsRecord{
Type: atproto.StatsCollection,
DID: did,
Repository: repo,
PullCount: 0,
PushCount: 0,
CreatedAt: time.Now(),
}
}
// Increment and update
stats.PullCount++
stats.LastPull = time.Now()
_, err = p.repomgr.UpdateRecord(ctx, p.uid, atproto.StatsCollection, rkey, stats)
return err
}
```
**Query endpoint (new XRPC):**
```
GET /xrpc/io.atcr.hold.getStats?did={userDID}&repo={repository}
→ Returns JSON: { pullCount, pushCount, lastPull, lastPush }
GET /xrpc/io.atcr.hold.listStats?did={userDID}
→ Returns all stats for a user across all repos on this hold
```
**AppView aggregation:**
```go
func (l *Loader) GetAggregatedStats(ctx context.Context, did, repo string) (*Stats, error) {
// 1. Get all holds that have served this repo
holdDIDs, _ := l.cache.GetHoldDIDsForRepo(did, repo)
// 2. Query each hold for stats
var total Stats
for _, holdDID := range holdDIDs {
holdURL := resolveHoldDID(holdDID)
stats, _ := queryHoldStats(ctx, holdURL, did, repo)
total.PullCount += stats.PullCount
total.PushCount += stats.PushCount
}
return &total, nil
}
```
**Files to modify:**
- `pkg/atproto/lexicon.go` - Add `StatsCollection` + `StatsRecord`
- `pkg/hold/pds/stats.go` - New file for stats operations
- `pkg/hold/pds/xrpc.go` - Add `repo` param to getBlob, add stats endpoints
- `pkg/appview/storage/proxy_blob_store.go` - Pass repository to getBlob
- `pkg/appview/cache/loader.go` - Aggregation logic
### Migration Path
**Phase 1: Add Valkey infrastructure**
- Add Valkey client to AppView
- Create store interfaces that abstract SQLite vs Valkey
- Dual-write OAuth sessions to both
**Phase 2: Migrate sessions to Valkey**
- OAuth sessions, UI sessions, auth requests, pending device auth
- Remove SQLite session tables
- Test: restart AppView, users get logged out (acceptable)
**Phase 3: Migrate devices to Valkey**
- Move device store to Valkey
- Same data structure, different backend
- Consider device expiry policy
**Phase 4: Implement hold-side stats**
- Add `io.atcr.hold.stats` collection to hold's embedded PDS
- Hold increments stats on blob access
- Add XRPC endpoint: `io.atcr.hold.getStats`
**Phase 5: AppView stats aggregation**
- Track holdDids per repo in Valkey cache
- Query holds for stats, aggregate
- Cache aggregated stats with TTL
**Phase 6: Remove SQLite (optional)**
- Keep SQLite as optional cache layer for UI queries
- Or: Query PDS on demand with Valkey caching
- Jetstream still useful for real-time updates
## Summary Table
| Category | Tables | % of Schema | Truly Persistent? |
|----------|--------|-------------|-------------------|
| Auth & Sessions + Metrics | 6 | 32% | Yes |
| PDS Cache | 11 | 58% | No (rebuildable) |
| Operational | 2 | 10% | No |
**~58% of the database is cached ATProto data that could be rebuilt from PDSes.**

500
examples/plugins/README.md Normal file
View File

@@ -0,0 +1,500 @@
# ATProto Signature Verification Plugins and Examples
This directory contains reference implementations and examples for integrating ATProto signature verification into various tools and workflows.
## Overview
ATCR uses ATProto's native signature system to cryptographically sign container images. To integrate signature verification into existing tools (Kubernetes, CI/CD, container runtimes), you can:
1. **Build plugins** for verification frameworks (Ratify, Gatekeeper, Containerd)
2. **Use external services** called by policy engines
3. **Integrate CLI tools** in your CI/CD pipelines
## Directory Structure
```
examples/plugins/
├── README.md # This file
├── ratify-verifier/ # Ratify plugin for Kubernetes
│ ├── README.md
│ ├── verifier.go
│ ├── config.go
│ ├── resolver.go
│ ├── crypto.go
│ ├── Dockerfile
│ ├── deployment.yaml
│ └── verifier-crd.yaml
├── gatekeeper-provider/ # OPA Gatekeeper external provider
│ ├── README.md
│ ├── main.go
│ ├── verifier.go
│ ├── resolver.go
│ ├── crypto.go
│ ├── Dockerfile
│ ├── deployment.yaml
│ └── provider-crd.yaml
├── containerd-verifier/ # Containerd bindir plugin
│ ├── README.md
│ ├── main.go
│ └── Dockerfile
└── ci-cd/ # CI/CD integration examples
├── github-actions.yml
├── gitlab-ci.yml
└── jenkins-pipeline.groovy
```
## Quick Start
### For Kubernetes (Recommended)
**Option A: Ratify Plugin**
```bash
cd ratify-verifier
# Build plugin and deploy to Kubernetes
./build.sh
kubectl apply -f deployment.yaml
kubectl apply -f verifier-crd.yaml
```
**Option B: Gatekeeper Provider**
```bash
cd gatekeeper-provider
# Build and deploy external provider
docker build -t atcr.io/atcr/gatekeeper-provider:latest .
kubectl apply -f deployment.yaml
kubectl apply -f provider-crd.yaml
```
### For CI/CD
**GitHub Actions**
```yaml
# Copy examples/plugins/ci-cd/github-actions.yml to .github/workflows/
cp ci-cd/github-actions.yml ../.github/workflows/verify-and-deploy.yml
```
**GitLab CI**
```yaml
# Copy examples/plugins/ci-cd/gitlab-ci.yml to your repo
cp ci-cd/gitlab-ci.yml ../.gitlab-ci.yml
```
### For Containerd
```bash
cd containerd-verifier
# Build plugin
./build.sh
# Install to containerd plugins directory
sudo cp atcr-verifier /opt/containerd/bin/
```
## Plugins Overview
### Ratify Verifier Plugin ⭐
**Use case:** Kubernetes admission control with OPA Gatekeeper
**How it works:**
1. Gatekeeper receives pod creation request
2. Calls Ratify verification engine
3. Ratify loads ATProto verifier plugin
4. Plugin verifies signature and checks trust policy
5. Returns allow/deny decision to Gatekeeper
**Pros:**
- Standard Ratify plugin interface
- Works with existing Gatekeeper deployments
- Can combine with other verifiers (Notation, Cosign)
- Policy-based enforcement
**Cons:**
- Requires building custom Ratify image
- Plugin must be compiled into image
- More complex deployment
**See:** [ratify-verifier/README.md](./ratify-verifier/README.md)
### Gatekeeper External Provider ⭐
**Use case:** Kubernetes admission control with OPA Gatekeeper
**How it works:**
1. Gatekeeper receives pod creation request
2. Rego policy calls external data provider API
3. Provider verifies ATProto signature
4. Returns verification result to Gatekeeper
5. Rego policy makes allow/deny decision
**Pros:**
- Simpler deployment (separate service)
- Easy to update (no Gatekeeper changes)
- Flexible Rego policies
- Can add caching, rate limiting
**Cons:**
- Additional service to maintain
- Network dependency (provider must be reachable)
- Slightly higher latency
**See:** [gatekeeper-provider/README.md](./gatekeeper-provider/README.md)
### Containerd Bindir Plugin
**Use case:** Runtime-level verification for all images
**How it works:**
1. Containerd pulls image
2. Calls verifier plugin (bindir)
3. Plugin verifies ATProto signature
4. Returns result to containerd
5. Containerd allows/blocks image
**Pros:**
- Works at runtime level (not just Kubernetes)
- CRI-O, Podman support (CRI-compatible)
- No Kubernetes required
- Applies to all images
**Cons:**
- Containerd 2.0+ required
- More complex to debug
- Less flexible policies
**See:** [containerd-verifier/README.md](./containerd-verifier/README.md)
## CI/CD Integration Examples
### GitHub Actions
Complete workflow with:
- Image signature verification
- DID trust checking
- Automated deployment on success
**See:** [ci-cd/github-actions.yml](./ci-cd/github-actions.yml)
### GitLab CI
Pipeline with:
- Multi-stage verification
- Trust policy enforcement
- Manual deployment approval
**See:** [ci-cd/gitlab-ci.yml](./ci-cd/gitlab-ci.yml)
### Jenkins
Declarative pipeline with:
- Signature verification stage
- Deployment gates
- Rollback on failure
**See:** [ci-cd/jenkins-pipeline.groovy](./ci-cd/jenkins-pipeline.groovy) (coming soon)
## Common Components
All plugins share common functionality:
### DID Resolution
Resolve DID to public key:
```go
func ResolveDIDToPublicKey(ctx context.Context, did string) (*PublicKey, error)
```
**Steps:**
1. Fetch DID document from PLC directory or did:web
2. Extract verification method
3. Decode multibase public key
4. Parse as K-256 public key
### PDS Communication
Fetch repository commit:
```go
func FetchCommit(ctx context.Context, pdsEndpoint, did, commitCID string) (*Commit, error)
```
**Steps:**
1. Call `com.atproto.sync.getRepo` XRPC endpoint
2. Parse CAR file response
3. Extract commit with matching CID
4. Return commit data and signature
### Signature Verification
Verify ECDSA K-256 signature:
```go
func VerifySignature(pubKey *PublicKey, commit *Commit) error
```
**Steps:**
1. Extract unsigned commit bytes
2. Hash with SHA-256
3. Verify ECDSA signature over hash
4. Check signature is valid for public key
### Trust Policy
Check if DID is trusted:
```go
func IsTrusted(did string, now time.Time) bool
```
**Steps:**
1. Load trust policy from config
2. Check if DID in trusted list
3. Verify validFrom/expiresAt timestamps
4. Return true if trusted
## Trust Policy Format
All plugins use the same trust policy format:
```yaml
version: 1.0
trustedDIDs:
did:plc:alice123:
name: "Alice (DevOps Lead)"
validFrom: "2024-01-01T00:00:00Z"
expiresAt: null
did:plc:bob456:
name: "Bob (Security Team)"
validFrom: "2024-06-01T00:00:00Z"
expiresAt: "2025-12-31T23:59:59Z"
policies:
- name: production-images
scope: "atcr.io/*/prod-*"
require:
signature: true
trustedDIDs:
- did:plc:alice123
- did:plc:bob456
minSignatures: 1
action: enforce
- name: dev-images
scope: "atcr.io/*/dev-*"
require:
signature: false
action: audit
```
## Implementation Notes
### Dependencies
All plugins require:
- Go 1.21+ for building
- ATProto DID resolution (PLC directory, did:web)
- ATProto PDS XRPC API access
- ECDSA K-256 signature verification
### Caching
Recommended caching strategy:
- **DID documents**: 5 minute TTL
- **Public keys**: 5 minute TTL
- **PDS endpoints**: 5 minute TTL
- **Signature results**: 5 minute TTL
### Error Handling
Plugins should handle:
- DID resolution failures (network, invalid DID)
- PDS connectivity issues (timeout, 404, 500)
- Invalid signature format
- Untrusted DIDs
- Network timeouts
### Logging
Structured logging with:
- `image` - Image being verified
- `did` - Signer DID
- `duration` - Operation duration
- `error` - Error message (if failed)
### Metrics
Expose Prometheus metrics:
- `atcr_verifications_total{result="verified|failed|error"}`
- `atcr_verification_duration_seconds`
- `atcr_did_resolutions_total{result="success|failure"}`
- `atcr_cache_hits_total`
- `atcr_cache_misses_total`
## Testing
### Unit Tests
Test individual components:
```bash
# Test DID resolution
go test ./pkg/resolver -v
# Test signature verification
go test ./pkg/crypto -v
# Test trust policy
go test ./pkg/trust -v
```
### Integration Tests
Test with real services:
```bash
# Test against ATCR registry
go test ./integration -tags=integration -v
# Test with test PDS
go test ./integration -tags=integration -pds=https://test.pds.example.com
```
### End-to-End Tests
Test full deployment:
```bash
# Deploy to test cluster
kubectl apply -f test/fixtures/
# Create pod with signed image (should succeed)
kubectl run test-signed --image=atcr.io/test/signed:latest
# Create pod with unsigned image (should fail)
kubectl run test-unsigned --image=atcr.io/test/unsigned:latest
```
## Performance Considerations
### Latency
Typical verification latency:
- DID resolution: 50-200ms (cached: <1ms)
- PDS query: 100-500ms (cached: <1ms)
- Signature verification: 1-5ms
- **Total**: 150-700ms (uncached), <10ms (cached)
### Throughput
Expected throughput (single instance):
- Without caching: ~5-10 verifications/second
- With caching: ~100-500 verifications/second
### Scaling
For high traffic:
- Deploy multiple replicas (stateless)
- Use Redis for distributed caching
- Implement rate limiting
- Monitor P95/P99 latency
## Security Considerations
### Network Policies
Restrict access to:
- DID resolution (PLC directory only)
- PDS XRPC endpoints
- Internal services only
### Denial of Service
Protect against:
- High verification request rate
- Slow DID resolution
- Malicious images with many signatures
- Large signature artifacts
### Trust Model
Understand trust dependencies:
- DID resolution is accurate (PLC directory)
- PDS serves correct records
- Private keys are secure
- Trust policy is maintained
## Troubleshooting
### Plugin Not Loading
```bash
# Check plugin exists
ls -la /path/to/plugin
# Check plugin is executable
chmod +x /path/to/plugin
# Check plugin logs
tail -f /var/log/atcr-verifier.log
```
### Verification Failing
```bash
# Test DID resolution
curl https://plc.directory/did:plc:alice123
# Test PDS connectivity
curl https://bsky.social/xrpc/com.atproto.server.describeServer
# Test signature exists
oras discover atcr.io/alice/myapp:latest \
--artifact-type application/vnd.atproto.signature.v1+json
```
### Policy Not Enforcing
```bash
# Check policy is loaded
kubectl get configmap atcr-trust-policy -n gatekeeper-system
# Check constraint is active
kubectl get constraint atcr-signatures-required -o yaml
# Check logs
kubectl logs -n gatekeeper-system deployment/ratify
```
## See Also
### Documentation
- [ATProto Signatures](../../docs/ATPROTO_SIGNATURES.md) - Technical deep-dive
- [Signature Integration](../../docs/SIGNATURE_INTEGRATION.md) - Tool-specific guides
- [Integration Strategy](../../docs/INTEGRATION_STRATEGY.md) - High-level overview
- [atcr-verify CLI](../../docs/ATCR_VERIFY_CLI.md) - CLI tool specification
### Examples
- [Verification Scripts](../verification/) - Shell scripts for manual verification
- [Kubernetes Webhook](../verification/kubernetes-webhook.yaml) - Custom webhook example
### External Resources
- [Ratify](https://ratify.dev/) - Verification framework
- [OPA Gatekeeper](https://open-policy-agent.github.io/gatekeeper/) - Policy engine
- [Containerd](https://containerd.io/) - Container runtime
## Support
For questions or issues:
- GitHub Issues: https://github.com/atcr-io/atcr/issues
- Documentation: https://docs.atcr.io
- Security: security@atcr.io
## Contributing
Contributions welcome! Please:
1. Follow existing code structure
2. Add tests for new features
3. Update documentation
4. Submit pull request
## License
See [LICENSE](../../LICENSE) file in repository root.

View File

@@ -0,0 +1,166 @@
# GitHub Actions workflow for verifying ATProto signatures
name: Verify and Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: atcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
verify-signature:
name: Verify Image Signature
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up image tag
id: vars
run: |
echo "IMAGE_TAG=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}" >> $GITHUB_OUTPUT
- name: Install verification tools
run: |
# Install ORAS
curl -LO https://github.com/oras-project/oras/releases/download/v1.0.0/oras_1.0.0_linux_amd64.tar.gz
tar -xzf oras_1.0.0_linux_amd64.tar.gz
sudo mv oras /usr/local/bin/
# Install crane
curl -sL "https://github.com/google/go-containerregistry/releases/download/v0.15.2/go-containerregistry_Linux_x86_64.tar.gz" > crane.tar.gz
tar -xzf crane.tar.gz
sudo mv crane /usr/local/bin/
# Install atcr-verify (when available)
# curl -LO https://github.com/atcr-io/atcr/releases/latest/download/atcr-verify
# chmod +x atcr-verify
# sudo mv atcr-verify /usr/local/bin/
- name: Check for signature
id: check_signature
run: |
IMAGE="${{ steps.vars.outputs.IMAGE_TAG }}"
echo "Checking signature for $IMAGE"
# Get image digest
DIGEST=$(crane digest "$IMAGE")
echo "Image digest: $DIGEST"
# Check for ATProto signature using ORAS
REPO=$(echo "$IMAGE" | cut -d: -f1)
REFERRERS=$(curl -s "https://${{ env.REGISTRY }}/v2/${REPO#${{ env.REGISTRY }}/}/referrers/${DIGEST}?artifactType=application/vnd.atproto.signature.v1+json")
SIG_COUNT=$(echo "$REFERRERS" | jq '.manifests | length')
if [ "$SIG_COUNT" -eq 0 ]; then
echo "❌ No ATProto signature found"
echo "has_signature=false" >> $GITHUB_OUTPUT
exit 1
fi
echo "✓ Found $SIG_COUNT signature(s)"
echo "has_signature=true" >> $GITHUB_OUTPUT
- name: Verify signature (full verification)
if: steps.check_signature.outputs.has_signature == 'true'
run: |
IMAGE="${{ steps.vars.outputs.IMAGE_TAG }}"
# Option 1: Use atcr-verify CLI (when available)
# atcr-verify "$IMAGE" --policy .atcr/trust-policy.yaml
# Option 2: Use shell script
chmod +x examples/verification/atcr-verify.sh
./examples/verification/atcr-verify.sh "$IMAGE"
echo "✓ Signature verified successfully"
- name: Verify signer DID
if: steps.check_signature.outputs.has_signature == 'true'
run: |
IMAGE="${{ steps.vars.outputs.IMAGE_TAG }}"
# Get signature metadata
DIGEST=$(crane digest "$IMAGE")
REPO=$(echo "$IMAGE" | cut -d: -f1)
REFERRERS=$(curl -s "https://${{ env.REGISTRY }}/v2/${REPO#${{ env.REGISTRY }}/}/referrers/${DIGEST}?artifactType=application/vnd.atproto.signature.v1+json")
SIG_DIGEST=$(echo "$REFERRERS" | jq -r '.manifests[0].digest')
# Pull signature artifact
oras pull "${REPO}@${SIG_DIGEST}" -o /tmp/sig
# Extract DID
DID=$(jq -r '.atproto.did' /tmp/sig/atproto-signature.json)
echo "Signed by DID: $DID"
# Check against trusted DIDs
TRUSTED_DIDS="${{ secrets.TRUSTED_DIDS }}" # e.g., "did:plc:alice123,did:plc:bob456"
if [[ ",$TRUSTED_DIDS," == *",$DID,"* ]]; then
echo "✓ DID is trusted"
else
echo "❌ DID $DID is not in trusted list"
exit 1
fi
deploy:
name: Deploy to Kubernetes
needs: verify-signature
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up image tag
id: vars
run: |
echo "IMAGE_TAG=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}" >> $GITHUB_OUTPUT
- name: Set up kubectl
uses: azure/setup-kubectl@v3
- name: Configure kubectl
run: |
echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > /tmp/kubeconfig
export KUBECONFIG=/tmp/kubeconfig
- name: Deploy to production
run: |
kubectl set image deployment/myapp \
myapp=${{ steps.vars.outputs.IMAGE_TAG }} \
-n production
kubectl rollout status deployment/myapp -n production
- name: Verify deployment
run: |
kubectl get pods -n production -l app=myapp
# Wait for rollout to complete
kubectl wait --for=condition=available --timeout=300s \
deployment/myapp -n production
# Alternative: Use atcr-verify action (when available)
verify-with-action:
name: Verify with ATCR Action
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Verify image signature
# uses: atcr-io/atcr-verify-action@v1
# with:
# image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
# policy: .atcr/trust-policy.yaml
# fail-on-error: true
run: |
echo "TODO: Use official atcr-verify GitHub Action"

View File

@@ -0,0 +1,156 @@
# GitLab CI pipeline for verifying ATProto signatures
variables:
REGISTRY: atcr.io
IMAGE_NAME: $CI_PROJECT_PATH
IMAGE_TAG: $REGISTRY/$IMAGE_NAME:$CI_COMMIT_SHA
stages:
- build
- verify
- deploy
build_image:
stage: build
image: docker:latest
services:
- docker:dind
script:
- docker build -t $IMAGE_TAG .
- docker push $IMAGE_TAG
verify_signature:
stage: verify
image: alpine:latest
before_script:
- apk add --no-cache curl jq
script:
- |
echo "Verifying signature for $IMAGE_TAG"
# Install crane
wget https://github.com/google/go-containerregistry/releases/download/v0.15.2/go-containerregistry_Linux_x86_64.tar.gz
tar -xzf go-containerregistry_Linux_x86_64.tar.gz crane
mv crane /usr/local/bin/
# Get image digest
DIGEST=$(crane digest "$IMAGE_TAG")
echo "Image digest: $DIGEST"
# Extract repository path
REPO=$(echo "$IMAGE_TAG" | cut -d: -f1)
REPO_PATH=${REPO#$REGISTRY/}
# Check for ATProto signature
REFERRERS=$(curl -s "https://$REGISTRY/v2/$REPO_PATH/referrers/${DIGEST}?artifactType=application/vnd.atproto.signature.v1+json")
SIG_COUNT=$(echo "$REFERRERS" | jq '.manifests | length')
if [ "$SIG_COUNT" -eq 0 ]; then
echo "❌ No ATProto signature found"
exit 1
fi
echo "✓ Found $SIG_COUNT signature(s)"
verify_full:
stage: verify
image: alpine:latest
before_script:
- apk add --no-cache curl jq bash
script:
- |
# Option 1: Use atcr-verify CLI (when available)
# wget https://github.com/atcr-io/atcr/releases/latest/download/atcr-verify
# chmod +x atcr-verify
# ./atcr-verify "$IMAGE_TAG" --policy .atcr/trust-policy.yaml
# Option 2: Use shell script
chmod +x examples/verification/atcr-verify.sh
./examples/verification/atcr-verify.sh "$IMAGE_TAG"
echo "✓ Signature verified successfully"
verify_trust:
stage: verify
image: alpine:latest
before_script:
- apk add --no-cache curl jq
script:
- |
# Install crane and ORAS
wget https://github.com/google/go-containerregistry/releases/download/v0.15.2/go-containerregistry_Linux_x86_64.tar.gz
tar -xzf go-containerregistry_Linux_x86_64.tar.gz crane
mv crane /usr/local/bin/
wget https://github.com/oras-project/oras/releases/download/v1.0.0/oras_1.0.0_linux_amd64.tar.gz
tar -xzf oras_1.0.0_linux_amd64.tar.gz
mv oras /usr/local/bin/
# Get signature metadata
DIGEST=$(crane digest "$IMAGE_TAG")
REPO=$(echo "$IMAGE_TAG" | cut -d: -f1)
REPO_PATH=${REPO#$REGISTRY/}
REFERRERS=$(curl -s "https://$REGISTRY/v2/$REPO_PATH/referrers/${DIGEST}?artifactType=application/vnd.atproto.signature.v1+json")
SIG_DIGEST=$(echo "$REFERRERS" | jq -r '.manifests[0].digest')
# Pull signature artifact
oras pull "${REPO}@${SIG_DIGEST}" -o /tmp/sig
# Extract DID
DID=$(jq -r '.atproto.did' /tmp/sig/atproto-signature.json)
echo "Signed by DID: $DID"
# Check against trusted DIDs (from CI/CD variables)
if [[ ",$TRUSTED_DIDS," == *",$DID,"* ]]; then
echo "✓ DID is trusted"
else
echo "❌ DID $DID is not in trusted list"
exit 1
fi
deploy_production:
stage: deploy
image: bitnami/kubectl:latest
dependencies:
- verify_signature
- verify_full
- verify_trust
only:
- main
script:
- |
# Configure kubectl
echo "$KUBE_CONFIG" | base64 -d > /tmp/kubeconfig
export KUBECONFIG=/tmp/kubeconfig
# Deploy to production
kubectl set image deployment/myapp \
myapp=$IMAGE_TAG \
-n production
kubectl rollout status deployment/myapp -n production
# Verify deployment
kubectl get pods -n production -l app=myapp
# Alternative: Manual approval before deploy
deploy_production_manual:
stage: deploy
image: bitnami/kubectl:latest
dependencies:
- verify_signature
when: manual
only:
- main
script:
- |
echo "Deploying $IMAGE_TAG to production"
echo "$KUBE_CONFIG" | base64 -d > /tmp/kubeconfig
export KUBECONFIG=/tmp/kubeconfig
kubectl set image deployment/myapp \
myapp=$IMAGE_TAG \
-n production

View File

@@ -0,0 +1,501 @@
# OPA Gatekeeper External Data Provider for ATProto Signatures
This is a reference implementation of an OPA Gatekeeper External Data Provider that verifies ATProto signatures on ATCR container images.
## Overview
Gatekeeper's External Data Provider feature allows Rego policies to call external HTTP services for data validation. This provider implements signature verification as an HTTP service that Gatekeeper can query.
## Architecture
```
Kubernetes Pod Creation
OPA Gatekeeper (admission webhook)
Rego Policy (constraint template)
External Data Provider API call
ATProto Verification Service ← This service
1. Resolve image digest
2. Discover signature artifacts
3. Parse ATProto signature metadata
4. Resolve DID to public key
5. Fetch commit from PDS
6. Verify K-256 signature
7. Check trust policy
Return: verified=true/false + metadata
```
## Files
- `main.go` - HTTP server and provider endpoints
- `verifier.go` - ATProto signature verification logic
- `resolver.go` - DID and PDS resolution
- `crypto.go` - K-256 signature verification
- `trust-policy.yaml` - Trust policy configuration
- `Dockerfile` - Build provider service image
- `deployment.yaml` - Kubernetes deployment manifest
- `provider-crd.yaml` - Gatekeeper Provider custom resource
- `constraint-template.yaml` - Rego constraint template
- `constraint.yaml` - Policy constraint example
## Prerequisites
- Go 1.21+
- Kubernetes cluster with OPA Gatekeeper installed
- Access to ATCR registry
## Building
```bash
# Build binary
CGO_ENABLED=0 go build -o atcr-provider \
-ldflags="-w -s" \
./main.go
# Build Docker image
docker build -t atcr.io/atcr/gatekeeper-provider:latest .
# Push to registry
docker push atcr.io/atcr/gatekeeper-provider:latest
```
## Deployment
### 1. Create Trust Policy ConfigMap
```bash
kubectl create namespace gatekeeper-system
kubectl create configmap atcr-trust-policy \
--from-file=trust-policy.yaml \
-n gatekeeper-system
```
### 2. Deploy Provider Service
```bash
kubectl apply -f deployment.yaml
```
### 3. Configure Gatekeeper Provider
```bash
kubectl apply -f provider-crd.yaml
```
### 4. Create Constraint Template
```bash
kubectl apply -f constraint-template.yaml
```
### 5. Create Constraint
```bash
kubectl apply -f constraint.yaml
```
### 6. Test
```bash
# Try to create pod with signed image (should succeed)
kubectl run test-signed --image=atcr.io/alice/myapp:latest
# Try to create pod with unsigned image (should fail)
kubectl run test-unsigned --image=atcr.io/malicious/fake:latest
# Check constraint status
kubectl get constraint atcr-signatures-required -o yaml
```
## API Specification
### Provider Endpoint
**POST /provide**
Request:
```json
{
"keys": ["image"],
"values": [
"atcr.io/alice/myapp:latest",
"atcr.io/bob/webapp:v1.0"
]
}
```
Response:
```json
{
"responses": [
{
"image": "atcr.io/alice/myapp:latest",
"verified": true,
"did": "did:plc:alice123",
"handle": "alice.bsky.social",
"signedAt": "2025-10-31T12:34:56Z",
"commitCid": "bafyreih8..."
},
{
"image": "atcr.io/bob/webapp:v1.0",
"verified": false,
"error": "no signature found"
}
]
}
```
### Health Check
**GET /health**
Response:
```json
{
"status": "ok",
"version": "1.0.0"
}
```
## Configuration
### Trust Policy Format
```yaml
# trust-policy.yaml
version: 1.0
trustedDIDs:
did:plc:alice123:
name: "Alice (DevOps)"
validFrom: "2024-01-01T00:00:00Z"
expiresAt: null
did:plc:bob456:
name: "Bob (Security)"
validFrom: "2024-06-01T00:00:00Z"
expiresAt: "2025-12-31T23:59:59Z"
policies:
- name: production
scope: "atcr.io/*/prod-*"
require:
signature: true
trustedDIDs:
- did:plc:alice123
- did:plc:bob456
action: enforce
```
### Provider Configuration
Environment variables:
- `TRUST_POLICY_PATH` - Path to trust policy file (default: `/config/trust-policy.yaml`)
- `HTTP_PORT` - HTTP server port (default: `8080`)
- `LOG_LEVEL` - Log level: debug, info, warn, error (default: `info`)
- `CACHE_ENABLED` - Enable caching (default: `true`)
- `CACHE_TTL` - Cache TTL in seconds (default: `300`)
- `DID_RESOLVER_TIMEOUT` - DID resolution timeout (default: `10s`)
- `PDS_TIMEOUT` - PDS XRPC timeout (default: `10s`)
## Rego Policy Examples
### Simple Verification
```rego
package atcrsignatures
import future.keywords.contains
import future.keywords.if
import future.keywords.in
provider := "atcr-verifier"
violation[{"msg": msg}] {
container := input.review.object.spec.containers[_]
startswith(container.image, "atcr.io/")
# Call external provider
response := external_data({
"provider": provider,
"keys": ["image"],
"values": [container.image]
})
# Check verification result
not response[_].verified == true
msg := sprintf("Image %v has no valid ATProto signature", [container.image])
}
```
### Advanced Verification with DID Trust
```rego
package atcrsignatures
import future.keywords.contains
import future.keywords.if
import future.keywords.in
provider := "atcr-verifier"
trusted_dids := [
"did:plc:alice123",
"did:plc:bob456"
]
violation[{"msg": msg}] {
container := input.review.object.spec.containers[_]
startswith(container.image, "atcr.io/")
# Call external provider
response := external_data({
"provider": provider,
"keys": ["image"],
"values": [container.image]
})
# Get response for this image
result := response[_]
result.image == container.image
# Check if verified
not result.verified == true
msg := sprintf("Image %v failed signature verification: %v", [container.image, result.error])
}
violation[{"msg": msg}] {
container := input.review.object.spec.containers[_]
startswith(container.image, "atcr.io/")
# Call external provider
response := external_data({
"provider": provider,
"keys": ["image"],
"values": [container.image]
})
# Get response for this image
result := response[_]
result.image == container.image
result.verified == true
# Check DID is trusted
not result.did in trusted_dids
msg := sprintf("Image %v signed by untrusted DID: %v", [container.image, result.did])
}
```
### Namespace-Specific Policies
```rego
package atcrsignatures
import future.keywords.contains
import future.keywords.if
import future.keywords.in
provider := "atcr-verifier"
# Production namespaces require signatures
production_namespaces := ["production", "prod", "staging"]
violation[{"msg": msg}] {
# Only apply to production namespaces
input.review.object.metadata.namespace in production_namespaces
container := input.review.object.spec.containers[_]
startswith(container.image, "atcr.io/")
# Call external provider
response := external_data({
"provider": provider,
"keys": ["image"],
"values": [container.image]
})
# Check verification result
not response[_].verified == true
msg := sprintf("Production namespace requires signed images. Image %v is not signed", [container.image])
}
```
## Performance Considerations
### Caching
The provider caches:
- Signature verification results (TTL: 5 minutes)
- DID documents (TTL: 5 minutes)
- PDS endpoints (TTL: 5 minutes)
- Public keys (TTL: 5 minutes)
Enable/disable via `CACHE_ENABLED` environment variable.
### Timeouts
- `DID_RESOLVER_TIMEOUT` - DID resolution timeout (default: 10s)
- `PDS_TIMEOUT` - PDS XRPC calls timeout (default: 10s)
- HTTP client timeout: 30s total
### Horizontal Scaling
The provider is stateless and can be scaled horizontally:
```yaml
apiVersion: apps/v1
kind: Deployment
spec:
replicas: 3 # Scale up for high traffic
```
### Rate Limiting
Consider implementing rate limiting for:
- Gatekeeper → Provider requests
- Provider → DID resolver
- Provider → PDS
## Monitoring
### Metrics
The provider exposes Prometheus metrics at `/metrics`:
```
# Request metrics
atcr_provider_requests_total{status="success|failure"}
atcr_provider_request_duration_seconds
# Verification metrics
atcr_provider_verifications_total{result="verified|failed|error"}
atcr_provider_verification_duration_seconds
# Cache metrics
atcr_provider_cache_hits_total
atcr_provider_cache_misses_total
```
### Logging
Structured JSON logging with fields:
- `image` - Image being verified
- `did` - Signer DID (if found)
- `duration` - Verification duration
- `error` - Error message (if failed)
### Health Checks
```bash
# Liveness probe
curl http://localhost:8080/health
# Readiness probe
curl http://localhost:8080/ready
```
## Troubleshooting
### Provider Not Reachable
```bash
# Check provider pod status
kubectl get pods -n gatekeeper-system -l app=atcr-provider
# Check service
kubectl get svc -n gatekeeper-system atcr-provider
# Test connectivity from Gatekeeper pod
kubectl exec -n gatekeeper-system deployment/gatekeeper-controller-manager -- \
curl http://atcr-provider.gatekeeper-system/health
```
### Verification Failing
```bash
# Check provider logs
kubectl logs -n gatekeeper-system deployment/atcr-provider
# Test verification manually
kubectl run test-curl --rm -it --image=curlimages/curl -- \
curl -X POST http://atcr-provider.gatekeeper-system/provide \
-H "Content-Type: application/json" \
-d '{"keys":["image"],"values":["atcr.io/alice/myapp:latest"]}'
```
### Policy Not Enforcing
```bash
# Check Gatekeeper logs
kubectl logs -n gatekeeper-system deployment/gatekeeper-controller-manager
# Check constraint status
kubectl get constraint atcr-signatures-required -o yaml
# Test policy manually with conftest
conftest test -p constraint-template.yaml pod.yaml
```
## Security Considerations
### Network Policies
Restrict network access:
```yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: atcr-provider
namespace: gatekeeper-system
spec:
podSelector:
matchLabels:
app: atcr-provider
ingress:
- from:
- podSelector:
matchLabels:
control-plane: controller-manager # Gatekeeper
ports:
- port: 8080
egress:
- to: # PLC directory
- namespaceSelector: {}
ports:
- port: 443
```
### Authentication
The provider should only be accessible from Gatekeeper. Options:
- Network policies (recommended for Kubernetes)
- Mutual TLS
- API tokens
### Trust Policy Management
- Store trust policy in version control
- Use GitOps (Flux, ArgoCD) for updates
- Review DID changes carefully
- Audit policy modifications
## See Also
- [Gatekeeper Documentation](https://open-policy-agent.github.io/gatekeeper/)
- [External Data Provider](https://open-policy-agent.github.io/gatekeeper/website/docs/externaldata/)
- [ATCR Signature Integration](../../../docs/SIGNATURE_INTEGRATION.md)
- [ATCR Integration Strategy](../../../docs/INTEGRATION_STRATEGY.md)
## Support
For issues or questions:
- GitHub Issues: https://github.com/atcr-io/atcr/issues
- Gatekeeper GitHub: https://github.com/open-policy-agent/gatekeeper

View File

@@ -0,0 +1,225 @@
// Package main implements an OPA Gatekeeper External Data Provider for ATProto signature verification.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"time"
)
const (
// DefaultPort is the default HTTP port
DefaultPort = "8080"
// DefaultTrustPolicyPath is the default trust policy file path
DefaultTrustPolicyPath = "/config/trust-policy.yaml"
)
// Server is the HTTP server for the external data provider.
type Server struct {
verifier *Verifier
port string
httpServer *http.Server
}
// ProviderRequest is the request format from Gatekeeper.
type ProviderRequest struct {
Keys []string `json:"keys"`
Values []string `json:"values"`
}
// ProviderResponse is the response format to Gatekeeper.
type ProviderResponse struct {
SystemError string `json:"system_error,omitempty"`
Responses []map[string]interface{} `json:"responses"`
}
// VerificationResult holds the result of verifying a single image.
type VerificationResult struct {
Image string `json:"image"`
Verified bool `json:"verified"`
DID string `json:"did,omitempty"`
Handle string `json:"handle,omitempty"`
SignedAt time.Time `json:"signedAt,omitempty"`
CommitCID string `json:"commitCid,omitempty"`
Error string `json:"error,omitempty"`
}
// NewServer creates a new provider server.
func NewServer(verifier *Verifier, port string) *Server {
return &Server{
verifier: verifier,
port: port,
}
}
// Start starts the HTTP server.
func (s *Server) Start() error {
mux := http.NewServeMux()
// Provider endpoint (called by Gatekeeper)
mux.HandleFunc("/provide", s.handleProvide)
// Health check endpoints
mux.HandleFunc("/health", s.handleHealth)
mux.HandleFunc("/ready", s.handleReady)
// Metrics endpoint (Prometheus)
// TODO: Implement metrics
// mux.HandleFunc("/metrics", s.handleMetrics)
s.httpServer = &http.Server{
Addr: ":" + s.port,
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Printf("Starting ATProto signature verification provider on port %s", s.port)
return s.httpServer.ListenAndServe()
}
// Stop gracefully stops the HTTP server.
func (s *Server) Stop(ctx context.Context) error {
if s.httpServer != nil {
return s.httpServer.Shutdown(ctx)
}
return nil
}
// handleProvide handles the provider endpoint called by Gatekeeper.
func (s *Server) handleProvide(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse request
var req ProviderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("ERROR: failed to parse request: %v", err)
http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest)
return
}
log.Printf("INFO: received verification request for %d images", len(req.Values))
// Verify each image
responses := make([]map[string]interface{}, 0, len(req.Values))
for _, image := range req.Values {
result := s.verifyImage(r.Context(), image)
responses = append(responses, structToMap(result))
}
// Send response
resp := ProviderResponse{
Responses: responses,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Printf("ERROR: failed to encode response: %v", err)
}
}
// verifyImage verifies a single image.
func (s *Server) verifyImage(ctx context.Context, image string) VerificationResult {
start := time.Now()
log.Printf("INFO: verifying image: %s", image)
// Call verifier
verified, metadata, err := s.verifier.Verify(ctx, image)
duration := time.Since(start)
if err != nil {
log.Printf("ERROR: verification failed for %s: %v (duration: %v)", image, err, duration)
return VerificationResult{
Image: image,
Verified: false,
Error: err.Error(),
}
}
if !verified {
log.Printf("WARN: image %s failed verification (duration: %v)", image, duration)
return VerificationResult{
Image: image,
Verified: false,
Error: "signature verification failed",
}
}
log.Printf("INFO: image %s verified successfully (DID: %s, duration: %v)",
image, metadata.DID, duration)
return VerificationResult{
Image: image,
Verified: true,
DID: metadata.DID,
Handle: metadata.Handle,
SignedAt: metadata.SignedAt,
CommitCID: metadata.CommitCID,
}
}
// handleHealth handles health check requests.
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"version": "1.0.0",
})
}
// handleReady handles readiness check requests.
func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
// TODO: Check dependencies (DID resolver, PDS connectivity)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ready",
})
}
// structToMap converts a struct to a map for JSON encoding.
func structToMap(v interface{}) map[string]interface{} {
data, _ := json.Marshal(v)
var m map[string]interface{}
json.Unmarshal(data, &m)
return m
}
func main() {
// Load configuration
port := os.Getenv("HTTP_PORT")
if port == "" {
port = DefaultPort
}
trustPolicyPath := os.Getenv("TRUST_POLICY_PATH")
if trustPolicyPath == "" {
trustPolicyPath = DefaultTrustPolicyPath
}
// Create verifier
verifier, err := NewVerifier(trustPolicyPath)
if err != nil {
log.Fatalf("FATAL: failed to create verifier: %v", err)
}
// Create server
server := NewServer(verifier, port)
// Start server
if err := server.Start(); err != nil && err != http.ErrServerClosed {
log.Fatalf("FATAL: server error: %v", err)
}
}
// TODO: Implement verifier.go with ATProto signature verification logic
// TODO: Implement resolver.go with DID resolution
// TODO: Implement crypto.go with K-256 signature verification

View File

@@ -0,0 +1,304 @@
# Ratify ATProto Verifier Plugin
This is a reference implementation of a Ratify verifier plugin for ATProto signatures.
## Overview
Ratify is a verification framework that integrates with OPA Gatekeeper to enforce signature policies in Kubernetes. This plugin adds support for verifying ATProto signatures on ATCR container images.
## Architecture
```
Kubernetes Pod Creation
OPA Gatekeeper (admission webhook)
Ratify (verification engine)
ATProto Verifier Plugin ← This plugin
1. Fetch signature artifact from registry
2. Parse ATProto signature metadata
3. Resolve DID to public key
4. Fetch repository commit from PDS
5. Verify ECDSA K-256 signature
6. Check trust policy
Return: Allow/Deny
```
## Files
- `verifier.go` - Main verifier implementation
- `config.go` - Configuration and trust policy
- `resolver.go` - DID and PDS resolution
- `crypto.go` - K-256 signature verification
- `Dockerfile` - Build custom Ratify image with plugin
- `deployment.yaml` - Kubernetes deployment manifest
- `verifier-crd.yaml` - Ratify Verifier custom resource
## Prerequisites
- Go 1.21+
- Ratify source code (for building plugin)
- Kubernetes cluster with OPA Gatekeeper installed
- Access to ATCR registry
## Building
```bash
# Clone Ratify
git clone https://github.com/ratify-project/ratify.git
cd ratify
# Copy plugin files
cp -r /path/to/examples/plugins/ratify-verifier plugins/verifier/atproto/
# Build plugin
CGO_ENABLED=0 go build -o atproto-verifier \
-ldflags="-w -s" \
./plugins/verifier/atproto
# Build custom Ratify image with plugin
docker build -f Dockerfile.with-atproto -t atcr.io/atcr/ratify-with-atproto:latest .
```
## Deployment
### 1. Deploy Ratify with Plugin
```bash
# Push custom image
docker push atcr.io/atcr/ratify-with-atproto:latest
# Deploy Ratify
kubectl apply -f deployment.yaml
```
### 2. Configure Verifier
```bash
# Create Verifier custom resource
kubectl apply -f verifier-crd.yaml
```
### 3. Configure Trust Policy
```bash
# Create ConfigMap with trust policy
kubectl create configmap atcr-trust-policy \
--from-file=trust-policy.yaml \
-n gatekeeper-system
```
### 4. Create Gatekeeper Constraint
```bash
kubectl apply -f constraint.yaml
```
### 5. Test
```bash
# Try to create pod with signed image (should succeed)
kubectl run test-signed --image=atcr.io/alice/myapp:latest
# Try to create pod with unsigned image (should fail)
kubectl run test-unsigned --image=atcr.io/malicious/fake:latest
```
## Configuration
### Trust Policy Format
```yaml
# trust-policy.yaml
version: 1.0
trustedDIDs:
did:plc:alice123:
name: "Alice (DevOps)"
validFrom: "2024-01-01T00:00:00Z"
expiresAt: null
did:plc:bob456:
name: "Bob (Security)"
validFrom: "2024-06-01T00:00:00Z"
expiresAt: "2025-12-31T23:59:59Z"
policies:
- name: production
scope: "atcr.io/*/prod-*"
require:
signature: true
trustedDIDs:
- did:plc:alice123
- did:plc:bob456
action: enforce
```
### Verifier Configuration
```yaml
apiVersion: config.ratify.deislabs.io/v1beta1
kind: Verifier
metadata:
name: atproto-verifier
spec:
name: atproto
artifactType: application/vnd.atproto.signature.v1+json
address: /.ratify/plugins/atproto-verifier
parameters:
trustPolicyPath: /config/trust-policy.yaml
didResolverTimeout: 10s
pdsTimeout: 10s
cacheEnabled: true
cacheTTL: 300s
```
## Implementation Details
### Verifier Interface
The plugin implements Ratify's `ReferenceVerifier` interface:
```go
type ReferenceVerifier interface {
Name() string
Type() string
CanVerify(artifactType string) bool
VerifyReference(
ctx context.Context,
subjectRef common.Reference,
referenceDesc ocispecs.ReferenceDescriptor,
store referrerstore.ReferrerStore,
) (VerifierResult, error)
}
```
### Verification Flow
1. **Artifact Fetch**: Download signature artifact from registry via Ratify's store
2. **Parse Metadata**: Extract ATProto signature metadata (DID, PDS, commit CID)
3. **DID Resolution**: Resolve DID to public key via PLC directory or did:web
4. **Commit Fetch**: Get repository commit from PDS via XRPC
5. **Signature Verify**: Verify ECDSA K-256 signature over commit bytes
6. **Trust Check**: Validate DID against trust policy
7. **Result**: Return success/failure with metadata
### Error Handling
The plugin returns detailed error information:
```go
type VerifierResult struct {
IsSuccess bool
Name string
Type string
Message string
Extensions map[string]interface{}
}
```
**Extensions include:**
- `did` - Signer's DID
- `handle` - Signer's handle (if available)
- `signedAt` - Signature timestamp
- `commitCid` - ATProto commit CID
- `pdsEndpoint` - PDS URL
- `error` - Error details (if verification failed)
## Troubleshooting
### Plugin Not Found
```bash
# Check plugin is in image
kubectl exec -n gatekeeper-system deployment/ratify -c ratify -- ls -la /.ratify/plugins/
# Check logs
kubectl logs -n gatekeeper-system deployment/ratify -c ratify
```
### Verification Failing
```bash
# Check Ratify logs for details
kubectl logs -n gatekeeper-system deployment/ratify -c ratify | grep atproto
# Check Verifier status
kubectl get verifier atproto-verifier -o yaml
# Test DID resolution manually
curl https://plc.directory/did:plc:alice123
```
### Trust Policy Issues
```bash
# Check ConfigMap exists
kubectl get configmap atcr-trust-policy -n gatekeeper-system
# View policy contents
kubectl get configmap atcr-trust-policy -n gatekeeper-system -o yaml
```
## Performance Considerations
### Caching
The plugin caches:
- DID documents (TTL: 5 minutes)
- PDS endpoints (TTL: 5 minutes)
- Public keys (TTL: 5 minutes)
Configure via `cacheEnabled` and `cacheTTL` parameters.
### Timeouts
Configure timeouts for external calls:
- `didResolverTimeout` - DID resolution (default: 10s)
- `pdsTimeout` - PDS XRPC calls (default: 10s)
### Rate Limiting
Consider implementing rate limiting for:
- DID resolution (PLC directory)
- PDS XRPC calls
- Signature verification
## Security Considerations
### Trust Policy Management
- Store trust policy in version control
- Review DID additions/removals carefully
- Set expiration dates for temporary access
- Audit trust policy changes
### Private Key Protection
- Plugin only uses public keys
- No private keys needed for verification
- DID resolution is read-only
- PDS queries are read-only
### Denial of Service
- Implement timeouts for all external calls
- Cache DID documents to reduce load
- Rate limit verification requests
- Monitor verification latency
## See Also
- [Ratify Documentation](https://ratify.dev/)
- [Ratify Plugin Development](https://ratify.dev/docs/plugins/verifier/overview)
- [ATCR Signature Integration](../../../docs/SIGNATURE_INTEGRATION.md)
- [ATCR Integration Strategy](../../../docs/INTEGRATION_STRATEGY.md)
## Support
For issues or questions:
- GitHub Issues: https://github.com/atcr-io/atcr/issues
- Ratify GitHub: https://github.com/ratify-project/ratify

View File

@@ -0,0 +1,214 @@
// Package atproto implements a Ratify verifier plugin for ATProto signatures.
package atproto
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/ratify-project/ratify/pkg/common"
"github.com/ratify-project/ratify/pkg/ocispecs"
"github.com/ratify-project/ratify/pkg/referrerstore"
"github.com/ratify-project/ratify/pkg/verifier"
)
const (
// VerifierName is the name of this verifier
VerifierName = "atproto"
// VerifierType is the type of this verifier
VerifierType = "atproto"
// ATProtoSignatureArtifactType is the OCI artifact type for ATProto signatures
ATProtoSignatureArtifactType = "application/vnd.atproto.signature.v1+json"
)
// ATProtoVerifier implements the Ratify ReferenceVerifier interface for ATProto signatures.
type ATProtoVerifier struct {
name string
config ATProtoConfig
resolver *Resolver
verifier *SignatureVerifier
trustStore *TrustStore
}
// ATProtoConfig holds configuration for the ATProto verifier.
type ATProtoConfig struct {
// TrustPolicyPath is the path to the trust policy YAML file
TrustPolicyPath string `json:"trustPolicyPath"`
// DIDResolverTimeout is the timeout for DID resolution
DIDResolverTimeout time.Duration `json:"didResolverTimeout"`
// PDSTimeout is the timeout for PDS XRPC calls
PDSTimeout time.Duration `json:"pdsTimeout"`
// CacheEnabled enables caching of DID documents and public keys
CacheEnabled bool `json:"cacheEnabled"`
// CacheTTL is the cache TTL for DID documents and public keys
CacheTTL time.Duration `json:"cacheTTL"`
}
// ATProtoSignature represents the ATProto signature metadata stored in the OCI artifact.
type ATProtoSignature struct {
Type string `json:"$type"`
Version string `json:"version"`
Subject struct {
Digest string `json:"digest"`
MediaType string `json:"mediaType"`
} `json:"subject"`
ATProto struct {
DID string `json:"did"`
Handle string `json:"handle"`
PDSEndpoint string `json:"pdsEndpoint"`
RecordURI string `json:"recordUri"`
CommitCID string `json:"commitCid"`
SignedAt time.Time `json:"signedAt"`
} `json:"atproto"`
Signature struct {
Algorithm string `json:"algorithm"`
KeyID string `json:"keyId"`
PublicKeyMultibase string `json:"publicKeyMultibase"`
} `json:"signature"`
}
// NewATProtoVerifier creates a new ATProto verifier instance.
func NewATProtoVerifier(name string, config ATProtoConfig) (*ATProtoVerifier, error) {
// Load trust policy
trustStore, err := LoadTrustStore(config.TrustPolicyPath)
if err != nil {
return nil, fmt.Errorf("failed to load trust policy: %w", err)
}
// Create resolver with caching
resolver := NewResolver(config.DIDResolverTimeout, config.CacheEnabled, config.CacheTTL)
// Create signature verifier
verifier := NewSignatureVerifier(config.PDSTimeout)
return &ATProtoVerifier{
name: name,
config: config,
resolver: resolver,
verifier: verifier,
trustStore: trustStore,
}, nil
}
// Name returns the name of this verifier.
func (v *ATProtoVerifier) Name() string {
return v.name
}
// Type returns the type of this verifier.
func (v *ATProtoVerifier) Type() string {
return VerifierType
}
// CanVerify returns true if this verifier can verify the given artifact type.
func (v *ATProtoVerifier) CanVerify(artifactType string) bool {
return artifactType == ATProtoSignatureArtifactType
}
// VerifyReference verifies an ATProto signature artifact.
func (v *ATProtoVerifier) VerifyReference(
ctx context.Context,
subjectRef common.Reference,
referenceDesc ocispecs.ReferenceDescriptor,
store referrerstore.ReferrerStore,
) (verifier.VerifierResult, error) {
// 1. Fetch signature blob from store
sigBlob, err := store.GetBlobContent(ctx, subjectRef, referenceDesc.Digest)
if err != nil {
return v.failureResult(fmt.Sprintf("failed to fetch signature blob: %v", err)), err
}
// 2. Parse ATProto signature metadata
var sigData ATProtoSignature
if err := json.Unmarshal(sigBlob, &sigData); err != nil {
return v.failureResult(fmt.Sprintf("failed to parse signature metadata: %v", err)), err
}
// Validate signature format
if err := v.validateSignature(&sigData); err != nil {
return v.failureResult(fmt.Sprintf("invalid signature format: %v", err)), err
}
// 3. Check trust policy first (fail fast if DID not trusted)
if !v.trustStore.IsTrusted(sigData.ATProto.DID, time.Now()) {
return v.failureResult(fmt.Sprintf("DID %s not in trusted list", sigData.ATProto.DID)),
fmt.Errorf("untrusted DID")
}
// 4. Resolve DID to public key
pubKey, err := v.resolver.ResolveDIDToPublicKey(ctx, sigData.ATProto.DID)
if err != nil {
return v.failureResult(fmt.Sprintf("failed to resolve DID: %v", err)), err
}
// 5. Fetch repository commit from PDS
commit, err := v.verifier.FetchCommit(ctx, sigData.ATProto.PDSEndpoint,
sigData.ATProto.DID, sigData.ATProto.CommitCID)
if err != nil {
return v.failureResult(fmt.Sprintf("failed to fetch commit: %v", err)), err
}
// 6. Verify K-256 signature
if err := v.verifier.VerifySignature(pubKey, commit); err != nil {
return v.failureResult(fmt.Sprintf("signature verification failed: %v", err)), err
}
// 7. Success - return detailed result
return verifier.VerifierResult{
IsSuccess: true,
Name: v.name,
Type: v.Type(),
Message: fmt.Sprintf("Successfully verified ATProto signature for DID %s", sigData.ATProto.DID),
Extensions: map[string]interface{}{
"did": sigData.ATProto.DID,
"handle": sigData.ATProto.Handle,
"signedAt": sigData.ATProto.SignedAt,
"commitCid": sigData.ATProto.CommitCID,
"pdsEndpoint": sigData.ATProto.PDSEndpoint,
},
}, nil
}
// validateSignature validates the signature metadata format.
func (v *ATProtoVerifier) validateSignature(sig *ATProtoSignature) error {
if sig.Type != "io.atcr.atproto.signature" {
return fmt.Errorf("invalid signature type: %s", sig.Type)
}
if sig.ATProto.DID == "" {
return fmt.Errorf("missing DID")
}
if sig.ATProto.PDSEndpoint == "" {
return fmt.Errorf("missing PDS endpoint")
}
if sig.ATProto.CommitCID == "" {
return fmt.Errorf("missing commit CID")
}
if sig.Signature.Algorithm != "ECDSA-K256-SHA256" {
return fmt.Errorf("unsupported signature algorithm: %s", sig.Signature.Algorithm)
}
return nil
}
// failureResult creates a failure result with the given message.
func (v *ATProtoVerifier) failureResult(message string) verifier.VerifierResult {
return verifier.VerifierResult{
IsSuccess: false,
Name: v.name,
Type: v.Type(),
Message: message,
Extensions: map[string]interface{}{
"error": message,
},
}
}
// TODO: Implement resolver.go with DID resolution logic
// TODO: Implement crypto.go with K-256 signature verification
// TODO: Implement config.go with trust policy loading

View File

@@ -0,0 +1,364 @@
# ATProto Signature Verification Examples
This directory contains practical examples for verifying ATProto signatures on ATCR container images.
## Files
### Scripts
- **`atcr-verify.sh`** - Standalone signature verification script
- Verifies ATProto signatures using shell commands
- Requires: `curl`, `jq`, `crane`, `oras`
- Does everything except full cryptographic verification
- Use this until the `atcr-verify` CLI tool is built
- **`verify-and-pull.sh`** - Secure image pull wrapper
- Verifies signatures before pulling images
- Can be used as a `docker pull` replacement
- Configurable via environment variables
### Configuration
- **`trust-policy.yaml`** - Example trust policy configuration
- Defines which DIDs to trust
- Specifies policies for different image scopes
- Includes audit logging and reporting settings
- **`kubernetes-webhook.yaml`** - Kubernetes admission controller
- Validates signatures before pod creation
- Includes webhook deployment, service, and configuration
- Uses trust policy ConfigMap
## Quick Start
### 1. Verify an Image
```bash
# Make script executable
chmod +x atcr-verify.sh
# Verify an image
./atcr-verify.sh atcr.io/alice/myapp:latest
```
**Output:**
```
═══════════════════════════════════════════════════
ATProto Signature Verification
═══════════════════════════════════════════════════
Image: atcr.io/alice/myapp:latest
═══════════════════════════════════════════════════
[1/7] Resolving image digest...
→ sha256:abc123...
[2/7] Discovering ATProto signature artifacts...
→ Found 1 signature(s)
→ Signature digest: sha256:sig789...
→ Signed by DID: did:plc:alice123
[3/7] Fetching signature metadata...
→ DID: did:plc:alice123
→ Handle: alice.bsky.social
→ PDS: https://bsky.social
→ Record: at://did:plc:alice123/io.atcr.manifest/abc123
→ Signed at: 2025-10-31T12:34:56.789Z
[4/7] Resolving DID to public key...
→ Public key: zQ3shokFTS3brHcD...
[5/7] Querying PDS for signed record...
→ Record CID: bafyreig7...
[6/7] Verifying record integrity...
→ Record digest matches image digest
[7/7] Cryptographic signature verification...
⚠ Full cryptographic verification requires ATProto crypto library
═══════════════════════════════════════════════════
✓ Verification Completed
═══════════════════════════════════════════════════
Signed by: alice.bsky.social (did:plc:alice123)
Signed at: 2025-10-31T12:34:56.789Z
PDS: https://bsky.social
Record: at://did:plc:alice123/io.atcr.manifest/abc123
Signature: sha256:sig789...
═══════════════════════════════════════════════════
```
### 2. Secure Pull
```bash
# Make script executable
chmod +x verify-and-pull.sh
# Pull image with verification
./verify-and-pull.sh atcr.io/alice/myapp:latest
# With Docker options
./verify-and-pull.sh atcr.io/alice/myapp:latest --platform linux/amd64
```
**Create an alias for convenience:**
```bash
# Add to ~/.bashrc or ~/.zshrc
alias docker-pull-secure='/path/to/verify-and-pull.sh'
# Use it
docker-pull-secure atcr.io/alice/myapp:latest
```
### 3. Deploy Kubernetes Webhook
```bash
# 1. Generate TLS certificates for webhook
openssl req -x509 -newkey rsa:4096 -keyout tls.key -out tls.crt \
-days 365 -nodes -subj "/CN=atcr-verify-webhook.atcr-system.svc"
# 2. Create namespace and secret
kubectl create namespace atcr-system
kubectl create secret tls atcr-verify-webhook-certs \
--cert=tls.crt --key=tls.key -n atcr-system
# 3. Update CA bundle in kubernetes-webhook.yaml
cat tls.crt | base64 -w 0
# Copy output and replace caBundle in kubernetes-webhook.yaml
# 4. Deploy webhook
kubectl apply -f kubernetes-webhook.yaml
# 5. Enable verification for a namespace
kubectl label namespace production atcr-verify=enabled
# 6. Test with a pod
kubectl run test-pod --image=atcr.io/alice/myapp:latest -n production
```
## Prerequisites
### For Scripts
Install required tools:
**macOS (Homebrew):**
```bash
brew install curl jq crane oras
```
**Linux (apt):**
```bash
# curl and jq
sudo apt-get install curl jq
# crane
curl -sL "https://github.com/google/go-containerregistry/releases/download/v0.15.2/go-containerregistry_Linux_x86_64.tar.gz" | tar -xz crane
sudo mv crane /usr/local/bin/
# oras
curl -LO "https://github.com/oras-project/oras/releases/download/v1.0.0/oras_1.0.0_linux_amd64.tar.gz"
tar -xzf oras_1.0.0_linux_amd64.tar.gz
sudo mv oras /usr/local/bin/
```
### For Kubernetes Webhook
Requirements:
- Kubernetes cluster (1.16+)
- `kubectl` configured
- Permission to create namespaces and webhooks
- Webhook container image (build from source or use pre-built)
## Configuration
### Environment Variables (verify-and-pull.sh)
- `VERIFY_SCRIPT` - Path to atcr-verify.sh (default: ./atcr-verify.sh)
- `TRUST_POLICY` - Path to trust policy (default: ./trust-policy.yaml)
- `REQUIRE_VERIFICATION` - Require verification (default: true)
- `SKIP_ATCR_IMAGES` - Skip verification for non-ATCR images (default: false)
**Example:**
```bash
# Skip verification for non-ATCR images
SKIP_ATCR_IMAGES=true ./verify-and-pull.sh docker.io/library/nginx:latest
# Allow pulling even if verification fails (NOT RECOMMENDED)
REQUIRE_VERIFICATION=false ./verify-and-pull.sh atcr.io/alice/myapp:latest
```
### Trust Policy
Edit `trust-policy.yaml` to customize:
1. **Add your DIDs:**
```yaml
trustedDIDs:
did:plc:your-did:
name: "Your Name"
validFrom: "2024-01-01T00:00:00Z"
```
2. **Define policies:**
```yaml
policies:
- name: my-policy
scope: "atcr.io/myorg/*"
require:
signature: true
trustedDIDs:
- did:plc:your-did
action: enforce
```
3. **Use with verification:**
```bash
# When atcr-verify CLI is available:
atcr-verify IMAGE --policy trust-policy.yaml
```
## Integration Patterns
### CI/CD (GitHub Actions)
```yaml
- name: Verify image signature
run: |
chmod +x examples/verification/atcr-verify.sh
./examples/verification/atcr-verify.sh ${{ env.IMAGE }}
- name: Deploy if verified
if: success()
run: kubectl set image deployment/app app=${{ env.IMAGE }}
```
### CI/CD (GitLab CI)
```yaml
verify:
script:
- chmod +x examples/verification/atcr-verify.sh
- ./examples/verification/atcr-verify.sh $IMAGE
deploy:
dependencies: [verify]
script:
- kubectl set image deployment/app app=$IMAGE
```
### Docker Alias
```bash
# ~/.bashrc or ~/.zshrc
function docker() {
if [ "$1" = "pull" ] && [[ "$2" =~ ^atcr\.io/ ]]; then
echo "Using secure pull with signature verification..."
/path/to/verify-and-pull.sh "${@:2}"
else
command docker "$@"
fi
}
```
### Systemd Service
```ini
# /etc/systemd/system/myapp.service
[Unit]
Description=My Application
After=docker.service
[Service]
Type=oneshot
ExecStartPre=/path/to/verify-and-pull.sh atcr.io/myorg/myapp:latest
ExecStart=/usr/bin/docker run atcr.io/myorg/myapp:latest
Restart=on-failure
[Install]
WantedBy=multi-user.target
```
## Troubleshooting
### "No ATProto signature found"
**Cause:** Image doesn't have a signature artifact
**Solutions:**
1. Check if image exists: `crane digest IMAGE`
2. Re-push image to generate signature
3. Verify referrers API is working:
```bash
curl "https://atcr.io/v2/REPO/referrers/DIGEST"
```
### "Failed to resolve DID"
**Cause:** DID resolution failed
**Solutions:**
1. Check internet connectivity
2. Verify DID is valid: `curl https://plc.directory/DID`
3. Check if DID document has verificationMethod
### "Failed to fetch record from PDS"
**Cause:** PDS is unreachable or record doesn't exist
**Solutions:**
1. Check PDS endpoint: `curl PDS_URL/xrpc/com.atproto.server.describeServer`
2. Verify record URI is correct
3. Check if record exists in PDS
### Webhook Pods Don't Start
**Cause:** Webhook is rejecting all pods
**Solutions:**
1. Check webhook logs: `kubectl logs -n atcr-system -l app=atcr-verify-webhook`
2. Disable webhook temporarily: `kubectl delete validatingwebhookconfiguration atcr-verify`
3. Fix issue and re-deploy
4. Test with labeled namespace first
## Security Best Practices
1. **Always verify in production**
- Enable webhook for production namespaces
- Set `failurePolicy: Fail` to block on errors
2. **Use trust policies**
- Define specific trusted DIDs
- Don't trust all signatures blindly
- Set expiration dates for temporary access
3. **Monitor verification**
- Enable audit logging
- Review verification failures
- Track signature coverage
4. **Rotate keys regularly**
- Update DID documents when keys change
- Revoke compromised keys immediately
- Monitor for unexpected key changes
5. **Secure webhook deployment**
- Use TLS for webhook communication
- Restrict webhook RBAC permissions
- Keep webhook image updated
## Next Steps
1. **Test verification** with your images
2. **Customize trust policy** for your organization
3. **Deploy webhook** to test clusters first
4. **Monitor** verification in CI/CD pipelines
5. **Gradually roll out** to production
## See Also
- [ATProto Signatures](../../docs/ATPROTO_SIGNATURES.md) - Technical details
- [Signature Integration](../../docs/SIGNATURE_INTEGRATION.md) - Integration guide
- [SBOM Scanning](../../docs/SBOM_SCANNING.md) - Similar ORAS pattern
## Support
For issues or questions:
- GitHub Issues: https://github.com/your-org/atcr/issues
- Documentation: https://docs.atcr.io
- Security: security@yourorg.com

View File

@@ -0,0 +1,243 @@
#!/bin/bash
# ATProto Signature Verification Script
#
# This script verifies ATProto signatures for container images stored in ATCR.
# It performs all steps except full cryptographic verification (which requires
# the indigo library). For production use, use the atcr-verify CLI tool.
#
# Usage: ./atcr-verify.sh IMAGE_REF
# Example: ./atcr-verify.sh atcr.io/alice/myapp:latest
#
# Requirements:
# - curl
# - jq
# - crane (https://github.com/google/go-containerregistry/releases)
# - oras (https://oras.land/docs/installation)
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Check dependencies
check_dependencies() {
local missing=0
for cmd in curl jq crane oras; do
if ! command -v $cmd &> /dev/null; then
echo -e "${RED}${NC} Missing dependency: $cmd"
missing=1
fi
done
if [ $missing -eq 1 ]; then
echo ""
echo "Install missing dependencies:"
echo " curl: https://curl.se/download.html"
echo " jq: https://stedolan.github.io/jq/download/"
echo " crane: https://github.com/google/go-containerregistry/releases"
echo " oras: https://oras.land/docs/installation"
exit 1
fi
}
# Print with color
print_step() {
echo -e "${BLUE}[$1/${TOTAL_STEPS}]${NC} $2..."
}
print_success() {
echo -e " ${GREEN}${NC} $1"
}
print_error() {
echo -e " ${RED}${NC} $1"
}
print_warning() {
echo -e " ${YELLOW}${NC} $1"
}
# Main verification function
verify_image() {
local image="$1"
if [ -z "$image" ]; then
echo "Usage: $0 IMAGE_REF"
echo "Example: $0 atcr.io/alice/myapp:latest"
exit 1
fi
TOTAL_STEPS=7
echo ""
echo "═══════════════════════════════════════════════════"
echo " ATProto Signature Verification"
echo "═══════════════════════════════════════════════════"
echo " Image: $image"
echo "═══════════════════════════════════════════════════"
echo ""
# Step 1: Resolve image digest
print_step 1 "Resolving image digest"
DIGEST=$(crane digest "$image" 2>&1)
if [ $? -ne 0 ]; then
print_error "Failed to resolve image digest"
echo "$DIGEST"
exit 1
fi
print_success "$DIGEST"
# Extract registry, repository, and tag
REGISTRY=$(echo "$image" | cut -d/ -f1)
REPO=$(echo "$image" | cut -d/ -f2-)
REPO_PATH=$(echo "$REPO" | cut -d: -f1)
# Step 2: Discover ATProto signature artifacts
print_step 2 "Discovering ATProto signature artifacts"
REFERRERS_URL="https://${REGISTRY}/v2/${REPO_PATH}/referrers/${DIGEST}?artifactType=application/vnd.atproto.signature.v1+json"
SIG_ARTIFACTS=$(curl -s -H "Accept: application/vnd.oci.image.index.v1+json" "$REFERRERS_URL")
if [ $? -ne 0 ]; then
print_error "Failed to query referrers API"
exit 1
fi
SIG_COUNT=$(echo "$SIG_ARTIFACTS" | jq '.manifests | length')
if [ "$SIG_COUNT" = "0" ]; then
print_error "No ATProto signature found"
echo ""
echo "This image does not have an ATProto signature."
echo "Signatures are automatically created when you push to ATCR."
exit 1
fi
print_success "Found $SIG_COUNT signature(s)"
# Get first signature digest
SIG_DIGEST=$(echo "$SIG_ARTIFACTS" | jq -r '.manifests[0].digest')
SIG_DID=$(echo "$SIG_ARTIFACTS" | jq -r '.manifests[0].annotations["io.atcr.atproto.did"]')
print_success "Signature digest: $SIG_DIGEST"
print_success "Signed by DID: $SIG_DID"
# Step 3: Fetch signature metadata
print_step 3 "Fetching signature metadata"
TMPDIR=$(mktemp -d)
trap "rm -rf $TMPDIR" EXIT
oras pull "${REGISTRY}/${REPO_PATH}@${SIG_DIGEST}" -o "$TMPDIR" --quiet 2>&1
if [ $? -ne 0 ]; then
print_error "Failed to fetch signature metadata"
exit 1
fi
# Find the JSON file
SIG_FILE=$(find "$TMPDIR" -name "*.json" -type f | head -n 1)
if [ -z "$SIG_FILE" ]; then
print_error "Signature metadata file not found"
exit 1
fi
DID=$(jq -r '.atproto.did' "$SIG_FILE")
HANDLE=$(jq -r '.atproto.handle // "unknown"' "$SIG_FILE")
PDS=$(jq -r '.atproto.pdsEndpoint' "$SIG_FILE")
RECORD_URI=$(jq -r '.atproto.recordUri' "$SIG_FILE")
COMMIT_CID=$(jq -r '.atproto.commitCid' "$SIG_FILE")
SIGNED_AT=$(jq -r '.atproto.signedAt' "$SIG_FILE")
print_success "DID: $DID"
print_success "Handle: $HANDLE"
print_success "PDS: $PDS"
print_success "Record: $RECORD_URI"
print_success "Signed at: $SIGNED_AT"
# Step 4: Resolve DID to public key
print_step 4 "Resolving DID to public key"
DID_DOC=$(curl -s "https://plc.directory/$DID")
if [ $? -ne 0 ]; then
print_error "Failed to resolve DID"
exit 1
fi
PUB_KEY_MB=$(echo "$DID_DOC" | jq -r '.verificationMethod[0].publicKeyMultibase')
if [ "$PUB_KEY_MB" = "null" ] || [ -z "$PUB_KEY_MB" ]; then
print_error "Public key not found in DID document"
exit 1
fi
print_success "Public key: ${PUB_KEY_MB:0:20}...${PUB_KEY_MB: -10}"
# Step 5: Query PDS for signed record
print_step 5 "Querying PDS for signed record"
# Extract collection and rkey from record URI (at://did/collection/rkey)
COLLECTION=$(echo "$RECORD_URI" | sed 's|at://[^/]*/\([^/]*\)/.*|\1|')
RKEY=$(echo "$RECORD_URI" | sed 's|at://.*/||')
RECORD_URL="${PDS}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=${COLLECTION}&rkey=${RKEY}"
RECORD=$(curl -s "$RECORD_URL")
if [ $? -ne 0 ]; then
print_error "Failed to fetch record from PDS"
exit 1
fi
RECORD_CID=$(echo "$RECORD" | jq -r '.cid')
if [ "$RECORD_CID" = "null" ] || [ -z "$RECORD_CID" ]; then
print_error "Record not found in PDS"
exit 1
fi
print_success "Record CID: $RECORD_CID"
# Step 6: Verify record matches image manifest
print_step 6 "Verifying record integrity"
RECORD_DIGEST=$(echo "$RECORD" | jq -r '.value.digest')
if [ "$RECORD_DIGEST" != "$DIGEST" ]; then
print_error "Record digest ($RECORD_DIGEST) doesn't match image digest ($DIGEST)"
exit 1
fi
print_success "Record digest matches image digest"
# Step 7: Signature verification status
print_step 7 "Cryptographic signature verification"
print_warning "Full cryptographic verification requires ATProto crypto library"
print_warning "This script verifies:"
echo " • Record exists in PDS"
echo " • DID resolved successfully"
echo " • Public key retrieved from DID document"
echo " • Record digest matches image digest"
echo ""
print_warning "For full cryptographic verification, use: atcr-verify $image"
# Summary
echo ""
echo "═══════════════════════════════════════════════════"
echo -e " ${GREEN}✓ Verification Completed${NC}"
echo "═══════════════════════════════════════════════════"
echo ""
echo " Signed by: $HANDLE ($DID)"
echo " Signed at: $SIGNED_AT"
echo " PDS: $PDS"
echo " Record: $RECORD_URI"
echo " Signature: $SIG_DIGEST"
echo ""
echo "═══════════════════════════════════════════════════"
echo ""
}
# Check dependencies first
check_dependencies
# Run verification
verify_image "$1"

View File

@@ -0,0 +1,259 @@
# Kubernetes Admission Webhook for ATProto Signature Verification
#
# This example shows how to deploy a validating admission webhook that
# verifies ATProto signatures before allowing pods to be created.
#
# Prerequisites:
# 1. Build and push the webhook image (see examples/webhook/ for code)
# 2. Generate TLS certificates for the webhook
# 3. Create trust policy ConfigMap
#
# Usage:
# kubectl apply -f kubernetes-webhook.yaml
# kubectl label namespace production atcr-verify=enabled
---
apiVersion: v1
kind: Namespace
metadata:
name: atcr-system
---
# ConfigMap with trust policy
apiVersion: v1
kind: ConfigMap
metadata:
name: atcr-trust-policy
namespace: atcr-system
data:
policy.yaml: |
version: 1.0
# Global settings
defaultAction: enforce # enforce, audit, or allow
# Policies by image pattern
policies:
- name: production-images
scope: "atcr.io/*/prod-*"
require:
signature: true
trustedDIDs:
- did:plc:your-org-devops
- did:plc:your-org-security
minSignatures: 1
action: enforce
- name: staging-images
scope: "atcr.io/*/staging-*"
require:
signature: true
trustedDIDs:
- did:plc:your-org-devops
- did:plc:your-org-security
- did:plc:your-developers
action: enforce
- name: dev-images
scope: "atcr.io/*/dev-*"
require:
signature: false
action: audit # Log but don't block
# Trusted DIDs configuration
trustedDIDs:
did:plc:your-org-devops:
name: "DevOps Team"
validFrom: "2024-01-01T00:00:00Z"
expiresAt: null
did:plc:your-org-security:
name: "Security Team"
validFrom: "2024-01-01T00:00:00Z"
expiresAt: null
did:plc:your-developers:
name: "Developer Team"
validFrom: "2024-06-01T00:00:00Z"
expiresAt: null
---
# Service for webhook
apiVersion: v1
kind: Service
metadata:
name: atcr-verify-webhook
namespace: atcr-system
spec:
selector:
app: atcr-verify-webhook
ports:
- name: https
port: 443
targetPort: 8443
---
# Deployment for webhook
apiVersion: apps/v1
kind: Deployment
metadata:
name: atcr-verify-webhook
namespace: atcr-system
spec:
replicas: 2
selector:
matchLabels:
app: atcr-verify-webhook
template:
metadata:
labels:
app: atcr-verify-webhook
spec:
containers:
- name: webhook
image: atcr.io/atcr/verify-webhook:latest
imagePullPolicy: Always
ports:
- containerPort: 8443
name: https
env:
- name: TLS_CERT_FILE
value: /etc/webhook/certs/tls.crt
- name: TLS_KEY_FILE
value: /etc/webhook/certs/tls.key
- name: POLICY_FILE
value: /etc/webhook/policy/policy.yaml
- name: LOG_LEVEL
value: info
volumeMounts:
- name: webhook-certs
mountPath: /etc/webhook/certs
readOnly: true
- name: policy
mountPath: /etc/webhook/policy
readOnly: true
resources:
requests:
memory: "64Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /healthz
port: 8443
scheme: HTTPS
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /readyz
port: 8443
scheme: HTTPS
initialDelaySeconds: 5
periodSeconds: 5
volumes:
- name: webhook-certs
secret:
secretName: atcr-verify-webhook-certs
- name: policy
configMap:
name: atcr-trust-policy
---
# ValidatingWebhookConfiguration
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
name: atcr-verify
webhooks:
- name: verify.atcr.io
admissionReviewVersions: ["v1", "v1beta1"]
sideEffects: None
# Client configuration
clientConfig:
service:
name: atcr-verify-webhook
namespace: atcr-system
path: /validate
port: 443
# CA bundle for webhook TLS (base64-encoded CA cert)
# Generate with: cat ca.crt | base64 -w 0
caBundle: LS0tLS1CRUdJTi... # Replace with your CA bundle
# Rules - what to validate
rules:
- operations: ["CREATE", "UPDATE"]
apiGroups: [""]
apiVersions: ["v1"]
resources: ["pods"]
scope: "Namespaced"
# Namespace selector - only validate labeled namespaces
namespaceSelector:
matchExpressions:
- key: atcr-verify
operator: In
values: ["enabled", "enforce"]
# Failure policy - what to do if webhook fails
failurePolicy: Fail # Reject pods if webhook is unavailable
# Timeout
timeoutSeconds: 10
# Match policy
matchPolicy: Equivalent
---
# Example: Label a namespace to enable verification
# kubectl label namespace production atcr-verify=enabled
---
# RBAC for webhook
apiVersion: v1
kind: ServiceAccount
metadata:
name: atcr-verify-webhook
namespace: atcr-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: atcr-verify-webhook
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
- apiGroups: [""]
resources: ["events"]
verbs: ["create", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: atcr-verify-webhook
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: atcr-verify-webhook
subjects:
- kind: ServiceAccount
name: atcr-verify-webhook
namespace: atcr-system
---
# Secret for TLS certificates
# Generate certificates with:
# openssl req -x509 -newkey rsa:4096 -keyout tls.key -out tls.crt \
# -days 365 -nodes -subj "/CN=atcr-verify-webhook.atcr-system.svc"
#
# Create secret with:
# kubectl create secret tls atcr-verify-webhook-certs \
# --cert=tls.crt --key=tls.key -n atcr-system
#
# (Commented out - create manually with your certs)
# apiVersion: v1
# kind: Secret
# metadata:
# name: atcr-verify-webhook-certs
# namespace: atcr-system
# type: kubernetes.io/tls
# data:
# tls.crt: <base64-encoded-cert>
# tls.key: <base64-encoded-key>

View File

@@ -0,0 +1,247 @@
# ATProto Signature Trust Policy
#
# This file defines which signatures to trust and what to do when
# signatures are invalid or missing.
#
# Usage with atcr-verify:
# atcr-verify IMAGE --policy trust-policy.yaml
version: 1.0
# Global settings
defaultAction: enforce # Options: enforce, audit, allow
requireSignature: true # Require at least one signature
# Policies matched by image scope (first match wins)
policies:
# Production images require signatures from trusted DIDs
- name: production-images
description: "Production images must be signed by DevOps or Security team"
scope: "atcr.io/*/prod-*"
require:
signature: true
trustedDIDs:
- did:plc:your-org-devops
- did:plc:your-org-security
minSignatures: 1
maxAge: 2592000 # 30 days in seconds
action: enforce # Reject if policy fails
# Critical infrastructure requires multi-signature
- name: critical-infrastructure
description: "Critical services require 2 signatures"
scope: "atcr.io/*/critical-*"
require:
signature: true
trustedDIDs:
- did:plc:your-org-security
- did:plc:your-org-devops
minSignatures: 2 # Require at least 2 signatures
algorithms:
- ECDSA-K256-SHA256 # Only allow specific algorithms
action: enforce
# Staging images require signature from any team member
- name: staging-images
description: "Staging images need any trusted signature"
scope: "atcr.io/*/staging-*"
require:
signature: true
trustedDIDs:
- did:plc:your-org-devops
- did:plc:your-org-security
- did:plc:your-org-developers
minSignatures: 1
action: enforce
# Development images are audited but not blocked
- name: dev-images
description: "Development images are monitored"
scope: "atcr.io/*/dev-*"
require:
signature: false # Don't require signatures
action: audit # Log but don't reject
# Test images from external sources
- name: external-test-images
description: "Test images from partners"
scope: "atcr.io/external/*"
require:
signature: true
trustedDIDs:
- did:plc:partner-acme
- did:plc:partner-widgets
minSignatures: 1
action: enforce
# Default fallback for all other images
- name: default
description: "All other images require signature"
scope: "atcr.io/*/*"
require:
signature: true
minSignatures: 1
action: enforce
# Trusted DID registry
trustedDIDs:
# Your organization's DevOps team
did:plc:your-org-devops:
name: "DevOps Team"
description: "Production deployment automation"
validFrom: "2024-01-01T00:00:00Z"
expiresAt: null # Never expires
contact: "devops@yourorg.com"
allowedScopes:
- "atcr.io/*/prod-*"
- "atcr.io/*/staging-*"
- "atcr.io/*/critical-*"
# Your organization's Security team
did:plc:your-org-security:
name: "Security Team"
description: "Security-reviewed images"
validFrom: "2024-01-01T00:00:00Z"
expiresAt: null
contact: "security@yourorg.com"
allowedScopes:
- "atcr.io/*/*" # Can sign any image
# Developer team (limited access)
did:plc:your-org-developers:
name: "Developer Team"
description: "Development and staging images"
validFrom: "2024-06-01T00:00:00Z"
expiresAt: "2025-12-31T23:59:59Z" # Temporary access
contact: "dev-team@yourorg.com"
allowedScopes:
- "atcr.io/*/dev-*"
- "atcr.io/*/staging-*"
notes: "Access expires end of 2025 - review then"
# External partner: ACME Corp
did:plc:partner-acme:
name: "ACME Corp Integration Team"
description: "Third-party integration images"
validFrom: "2024-09-01T00:00:00Z"
expiresAt: "2025-09-01T00:00:00Z"
contact: "integration@acme.example.com"
allowedScopes:
- "atcr.io/external/acme-*"
# External partner: Widgets Inc
did:plc:partner-widgets:
name: "Widgets Inc"
description: "Widgets service integration"
validFrom: "2024-10-01T00:00:00Z"
expiresAt: "2025-10-01T00:00:00Z"
contact: "api@widgets.example.com"
allowedScopes:
- "atcr.io/external/widgets-*"
# Signature validation settings
validation:
# Signature age limits
maxSignatureAge: 7776000 # 90 days in seconds (null = no limit)
# Allowed signature algorithms
allowedAlgorithms:
- ECDSA-K256-SHA256 # ATProto default
- ECDSA-P256-SHA256 # Alternative
# DID resolution settings
didResolver:
timeout: 10 # seconds
cache:
enabled: true
ttl: 3600 # 1 hour in seconds
fallbackResolvers:
- https://plc.directory
- https://backup-plc.example.com
# PDS connection settings
pds:
timeout: 15 # seconds
retries: 3
cache:
enabled: true
ttl: 600 # 10 minutes
# Audit logging
audit:
enabled: true
logLevel: info # debug, info, warn, error
# What to log
logEvents:
- signature_verified
- signature_missing
- signature_invalid
- signature_expired
- did_resolution_failed
- pds_query_failed
- policy_violation
# Log destinations
destinations:
- type: stdout
format: json
- type: file
path: /var/log/atcr-verify/audit.log
format: json
rotate: true
maxSize: 100MB
maxFiles: 10
# Reporting and metrics
reporting:
# Prometheus metrics
metrics:
enabled: true
port: 9090
path: /metrics
# Periodic reports
reports:
enabled: true
interval: 86400 # Daily in seconds
email:
- security@yourorg.com
- devops@yourorg.com
includeStatistics: true
# Emergency overrides
overrides:
# Allow bypassing verification in emergencies
enabled: false # Enable with extreme caution!
requireApproval: true
approvers:
- security@yourorg.com
validDuration: 3600 # Override valid for 1 hour
# Examples of policy evaluation:
#
# atcr.io/myorg/prod-api:v1.2.3
# → Matches: production-images
# → Requires: 1 signature from DevOps or Security
# → Action: enforce
#
# atcr.io/myorg/critical-auth:v2.0.0
# → Matches: critical-infrastructure
# → Requires: 2 signatures from Security and DevOps
# → Action: enforce
#
# atcr.io/myorg/staging-frontend:latest
# → Matches: staging-images
# → Requires: 1 signature from any team member
# → Action: enforce
#
# atcr.io/myorg/dev-experiment:test
# → Matches: dev-images
# → Requires: none
# → Action: audit (log only)
#
# atcr.io/external/acme-connector:v1.0
# → Matches: external-test-images
# → Requires: 1 signature from partner-acme
# → Action: enforce

View File

@@ -0,0 +1,162 @@
#!/bin/bash
# Verify and Pull Script
#
# This script verifies ATProto signatures before pulling images with Docker.
# It acts as a wrapper around `docker pull` to enforce signature verification.
#
# Usage: ./verify-and-pull.sh IMAGE [DOCKER_PULL_OPTIONS]
# Example: ./verify-and-pull.sh atcr.io/alice/myapp:latest
# Example: ./verify-and-pull.sh atcr.io/alice/myapp:latest --platform linux/amd64
#
# To use this as a replacement for docker pull, create an alias:
# alias docker-pull-secure='/path/to/verify-and-pull.sh'
set -e
# Configuration
VERIFY_SCRIPT="${VERIFY_SCRIPT:-$(dirname $0)/atcr-verify.sh}"
TRUST_POLICY="${TRUST_POLICY:-$(dirname $0)/trust-policy.yaml}"
REQUIRE_VERIFICATION="${REQUIRE_VERIFICATION:-true}"
SKIP_ATCR_IMAGES="${SKIP_ATCR_IMAGES:-false}" # Skip verification for non-ATCR images
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
print_header() {
echo ""
echo -e "${BLUE}═══════════════════════════════════════════════════${NC}"
echo -e "${BLUE} Secure Image Pull with Signature Verification${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════${NC}"
echo ""
}
print_success() {
echo -e "${GREEN}${NC} $1"
}
print_error() {
echo -e "${RED}${NC} $1"
}
print_warning() {
echo -e "${YELLOW}${NC} $1"
}
# Check if image is from ATCR
is_atcr_image() {
local image="$1"
if [[ "$image" =~ ^atcr\.io/ ]]; then
return 0
else
return 1
fi
}
# Main function
main() {
if [ $# -eq 0 ]; then
echo "Usage: $0 IMAGE [DOCKER_PULL_OPTIONS]"
echo ""
echo "Examples:"
echo " $0 atcr.io/alice/myapp:latest"
echo " $0 atcr.io/alice/myapp:latest --platform linux/amd64"
echo ""
echo "Environment variables:"
echo " VERIFY_SCRIPT - Path to verification script (default: ./atcr-verify.sh)"
echo " TRUST_POLICY - Path to trust policy (default: ./trust-policy.yaml)"
echo " REQUIRE_VERIFICATION - Require verification for ATCR images (default: true)"
echo " SKIP_ATCR_IMAGES - Skip verification for non-ATCR images (default: false)"
exit 1
fi
local image="$1"
shift
local docker_args="$@"
print_header
echo -e "${BLUE}Image:${NC} $image"
if [ -n "$docker_args" ]; then
echo -e "${BLUE}Docker options:${NC} $docker_args"
fi
echo ""
# Check if this is an ATCR image
if ! is_atcr_image "$image"; then
if [ "$SKIP_ATCR_IMAGES" = "true" ]; then
print_warning "Not an ATCR image - skipping signature verification"
echo ""
docker pull $docker_args "$image"
exit $?
else
print_warning "Not an ATCR image"
if [ "$REQUIRE_VERIFICATION" = "true" ]; then
print_error "Verification required but image is not from ATCR"
exit 1
else
print_warning "Proceeding without verification"
echo ""
docker pull $docker_args "$image"
exit $?
fi
fi
fi
# Step 1: Verify signature
echo -e "${BLUE}Step 1: Verifying ATProto signature${NC}"
echo ""
if [ ! -f "$VERIFY_SCRIPT" ]; then
print_error "Verification script not found: $VERIFY_SCRIPT"
exit 1
fi
# Run verification
if bash "$VERIFY_SCRIPT" "$image"; then
print_success "Signature verification passed"
echo ""
else
print_error "Signature verification failed"
echo ""
if [ "$REQUIRE_VERIFICATION" = "true" ]; then
echo -e "${RED}Image pull blocked due to failed signature verification${NC}"
echo ""
echo "To proceed anyway (NOT RECOMMENDED), run:"
echo " REQUIRE_VERIFICATION=false $0 $image $docker_args"
exit 1
else
print_warning "Verification failed but REQUIRE_VERIFICATION=false"
print_warning "Proceeding with pull (NOT RECOMMENDED)"
echo ""
fi
fi
# Step 2: Pull image
echo -e "${BLUE}Step 2: Pulling image${NC}"
echo ""
if docker pull $docker_args "$image"; then
print_success "Image pulled successfully"
else
print_error "Failed to pull image"
exit 1
fi
# Summary
echo ""
echo -e "${GREEN}═══════════════════════════════════════════════════${NC}"
echo -e "${GREEN} ✓ Secure pull completed successfully${NC}"
echo -e "${GREEN}═══════════════════════════════════════════════════${NC}"
echo ""
echo -e "${BLUE}Image:${NC} $image"
echo -e "${BLUE}Status:${NC} Verified and pulled"
echo ""
}
# Run main function
main "$@"

328
go.mod
View File

@@ -1,16 +1,15 @@
module atcr.io
go 1.24.9
go 1.25.4
require (
github.com/anchore/grype v0.102.0
github.com/anchore/syft v1.36.0
github.com/aws/aws-sdk-go v1.55.5
github.com/bluesky-social/indigo v0.0.0-20251021193747-543ab1124beb
github.com/bluesky-social/indigo v0.0.0-20251218205144-034a2c019e64
github.com/distribution/distribution/v3 v3.0.0
github.com/distribution/reference v0.6.0
github.com/earthboundkid/versioninfo/v2 v2.24.1
github.com/go-chi/chi/v5 v5.2.3
github.com/goki/freetype v1.0.5
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
@@ -25,191 +24,52 @@ require (
github.com/microcosm-cc/bluemonday v1.0.27
github.com/multiformats/go-multihash v0.2.3
github.com/opencontainers/go-digest v1.0.0
github.com/spf13/cobra v1.10.1
github.com/stretchr/testify v1.11.1
github.com/spf13/cobra v1.8.0
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
github.com/stretchr/testify v1.10.0
github.com/whyrusleeping/cbor-gen v0.3.1
github.com/yuin/goldmark v1.7.13
go.opentelemetry.io/otel v1.37.0
go.opentelemetry.io/otel v1.32.0
go.yaml.in/yaml/v4 v4.0.0-rc.2
golang.org/x/crypto v0.43.0
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
gorm.io/gorm v1.31.0
golang.org/x/crypto v0.44.0
golang.org/x/image v0.34.0
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028
gorm.io/gorm v1.25.9
)
require (
cel.dev/expr v0.24.0 // indirect
cloud.google.com/go v0.121.3 // indirect
cloud.google.com/go/auth v0.16.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.7.0 // indirect
cloud.google.com/go/iam v1.5.2 // indirect
cloud.google.com/go/monitoring v1.24.2 // indirect
cloud.google.com/go/storage v1.55.0 // indirect
dario.cat/mergo v1.0.2 // indirect
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect
github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20250520111509-a70c2aa677fa // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/CycloneDX/cyclonedx-go v0.9.3 // indirect
github.com/DataDog/zstd v1.5.7 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect
github.com/Intevation/gval v1.3.0 // indirect
github.com/Intevation/jsonpath v0.2.1 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/Microsoft/hcsshim v0.13.0 // indirect
github.com/OneOfOne/xxhash v1.2.8 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect
github.com/STARRY-S/zip v0.2.3 // indirect
github.com/acobaugh/osrelease v0.1.0 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/agext/levenshtein v1.2.3 // indirect
github.com/anchore/archiver/v3 v3.5.3-0.20241210171143-5b1d8d1c7c51 // indirect
github.com/anchore/clio v0.0.0-20250715152405-a0fa658e5084 // indirect
github.com/anchore/fangs v0.0.0-20250716230140-94c22408c232 // indirect
github.com/anchore/go-collections v0.0.0-20251016125210-a3c352120e8c // indirect
github.com/anchore/go-homedir v0.0.0-20250319154043-c29668562e4d // indirect
github.com/anchore/go-logger v0.0.0-20250318195838-07ae343dd722 // indirect
github.com/anchore/go-lzo v0.1.0 // indirect
github.com/anchore/go-macholibre v0.0.0-20250320151634-807da7ad2331 // indirect
github.com/anchore/go-rpmdb v0.0.0-20250516171929-f77691e1faec // indirect
github.com/anchore/go-struct-converter v0.0.0-20250211213226-cce56d595160 // indirect
github.com/anchore/go-sync v0.0.0-20250714163430-add63db73ad1 // indirect
github.com/anchore/go-version v1.2.2-0.20210903204242-51efa5b487c4 // indirect
github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115 // indirect
github.com/anchore/stereoscope v0.1.11 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/aquasecurity/go-pep440-version v0.0.1 // indirect
github.com/aquasecurity/go-version v0.0.1 // indirect
github.com/aws/aws-sdk-go-v2 v1.36.5 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
github.com/aws/aws-sdk-go-v2/config v1.29.17 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.70 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.80.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.25.5 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 // indirect
github.com/aws/smithy-go v1.22.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/becheran/wildmatch-go v1.0.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
github.com/bitnami/go-version v0.0.0-20250505154626-452e8c5ee607 // indirect
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect
github.com/bmatcuk/doublestar/v2 v2.0.4 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/bodgit/plumbing v1.3.0 // indirect
github.com/bodgit/sevenzip v1.6.1 // indirect
github.com/bodgit/windows v1.0.1 // indirect
github.com/bshuster-repo/logrus-logstash-hook v1.0.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.3.1 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
github.com/containerd/cgroups/v3 v3.0.3 // indirect
github.com/containerd/containerd v1.7.28 // indirect
github.com/containerd/containerd/api v1.9.0 // indirect
github.com/containerd/continuity v0.4.5 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/fifo v1.1.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
github.com/containerd/ttrpc v1.2.7 // indirect
github.com/containerd/typeurl/v2 v2.2.3 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/deitch/magic v0.0.0-20240306090643-c67ab88f10cb // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/diskfs/go-diskfs v1.7.0 // indirect
github.com/docker/cli v28.5.1+incompatible // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker v28.5.1+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.3 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-events v0.0.0-20250114142523-c867878c5e32 // indirect
github.com/docker/docker-credential-helpers v0.8.2 // indirect
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect
github.com/docker/go-metrics v0.0.1 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elliotchance/phpserialize v1.4.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
github.com/facebookincubator/nvdtools v0.1.5 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/fgprof v0.9.5 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/github/go-spdx/v2 v2.3.4 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/glebarez/sqlite v1.11.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/go-git/go-git/v5 v5.16.3 // indirect
github.com/go-jose/go-jose/v4 v4.1.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-restruct/restruct v1.2.0-alpha // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/gocql/gocql v1.7.0 // indirect
github.com/gocsaf/csaf/v3 v3.3.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/gohugoio/hashstructure v0.6.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-containerregistry v0.20.6 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/licensecheck v0.3.1 // indirect
github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/gookit/color v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/gorilla/handlers v1.5.2 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 // indirect
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect
github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b // indirect
github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.65 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-getter v1.8.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/arc/v2 v2.0.6 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/hcl/v2 v2.24.0 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/iancoleman/strcase v0.3.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/ipfs/bbloom v0.0.4 // indirect
github.com/ipfs/go-blockservice v0.5.2 // indirect
@@ -230,70 +90,22 @@ require (
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.0 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jbenet/goprocess v0.1.4 // indirect
github.com/jinzhu/copier v0.4.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/kastenhq/goversion v0.0.0-20230811215019-93b2f8823953 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f // indirect
github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/masahiro331/go-mvn-version v0.0.0-20250131095131-f4974fa13b8a // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/mholt/archives v0.1.5 // indirect
github.com/mikelolasagasti/xz v1.0.1 // indirect
github.com/minio/minlz v1.0.1 // indirect
github.com/minio/sha256-simd v1.0.1 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/locker v1.0.1 // indirect
github.com/moby/sys/mountinfo v0.7.2 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/signal v0.7.1 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/multiformats/go-base32 v0.1.0 // indirect
github.com/multiformats/go-base36 v0.2.0 // indirect
github.com/multiformats/go-multibase v0.2.0 // indirect
github.com/multiformats/go-varint v0.0.7 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/nix-community/go-nix v0.0.0-20250101154619-4bdde671e0a1 // indirect
github.com/nwaples/rardecode v1.1.3 // indirect
github.com/nwaples/rardecode/v2 v2.2.0 // indirect
github.com/olekukonko/errors v1.1.0 // indirect
github.com/olekukonko/ll v0.0.9 // indirect
github.com/olekukonko/tablewriter v1.1.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/opencontainers/runtime-spec v1.2.1 // indirect
github.com/opencontainers/selinux v1.12.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/openvex/go-vex v0.2.7 // indirect
github.com/package-url/packageurl-go v0.1.3 // indirect
github.com/pandatix/go-cvss v0.6.2 // indirect
github.com/pborman/indent v1.2.1 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pjbgf/sha1cd v0.4.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pkg/profile v1.7.0 // indirect
github.com/pkg/xattr v0.4.12 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
github.com/prometheus/client_golang v1.20.5 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
@@ -302,107 +114,47 @@ require (
github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 // indirect
github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 // indirect
github.com/redis/go-redis/v9 v9.7.3 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rust-secure-code/go-rustaudit v0.0.0-20250226111315-e20ec32e963c // indirect
github.com/sagikazarmark/locafero v0.9.0 // indirect
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/sassoftware/go-rpmutils v0.4.0 // indirect
github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/sorairolake/lzip-go v0.3.8 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/spdx/gordf v0.0.0-20250128162952-000978ccd6fb // indirect
github.com/spdx/tools-golang v0.5.5 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.9.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/spf13/viper v1.20.1 // indirect
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/sylabs/sif/v2 v2.22.0 // indirect
github.com/sylabs/squashfs v1.0.6 // indirect
github.com/therootcompany/xz v1.0.1 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/vbatts/go-mtree v0.6.0 // indirect
github.com/vbatts/tar-split v0.12.1 // indirect
github.com/vifraa/gopom v1.0.0 // indirect
github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 // indirect
github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zclconf/go-cty v1.16.3 // indirect
github.com/zeebo/errs v1.4.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
go.etcd.io/bbolt v1.4.2 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.37.0 // indirect
go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.54.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 // indirect
go.opentelemetry.io/otel/log v0.8.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/sdk v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.32.0 // indirect
go.opentelemetry.io/otel/sdk v1.32.0 // indirect
go.opentelemetry.io/otel/sdk/log v0.8.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
go.opentelemetry.io/proto/otlp v1.4.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.32.0 // indirect
go.opentelemetry.io/otel/trace v1.32.0 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/term v0.36.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.38.0 // indirect
google.golang.org/api v0.242.0 // indirect
google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79 // indirect
google.golang.org/grpc v1.74.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.6.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 // indirect
google.golang.org/grpc v1.68.0 // indirect
google.golang.org/protobuf v1.35.1 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/postgres v1.5.7 // indirect
lukechampine.com/blake3 v1.2.1 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.39.1 // indirect
)
// Pin OpenTelemetry SDK to v1.32.0 for compatibility with distribution/distribution
replace (
go.opentelemetry.io/otel/sdk => go.opentelemetry.io/otel/sdk v1.32.0
go.opentelemetry.io/otel/sdk/log => go.opentelemetry.io/otel/sdk/log v0.8.0
go.opentelemetry.io/otel/sdk/metric => go.opentelemetry.io/otel/sdk/metric v1.32.0
)

1456
go.sum

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
{
"lexicon": 1,
"id": "io.atcr.authFullApp",
"defs": {
"main": {
"type": "permission-set",
"title": "AT Container Registry",
"title:langs": {},
"detail": "Push and pull container images to the ATProto Container Registry. Includes creating and managing image manifests, tags, and repository settings.",
"detail:langs": {},
"permissions": [
{
"type": "permission",
"resource": "repo",
"action": ["create", "update", "delete"],
"collection": ["io.atcr.manifest", "io.atcr.tag", "io.atcr.sailor.star", "io.atcr.sailor.profile", "io.atcr.repo.page"]
}
]
}
}
}

View File

@@ -1,37 +0,0 @@
{
"lexicon": 1,
"id": "io.atcr.hold",
"defs": {
"main": {
"type": "record",
"description": "Storage hold definition for Bring Your Own Storage (BYOS). Defines where blobs are stored.",
"key": "any",
"record": {
"type": "object",
"required": ["endpoint", "owner", "createdAt"],
"properties": {
"endpoint": {
"type": "string",
"format": "uri",
"description": "URL of the hold service (e.g., 'https://hold1.example.com')"
},
"owner": {
"type": "string",
"format": "did",
"description": "DID of the hold owner"
},
"public": {
"type": "boolean",
"description": "Whether this hold allows public blob reads (pulls) without authentication. Writes always require crew membership.",
"default": false
},
"createdAt": {
"type": "string",
"format": "datetime",
"description": "Hold creation timestamp"
}
}
}
}
}
}

View File

@@ -0,0 +1,49 @@
{
"lexicon": 1,
"id": "io.atcr.hold.captain",
"defs": {
"main": {
"type": "record",
"description": "Represents the hold's ownership and metadata. Stored as a singleton record at rkey 'self' in the hold's embedded PDS.",
"key": "literal:self",
"record": {
"type": "object",
"required": ["owner", "public", "allowAllCrew", "enableBlueskyPosts", "deployedAt"],
"properties": {
"owner": {
"type": "string",
"format": "did",
"description": "DID of the hold owner"
},
"public": {
"type": "boolean",
"description": "Whether this hold allows public blob reads (pulls) without authentication"
},
"allowAllCrew": {
"type": "boolean",
"description": "Allow any authenticated user to register as crew"
},
"enableBlueskyPosts": {
"type": "boolean",
"description": "Enable Bluesky posts when manifests are pushed"
},
"deployedAt": {
"type": "string",
"format": "datetime",
"description": "RFC3339 timestamp of when the hold was deployed"
},
"region": {
"type": "string",
"description": "S3 region where blobs are stored",
"maxLength": 64
},
"provider": {
"type": "string",
"description": "Deployment provider (e.g., fly.io, aws, etc.)",
"maxLength": 64
}
}
}
}
}
}

View File

@@ -4,40 +4,35 @@
"defs": {
"main": {
"type": "record",
"description": "Crew membership for a storage hold. Stored in the hold owner's PDS to maintain control over write access. Supports explicit DIDs (with backlinks), wildcard access, and handle patterns. Crew members can push blobs to the hold. Read access is controlled by the hold's public flag, not crew membership.",
"description": "Crew member in a hold's embedded PDS. Grants access permissions to push blobs to the hold. Stored in the hold's embedded PDS (one record per member).",
"key": "any",
"record": {
"type": "object",
"required": ["hold", "role", "createdAt"],
"required": ["member", "role", "permissions", "addedAt"],
"properties": {
"hold": {
"type": "string",
"format": "at-uri",
"description": "AT-URI of the hold record (e.g., 'at://did:plc:owner/io.atcr.hold/hold1')"
},
"member": {
"type": "string",
"format": "did",
"description": "DID of crew member (for individual access with backlinks). Exactly one of 'member' or 'memberPattern' must be set."
},
"memberPattern": {
"type": "string",
"description": "Pattern for matching multiple users. Supports wildcards: '*' (all users), '*.domain.com' (handle glob). Exactly one of 'member' or 'memberPattern' must be set."
"description": "DID of the crew member"
},
"role": {
"type": "string",
"description": "Member's role/permissions for write access. 'owner' = hold owner, 'write' = can push blobs. Read access is controlled by hold's public flag.",
"knownValues": ["owner", "write"]
"description": "Member's role in the hold",
"knownValues": ["owner", "admin", "write", "read"],
"maxLength": 32
},
"expiresAt": {
"permissions": {
"type": "array",
"description": "Specific permissions granted to this member",
"items": {
"type": "string",
"maxLength": 64
}
},
"addedAt": {
"type": "string",
"format": "datetime",
"description": "Optional expiration for this membership"
},
"createdAt": {
"type": "string",
"format": "datetime",
"description": "Membership creation timestamp"
"description": "RFC3339 timestamp of when the member was added"
}
}
}

View File

@@ -0,0 +1,51 @@
{
"lexicon": 1,
"id": "io.atcr.hold.layer",
"defs": {
"main": {
"type": "record",
"key": "tid",
"description": "Represents metadata about a container layer stored in the hold. Stored in the hold's embedded PDS for tracking and analytics.",
"record": {
"type": "object",
"required": ["digest", "size", "mediaType", "repository", "userDid", "userHandle", "createdAt"],
"properties": {
"digest": {
"type": "string",
"description": "Layer digest (e.g., sha256:abc123...)",
"maxLength": 128
},
"size": {
"type": "integer",
"description": "Size in bytes"
},
"mediaType": {
"type": "string",
"description": "Media type (e.g., application/vnd.oci.image.layer.v1.tar+gzip)",
"maxLength": 128
},
"repository": {
"type": "string",
"description": "Repository this layer belongs to",
"maxLength": 255
},
"userDid": {
"type": "string",
"format": "did",
"description": "DID of user who uploaded this layer"
},
"userHandle": {
"type": "string",
"format": "handle",
"description": "Handle of user (for display purposes)"
},
"createdAt": {
"type": "string",
"format": "datetime",
"description": "RFC3339 timestamp of when the layer was uploaded"
}
}
}
}
}
}

View File

@@ -8,7 +8,7 @@
"key": "tid",
"record": {
"type": "object",
"required": ["repository", "digest", "mediaType", "schemaVersion", "holdEndpoint", "createdAt"],
"required": ["repository", "digest", "mediaType", "schemaVersion", "createdAt"],
"properties": {
"repository": {
"type": "string",
@@ -17,12 +17,18 @@
},
"digest": {
"type": "string",
"description": "Content digest (e.g., 'sha256:abc123...')"
"description": "Content digest (e.g., 'sha256:abc123...')",
"maxLength": 128
},
"holdDid": {
"type": "string",
"format": "did",
"description": "DID of the hold service where blobs are stored (e.g., 'did:web:hold01.atcr.io'). Primary reference for hold resolution."
},
"holdEndpoint": {
"type": "string",
"format": "uri",
"description": "Hold service endpoint where blobs are stored (e.g., 'https://hold1.bob.com'). Historical reference."
"description": "Hold service endpoint URL where blobs are stored. DEPRECATED: Use holdDid instead. Kept for backward compatibility."
},
"mediaType": {
"type": "string",
@@ -32,7 +38,8 @@
"application/vnd.docker.distribution.manifest.v2+json",
"application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json"
]
],
"maxLength": 128
},
"schemaVersion": {
"type": "integer",
@@ -60,8 +67,8 @@
"description": "Referenced manifests (for manifest lists/indexes)"
},
"annotations": {
"type": "object",
"description": "Optional metadata annotations"
"type": "unknown",
"description": "Optional OCI annotation metadata. Map of string keys to string values (e.g., org.opencontainers.image.title → 'My App')."
},
"subject": {
"type": "ref",
@@ -87,7 +94,8 @@
"properties": {
"mediaType": {
"type": "string",
"description": "MIME type of the blob"
"description": "MIME type of the blob",
"maxLength": 128
},
"size": {
"type": "integer",
@@ -95,7 +103,8 @@
},
"digest": {
"type": "string",
"description": "Content digest (e.g., 'sha256:...')"
"description": "Content digest (e.g., 'sha256:...')",
"maxLength": 128
},
"urls": {
"type": "array",
@@ -106,8 +115,8 @@
"description": "Optional direct URLs to blob (for BYOS)"
},
"annotations": {
"type": "object",
"description": "Optional metadata"
"type": "unknown",
"description": "Optional OCI annotation metadata. Map of string keys to string values."
}
}
},
@@ -118,7 +127,8 @@
"properties": {
"mediaType": {
"type": "string",
"description": "Media type of the referenced manifest"
"description": "Media type of the referenced manifest",
"maxLength": 128
},
"size": {
"type": "integer",
@@ -126,7 +136,8 @@
},
"digest": {
"type": "string",
"description": "Content digest (e.g., 'sha256:...')"
"description": "Content digest (e.g., 'sha256:...')",
"maxLength": 128
},
"platform": {
"type": "ref",
@@ -134,8 +145,8 @@
"description": "Platform information for this manifest"
},
"annotations": {
"type": "object",
"description": "Optional metadata"
"type": "unknown",
"description": "Optional OCI annotation metadata. Map of string keys to string values."
}
}
},
@@ -146,26 +157,31 @@
"properties": {
"architecture": {
"type": "string",
"description": "CPU architecture (e.g., 'amd64', 'arm64', 'arm')"
"description": "CPU architecture (e.g., 'amd64', 'arm64', 'arm')",
"maxLength": 32
},
"os": {
"type": "string",
"description": "Operating system (e.g., 'linux', 'windows', 'darwin')"
"description": "Operating system (e.g., 'linux', 'windows', 'darwin')",
"maxLength": 32
},
"osVersion": {
"type": "string",
"description": "Optional OS version"
"description": "Optional OS version",
"maxLength": 64
},
"osFeatures": {
"type": "array",
"items": {
"type": "string"
"type": "string",
"maxLength": 64
},
"description": "Optional OS features"
},
"variant": {
"type": "string",
"description": "Optional CPU variant (e.g., 'v7' for ARM)"
"description": "Optional CPU variant (e.g., 'v7' for ARM)",
"maxLength": 32
}
}
}

View File

@@ -0,0 +1,43 @@
{
"lexicon": 1,
"id": "io.atcr.repo.page",
"defs": {
"main": {
"type": "record",
"description": "Repository page metadata including description and avatar. Users can edit this directly in their PDS to customize their repository page.",
"key": "any",
"record": {
"type": "object",
"required": ["repository", "createdAt", "updatedAt"],
"properties": {
"repository": {
"type": "string",
"description": "The name of the repository (e.g., 'myapp'). Must match the rkey.",
"maxLength": 256
},
"description": {
"type": "string",
"description": "Markdown README/description content for the repository page.",
"maxLength": 100000
},
"avatar": {
"type": "blob",
"description": "Repository avatar/icon image.",
"accept": ["image/png", "image/jpeg", "image/webp"],
"maxSize": 3000000
},
"createdAt": {
"type": "string",
"format": "datetime",
"description": "Record creation timestamp"
},
"updatedAt": {
"type": "string",
"format": "datetime",
"description": "Record last updated timestamp"
}
}
}
}
}
}

View File

@@ -27,7 +27,8 @@
},
"manifestDigest": {
"type": "string",
"description": "DEPRECATED: Digest of the manifest (e.g., 'sha256:...'). Kept for backward compatibility with old records. New records should use 'manifest' field instead."
"description": "DEPRECATED: Digest of the manifest (e.g., 'sha256:...'). Kept for backward compatibility with old records. New records should use 'manifest' field instead.",
"maxLength": 128
},
"createdAt": {
"type": "string",

25
license Normal file
View File

@@ -0,0 +1,25 @@
The MIT License (MIT)
=====================
Copyright © 2025 Evan Jarrett
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the “Software”), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -13,6 +13,7 @@ import (
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/distribution/distribution/v3/configuration"
@@ -20,14 +21,15 @@ import (
// Config represents the AppView service configuration
type Config struct {
Version string `yaml:"version"`
LogLevel string `yaml:"log_level"`
Server ServerConfig `yaml:"server"`
UI UIConfig `yaml:"ui"`
Health HealthConfig `yaml:"health"`
Jetstream JetstreamConfig `yaml:"jetstream"`
Auth AuthConfig `yaml:"auth"`
Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility
Version string `yaml:"version"`
LogLevel string `yaml:"log_level"`
Server ServerConfig `yaml:"server"`
UI UIConfig `yaml:"ui"`
Health HealthConfig `yaml:"health"`
Jetstream JetstreamConfig `yaml:"jetstream"`
Auth AuthConfig `yaml:"auth"`
CredentialHelper CredentialHelperConfig `yaml:"credential_helper"`
Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility
}
// ServerConfig defines server settings
@@ -77,9 +79,6 @@ type HealthConfig struct {
// CheckInterval is the hold health check refresh interval (from env: ATCR_HEALTH_CHECK_INTERVAL, default: 15m)
CheckInterval time.Duration `yaml:"check_interval"`
// ReadmeCacheTTL is the README cache TTL (from env: ATCR_README_CACHE_TTL, default: 1h)
ReadmeCacheTTL time.Duration `yaml:"readme_cache_ttl"`
}
// JetstreamConfig defines ATProto Jetstream settings
@@ -113,6 +112,21 @@ type AuthConfig struct {
ServiceName string `yaml:"service_name"`
}
// CredentialHelperConfig defines credential helper version and download settings
type CredentialHelperConfig struct {
// Version is the latest credential helper version (from env: ATCR_CREDENTIAL_HELPER_VERSION)
// e.g., "v0.0.2"
Version string `yaml:"version"`
// TangledRepo is the Tangled repository URL for downloads (from env: ATCR_CREDENTIAL_HELPER_TANGLED_REPO)
// Default: "https://tangled.org/@evan.jarrett.net/at-container-registry"
TangledRepo string `yaml:"tangled_repo"`
// Checksums is a comma-separated list of platform:sha256 pairs (from env: ATCR_CREDENTIAL_HELPER_CHECKSUMS)
// e.g., "linux_amd64:abc123,darwin_arm64:def456"
Checksums map[string]string `yaml:"-"`
}
// LoadConfigFromEnv builds a complete configuration from environment variables
// This follows the same pattern as the hold service (no config files, only env vars)
func LoadConfigFromEnv() (*Config, error) {
@@ -148,7 +162,6 @@ func LoadConfigFromEnv() (*Config, error) {
// Health and cache configuration
cfg.Health.CacheTTL = getDurationOrDefault("ATCR_HEALTH_CACHE_TTL", 15*time.Minute)
cfg.Health.CheckInterval = getDurationOrDefault("ATCR_HEALTH_CHECK_INTERVAL", 15*time.Minute)
cfg.Health.ReadmeCacheTTL = getDurationOrDefault("ATCR_README_CACHE_TTL", 1*time.Hour)
// Jetstream configuration
cfg.Jetstream.URL = getEnvOrDefault("JETSTREAM_URL", "wss://jetstream2.us-west.bsky.network/subscribe")
@@ -171,6 +184,11 @@ func LoadConfigFromEnv() (*Config, error) {
// Derive service name from base URL or env var (used for JWT issuer and service)
cfg.Auth.ServiceName = getServiceName(cfg.Server.BaseURL)
// Credential helper configuration
cfg.CredentialHelper.Version = os.Getenv("ATCR_CREDENTIAL_HELPER_VERSION")
cfg.CredentialHelper.TangledRepo = getEnvOrDefault("ATCR_CREDENTIAL_HELPER_TANGLED_REPO", "https://tangled.org/@evan.jarrett.net/at-container-registry")
cfg.CredentialHelper.Checksums = parseChecksums(os.Getenv("ATCR_CREDENTIAL_HELPER_CHECKSUMS"))
// Build distribution configuration for compatibility with distribution library
distConfig, err := buildDistributionConfig(cfg)
if err != nil {
@@ -361,3 +379,25 @@ func getDurationOrDefault(envKey string, defaultValue time.Duration) time.Durati
return parsed
}
// parseChecksums parses a comma-separated list of platform:sha256 pairs
// e.g., "linux_amd64:abc123,darwin_arm64:def456"
func parseChecksums(checksumsStr string) map[string]string {
checksums := make(map[string]string)
if checksumsStr == "" {
return checksums
}
pairs := strings.Split(checksumsStr, ",")
for _, pair := range pairs {
parts := strings.SplitN(strings.TrimSpace(pair), ":", 2)
if len(parts) == 2 {
platform := strings.TrimSpace(parts[0])
hash := strings.TrimSpace(parts[1])
if platform != "" && hash != "" {
checksums[platform] = hash
}
}
}
return checksums
}

View File

@@ -0,0 +1,11 @@
description: Add is_attestation column to manifest_references table
query: |
-- Add is_attestation column to track attestation manifests
-- Attestation manifests have vnd.docker.reference.type = "attestation-manifest"
ALTER TABLE manifest_references ADD COLUMN is_attestation BOOLEAN DEFAULT FALSE;
-- Mark existing unknown/unknown platforms as attestations
-- Docker BuildKit attestation manifests always have unknown/unknown platform
UPDATE manifest_references
SET is_attestation = 1
WHERE platform_os = 'unknown' AND platform_architecture = 'unknown';

View File

@@ -0,0 +1,18 @@
description: Add repo_pages table and remove readme_cache
query: |
-- Create repo_pages table for storing repository page metadata
-- This replaces readme_cache with PDS-synced data
CREATE TABLE IF NOT EXISTS repo_pages (
did TEXT NOT NULL,
repository TEXT NOT NULL,
description TEXT,
avatar_cid TEXT,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
PRIMARY KEY(did, repository),
FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_repo_pages_did ON repo_pages(did);
-- Drop readme_cache table (no longer needed)
DROP TABLE IF EXISTS readme_cache;

View File

@@ -45,6 +45,7 @@ type ManifestReference struct {
PlatformOS string
PlatformVariant string
PlatformOSVersion string
IsAttestation bool // true if vnd.docker.reference.type = "attestation-manifest"
ReferenceIndex int
}
@@ -147,28 +148,19 @@ type PlatformInfo struct {
// TagWithPlatforms extends Tag with platform information
type TagWithPlatforms struct {
Tag
Platforms []PlatformInfo
IsMultiArch bool
}
// VulnerabilitySummary represents vulnerability counts by severity
type VulnerabilitySummary struct {
Critical int
High int
Medium int
Low int
Total int
Platforms []PlatformInfo
IsMultiArch bool
HasAttestations bool // true if manifest list contains attestation references
}
// ManifestWithMetadata extends Manifest with tags and platform information
type ManifestWithMetadata struct {
Manifest
Tags []string
Platforms []PlatformInfo
PlatformCount int
IsManifestList bool
Reachable bool // Whether the hold endpoint is reachable
Pending bool // Whether health check is still in progress
Vulnerabilities *VulnerabilitySummary
HasVulnerabilities bool
Tags []string
Platforms []PlatformInfo
PlatformCount int
IsManifestList bool
HasAttestations bool // true if manifest list contains attestation references
Reachable bool // Whether the hold endpoint is reachable
Pending bool // Whether health check is still in progress
}

View File

@@ -112,6 +112,25 @@ func (s *OAuthStore) DeleteSessionsForDID(ctx context.Context, did string) error
return nil
}
// DeleteOldSessionsForDID removes all sessions for a DID except the specified session to keep
// This is used during OAuth callback to clean up stale sessions with expired refresh tokens
func (s *OAuthStore) DeleteOldSessionsForDID(ctx context.Context, did string, keepSessionID string) error {
result, err := s.db.ExecContext(ctx, `
DELETE FROM oauth_sessions WHERE account_did = ? AND session_id != ?
`, did, keepSessionID)
if err != nil {
return fmt.Errorf("failed to delete old sessions for DID: %w", err)
}
deleted, _ := result.RowsAffected()
if deleted > 0 {
slog.Info("Deleted old OAuth sessions for DID", "count", deleted, "did", did, "kept", keepSessionID)
}
return nil
}
// GetAuthRequestInfo retrieves authentication request data by state
func (s *OAuthStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) {
var requestDataJSON string
@@ -318,6 +337,103 @@ func scopesMatch(stored, desired []string) bool {
return true
}
// GetSessionStats returns statistics about stored OAuth sessions
// Useful for monitoring and debugging session health
func (s *OAuthStore) GetSessionStats(ctx context.Context) (map[string]interface{}, error) {
stats := make(map[string]interface{})
// Total sessions
var totalSessions int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM oauth_sessions`).Scan(&totalSessions)
if err != nil {
return nil, fmt.Errorf("failed to count sessions: %w", err)
}
stats["total_sessions"] = totalSessions
// Sessions by age
var sessionsOlderThan1Hour, sessionsOlderThan1Day, sessionsOlderThan7Days int
err = s.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM oauth_sessions
WHERE updated_at < datetime('now', '-1 hour')
`).Scan(&sessionsOlderThan1Hour)
if err == nil {
stats["sessions_idle_1h+"] = sessionsOlderThan1Hour
}
err = s.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM oauth_sessions
WHERE updated_at < datetime('now', '-1 day')
`).Scan(&sessionsOlderThan1Day)
if err == nil {
stats["sessions_idle_1d+"] = sessionsOlderThan1Day
}
err = s.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM oauth_sessions
WHERE updated_at < datetime('now', '-7 days')
`).Scan(&sessionsOlderThan7Days)
if err == nil {
stats["sessions_idle_7d+"] = sessionsOlderThan7Days
}
// Recent sessions (updated in last 5 minutes)
var recentSessions int
err = s.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM oauth_sessions
WHERE updated_at > datetime('now', '-5 minutes')
`).Scan(&recentSessions)
if err == nil {
stats["sessions_active_5m"] = recentSessions
}
return stats, nil
}
// ListSessionsForMonitoring returns a list of all sessions with basic info for monitoring
// Returns: DID, session age (minutes), last update time
func (s *OAuthStore) ListSessionsForMonitoring(ctx context.Context) ([]map[string]interface{}, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT
account_did,
session_id,
created_at,
updated_at,
CAST((julianday('now') - julianday(updated_at)) * 24 * 60 AS INTEGER) as idle_minutes
FROM oauth_sessions
ORDER BY updated_at DESC
`)
if err != nil {
return nil, fmt.Errorf("failed to query sessions: %w", err)
}
defer rows.Close()
var sessions []map[string]interface{}
for rows.Next() {
var did, sessionID, createdAt, updatedAt string
var idleMinutes int
if err := rows.Scan(&did, &sessionID, &createdAt, &updatedAt, &idleMinutes); err != nil {
slog.Warn("Failed to scan session row", "error", err)
continue
}
sessions = append(sessions, map[string]interface{}{
"did": did,
"session_id": sessionID,
"created_at": createdAt,
"updated_at": updatedAt,
"idle_minutes": idleMinutes,
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating sessions: %w", err)
}
return sessions, nil
}
// makeSessionKey creates a composite key for session storage
func makeSessionKey(did, sessionID string) string {
return fmt.Sprintf("%s:%s", did, sessionID)

View File

@@ -7,6 +7,12 @@ import (
"time"
)
// BlobCDNURL returns the CDN URL for an ATProto blob
// This is a local copy to avoid importing atproto (prevents circular dependencies)
func BlobCDNURL(did, cid string) string {
return fmt.Sprintf("https://imgs.blue/%s/%s", did, cid)
}
// escapeLikePattern escapes SQL LIKE wildcards (%, _) and backslash for safe searching.
// It also sanitizes the input to prevent injection attacks via special characters.
func escapeLikePattern(s string) string {
@@ -46,11 +52,13 @@ func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string, currentUs
COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0),
COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = u.did AND repository = t.repository), 0),
t.created_at,
m.hold_endpoint
m.hold_endpoint,
COALESCE(rp.avatar_cid, '')
FROM tags t
JOIN users u ON t.did = u.did
JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest
LEFT JOIN repository_stats rs ON t.did = rs.did AND t.repository = rs.repository
LEFT JOIN repo_pages rp ON t.did = rp.did AND t.repository = rp.repository
`
args := []any{currentUserDID}
@@ -73,10 +81,15 @@ func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string, currentUs
for rows.Next() {
var p Push
var isStarredInt int
if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &isStarredInt, &p.CreatedAt, &p.HoldEndpoint); err != nil {
var avatarCID string
if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &isStarredInt, &p.CreatedAt, &p.HoldEndpoint, &avatarCID); err != nil {
return nil, 0, err
}
p.IsStarred = isStarredInt > 0
// Prefer repo page avatar over annotation icon
if avatarCID != "" {
p.IconURL = BlobCDNURL(p.DID, avatarCID)
}
pushes = append(pushes, p)
}
@@ -119,11 +132,13 @@ func SearchPushes(db *sql.DB, query string, limit, offset int, currentUserDID st
COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0),
COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = u.did AND repository = t.repository), 0),
t.created_at,
m.hold_endpoint
m.hold_endpoint,
COALESCE(rp.avatar_cid, '')
FROM tags t
JOIN users u ON t.did = u.did
JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest
LEFT JOIN repository_stats rs ON t.did = rs.did AND t.repository = rs.repository
LEFT JOIN repo_pages rp ON t.did = rp.did AND t.repository = rp.repository
WHERE u.handle LIKE ? ESCAPE '\'
OR u.did = ?
OR t.repository LIKE ? ESCAPE '\'
@@ -146,10 +161,15 @@ func SearchPushes(db *sql.DB, query string, limit, offset int, currentUserDID st
for rows.Next() {
var p Push
var isStarredInt int
if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &isStarredInt, &p.CreatedAt, &p.HoldEndpoint); err != nil {
var avatarCID string
if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &isStarredInt, &p.CreatedAt, &p.HoldEndpoint, &avatarCID); err != nil {
return nil, 0, err
}
p.IsStarred = isStarredInt > 0
// Prefer repo page avatar over annotation icon
if avatarCID != "" {
p.IconURL = BlobCDNURL(p.DID, avatarCID)
}
pushes = append(pushes, p)
}
@@ -293,6 +313,12 @@ func GetUserRepositories(db *sql.DB, did string) ([]Repository, error) {
r.IconURL = annotations["io.atcr.icon"]
r.ReadmeURL = annotations["io.atcr.readme"]
// Check for repo page avatar (overrides annotation icon)
repoPage, err := GetRepoPage(db, did, r.Name)
if err == nil && repoPage != nil && repoPage.AvatarCID != "" {
r.IconURL = BlobCDNURL(did, repoPage.AvatarCID)
}
repos = append(repos, r)
}
@@ -596,6 +622,7 @@ func DeleteTag(db *sql.DB, did, repository, tag string) error {
// GetTagsWithPlatforms returns all tags for a repository with platform information
// Only multi-arch tags (manifest lists) have platform info in manifest_references
// Single-arch tags will have empty Platforms slice (platform is obvious for single-arch)
// Attestation references (unknown/unknown platforms) are filtered out but tracked via HasAttestations
func GetTagsWithPlatforms(db *sql.DB, did, repository string) ([]TagWithPlatforms, error) {
rows, err := db.Query(`
SELECT
@@ -609,7 +636,8 @@ func GetTagsWithPlatforms(db *sql.DB, did, repository string) ([]TagWithPlatform
COALESCE(mr.platform_os, '') as platform_os,
COALESCE(mr.platform_architecture, '') as platform_architecture,
COALESCE(mr.platform_variant, '') as platform_variant,
COALESCE(mr.platform_os_version, '') as platform_os_version
COALESCE(mr.platform_os_version, '') as platform_os_version,
COALESCE(mr.is_attestation, 0) as is_attestation
FROM tags t
JOIN manifests m ON t.digest = m.digest AND t.did = m.did AND t.repository = m.repository
LEFT JOIN manifest_references mr ON m.id = mr.manifest_id
@@ -629,9 +657,10 @@ func GetTagsWithPlatforms(db *sql.DB, did, repository string) ([]TagWithPlatform
for rows.Next() {
var t Tag
var mediaType, platformOS, platformArch, platformVariant, platformOSVersion string
var isAttestation bool
if err := rows.Scan(&t.ID, &t.DID, &t.Repository, &t.Tag, &t.Digest, &t.CreatedAt,
&mediaType, &platformOS, &platformArch, &platformVariant, &platformOSVersion); err != nil {
&mediaType, &platformOS, &platformArch, &platformVariant, &platformOSVersion, &isAttestation); err != nil {
return nil, err
}
@@ -645,6 +674,13 @@ func GetTagsWithPlatforms(db *sql.DB, did, repository string) ([]TagWithPlatform
tagOrder = append(tagOrder, tagKey)
}
// Track if manifest list has attestations
if isAttestation {
tagMap[tagKey].HasAttestations = true
// Skip attestation references in platform display
continue
}
// Add platform info if present (only for multi-arch manifest lists)
if platformOS != "" || platformArch != "" {
tagMap[tagKey].Platforms = append(tagMap[tagKey].Platforms, PlatformInfo{
@@ -724,6 +760,30 @@ func GetNewestManifestForRepo(db *sql.DB, did, repository string) (*Manifest, er
return &m, nil
}
// GetLatestHoldDIDForRepo returns the hold DID from the most recent manifest for a repository
// Returns empty string if no manifests exist (e.g., first push)
// This is used instead of the in-memory cache to determine which hold to use for blob operations
func GetLatestHoldDIDForRepo(db *sql.DB, did, repository string) (string, error) {
var holdDID string
err := db.QueryRow(`
SELECT hold_endpoint
FROM manifests
WHERE did = ? AND repository = ?
ORDER BY created_at DESC
LIMIT 1
`, did, repository).Scan(&holdDID)
if err == sql.ErrNoRows {
// No manifests yet - return empty string (first push case)
return "", nil
}
if err != nil {
return "", err
}
return holdDID, nil
}
// GetRepositoriesForDID returns all unique repository names for a DID
// Used by backfill to reconcile annotations for all repositories
func GetRepositoriesForDID(db *sql.DB, did string) ([]string, error) {
@@ -780,12 +840,12 @@ func InsertManifestReference(db *sql.DB, ref *ManifestReference) error {
INSERT INTO manifest_references (manifest_id, digest, size, media_type,
platform_architecture, platform_os,
platform_variant, platform_os_version,
reference_index)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
is_attestation, reference_index)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, ref.ManifestID, ref.Digest, ref.Size, ref.MediaType,
ref.PlatformArchitecture, ref.PlatformOS,
ref.PlatformVariant, ref.PlatformOSVersion,
ref.ReferenceIndex)
ref.IsAttestation, ref.ReferenceIndex)
return err
}
@@ -916,7 +976,8 @@ func GetTopLevelManifests(db *sql.DB, did, repository string, limit, offset int)
mr.platform_os,
mr.platform_architecture,
mr.platform_variant,
mr.platform_os_version
mr.platform_os_version,
COALESCE(mr.is_attestation, 0) as is_attestation
FROM manifest_references mr
WHERE mr.manifest_id = ?
ORDER BY mr.reference_index
@@ -930,12 +991,20 @@ func GetTopLevelManifests(db *sql.DB, did, repository string, limit, offset int)
for platformRows.Next() {
var p PlatformInfo
var os, arch, variant, osVersion sql.NullString
var isAttestation bool
if err := platformRows.Scan(&os, &arch, &variant, &osVersion); err != nil {
if err := platformRows.Scan(&os, &arch, &variant, &osVersion, &isAttestation); err != nil {
platformRows.Close()
return nil, err
}
// Track if manifest list has attestations
if isAttestation {
manifests[i].HasAttestations = true
// Skip attestation references in platform display
continue
}
if os.Valid {
p.OS = os.String
}
@@ -1015,7 +1084,8 @@ func GetManifestDetail(db *sql.DB, did, repository, digest string) (*ManifestWit
mr.platform_os,
mr.platform_architecture,
mr.platform_variant,
mr.platform_os_version
mr.platform_os_version,
COALESCE(mr.is_attestation, 0) as is_attestation
FROM manifest_references mr
WHERE mr.manifest_id = ?
ORDER BY mr.reference_index
@@ -1030,11 +1100,19 @@ func GetManifestDetail(db *sql.DB, did, repository, digest string) (*ManifestWit
for platforms.Next() {
var p PlatformInfo
var os, arch, variant, osVersion sql.NullString
var isAttestation bool
if err := platforms.Scan(&os, &arch, &variant, &osVersion); err != nil {
if err := platforms.Scan(&os, &arch, &variant, &osVersion, &isAttestation); err != nil {
return nil, err
}
// Track if manifest list has attestations
if isAttestation {
m.HasAttestations = true
// Skip attestation references in platform display
continue
}
if os.Valid {
p.OS = os.String
}
@@ -1556,26 +1634,6 @@ func parseTimestamp(s string) (time.Time, error) {
return time.Time{}, fmt.Errorf("unable to parse timestamp: %s", s)
}
// MetricsDB wraps a sql.DB and implements the metrics interface for middleware
type MetricsDB struct {
db *sql.DB
}
// NewMetricsDB creates a new metrics database wrapper
func NewMetricsDB(db *sql.DB) *MetricsDB {
return &MetricsDB{db: db}
}
// IncrementPullCount increments the pull count for a repository
func (m *MetricsDB) IncrementPullCount(did, repository string) error {
return IncrementPullCount(m.db, did, repository)
}
// IncrementPushCount increments the push count for a repository
func (m *MetricsDB) IncrementPushCount(did, repository string) error {
return IncrementPushCount(m.db, did, repository)
}
// GetFeaturedRepositories fetches top repositories sorted by stars and pulls
func GetFeaturedRepositories(db *sql.DB, limit int, currentUserDID string) ([]FeaturedRepository, error) {
query := `
@@ -1603,11 +1661,13 @@ func GetFeaturedRepositories(db *sql.DB, limit int, currentUserDID string) ([]Fe
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'io.atcr.icon'), ''),
rs.pull_count,
rs.star_count,
COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = m.did AND repository = m.repository), 0)
COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = m.did AND repository = m.repository), 0),
COALESCE(rp.avatar_cid, '')
FROM latest_manifests lm
JOIN manifests m ON lm.latest_id = m.id
JOIN users u ON m.did = u.did
JOIN repo_stats rs ON m.did = rs.did AND m.repository = rs.repository
LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository
ORDER BY rs.score DESC, rs.star_count DESC, rs.pull_count DESC, m.created_at DESC
LIMIT ?
`
@@ -1622,15 +1682,88 @@ func GetFeaturedRepositories(db *sql.DB, limit int, currentUserDID string) ([]Fe
for rows.Next() {
var f FeaturedRepository
var isStarredInt int
var avatarCID string
if err := rows.Scan(&f.OwnerDID, &f.OwnerHandle, &f.Repository,
&f.Title, &f.Description, &f.IconURL, &f.PullCount, &f.StarCount, &isStarredInt); err != nil {
&f.Title, &f.Description, &f.IconURL, &f.PullCount, &f.StarCount, &isStarredInt, &avatarCID); err != nil {
return nil, err
}
f.IsStarred = isStarredInt > 0
// Prefer repo page avatar over annotation icon
if avatarCID != "" {
f.IconURL = BlobCDNURL(f.OwnerDID, avatarCID)
}
featured = append(featured, f)
}
return featured, nil
}
// RepoPage represents a repository page record cached from PDS
type RepoPage struct {
DID string
Repository string
Description string
AvatarCID string
CreatedAt time.Time
UpdatedAt time.Time
}
// UpsertRepoPage inserts or updates a repo page record
func UpsertRepoPage(db *sql.DB, did, repository, description, avatarCID string, createdAt, updatedAt time.Time) error {
_, err := db.Exec(`
INSERT INTO repo_pages (did, repository, description, avatar_cid, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(did, repository) DO UPDATE SET
description = excluded.description,
avatar_cid = excluded.avatar_cid,
updated_at = excluded.updated_at
`, did, repository, description, avatarCID, createdAt, updatedAt)
return err
}
// GetRepoPage retrieves a repo page record
func GetRepoPage(db *sql.DB, did, repository string) (*RepoPage, error) {
var rp RepoPage
err := db.QueryRow(`
SELECT did, repository, description, avatar_cid, created_at, updated_at
FROM repo_pages
WHERE did = ? AND repository = ?
`, did, repository).Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.CreatedAt, &rp.UpdatedAt)
if err != nil {
return nil, err
}
return &rp, nil
}
// DeleteRepoPage deletes a repo page record
func DeleteRepoPage(db *sql.DB, did, repository string) error {
_, err := db.Exec(`
DELETE FROM repo_pages WHERE did = ? AND repository = ?
`, did, repository)
return err
}
// GetRepoPagesByDID returns all repo pages for a DID
func GetRepoPagesByDID(db *sql.DB, did string) ([]RepoPage, error) {
rows, err := db.Query(`
SELECT did, repository, description, avatar_cid, created_at, updated_at
FROM repo_pages
WHERE did = ?
`, did)
if err != nil {
return nil, err
}
defer rows.Close()
var pages []RepoPage
for rows.Next() {
var rp RepoPage
if err := rows.Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.CreatedAt, &rp.UpdatedAt); err != nil {
return nil, err
}
pages = append(pages, rp)
}
return pages, rows.Err()
}

View File

@@ -86,17 +86,34 @@ func runMigrations(db *sql.DB) error {
continue
}
// Apply migration
// Apply migration in a transaction
slog.Info("Applying migration", "version", m.Version, "name", m.Name, "description", m.Description)
if _, err := db.Exec(m.Query); err != nil {
return fmt.Errorf("failed to apply migration %d (%s): %w", m.Version, m.Name, err)
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction for migration %d: %w", m.Version, err)
}
// Split query into individual statements and execute each
// go-sqlite3's Exec() doesn't reliably execute all statements in multi-statement queries
statements := splitSQLStatements(m.Query)
for i, stmt := range statements {
if _, err := tx.Exec(stmt); err != nil {
tx.Rollback()
return fmt.Errorf("failed to apply migration %d (%s) statement %d: %w", m.Version, m.Name, i+1, err)
}
}
// Record migration
if _, err := db.Exec("INSERT INTO schema_migrations (version) VALUES (?)", m.Version); err != nil {
if _, err := tx.Exec("INSERT INTO schema_migrations (version) VALUES (?)", m.Version); err != nil {
tx.Rollback()
return fmt.Errorf("failed to record migration %d: %w", m.Version, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit migration %d: %w", m.Version, err)
}
slog.Info("Migration applied successfully", "version", m.Version)
}
@@ -146,6 +163,42 @@ func loadMigrations() ([]Migration, error) {
return migrations, nil
}
// splitSQLStatements splits a SQL query into individual statements.
// It handles semicolons as statement separators and filters out empty statements.
func splitSQLStatements(query string) []string {
var statements []string
// Split on semicolons
parts := strings.Split(query, ";")
for _, part := range parts {
// Trim whitespace
stmt := strings.TrimSpace(part)
// Skip empty statements (could be trailing semicolon or comment-only)
if stmt == "" {
continue
}
// Skip comment-only statements
lines := strings.Split(stmt, "\n")
hasCode := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed != "" && !strings.HasPrefix(trimmed, "--") {
hasCode = true
break
}
}
if hasCode {
statements = append(statements, stmt)
}
}
return statements
}
// parseMigrationFilename extracts version and name from migration filename
// Expected format: 0001_migration_name.yaml
// Returns: version (int), name (string), error

View File

@@ -67,6 +67,7 @@ CREATE TABLE IF NOT EXISTS manifest_references (
platform_os TEXT,
platform_variant TEXT,
platform_os_version TEXT,
is_attestation BOOLEAN DEFAULT FALSE,
reference_index INTEGER NOT NULL,
PRIMARY KEY(manifest_id, reference_index),
FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE
@@ -204,9 +205,14 @@ CREATE TABLE IF NOT EXISTS hold_crew_denials (
);
CREATE INDEX IF NOT EXISTS idx_crew_denials_retry ON hold_crew_denials(next_retry_at);
CREATE TABLE IF NOT EXISTS readme_cache (
url TEXT PRIMARY KEY,
html TEXT NOT NULL,
fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
CREATE TABLE IF NOT EXISTS repo_pages (
did TEXT NOT NULL,
repository TEXT NOT NULL,
description TEXT,
avatar_cid TEXT,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
PRIMARY KEY(did, repository),
FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_readme_cache_fetched ON readme_cache(fetched_at);
CREATE INDEX IF NOT EXISTS idx_repo_pages_did ON repo_pages(did);

View File

@@ -0,0 +1,92 @@
package db
import (
"testing"
)
func TestSplitSQLStatements(t *testing.T) {
tests := []struct {
name string
query string
expected []string
}{
{
name: "single statement",
query: "SELECT 1",
expected: []string{"SELECT 1"},
},
{
name: "single statement with semicolon",
query: "SELECT 1;",
expected: []string{"SELECT 1"},
},
{
name: "two statements",
query: "SELECT 1; SELECT 2;",
expected: []string{"SELECT 1", "SELECT 2"},
},
{
name: "statements with comments",
query: `-- This is a comment
ALTER TABLE foo ADD COLUMN bar TEXT;
-- Another comment
UPDATE foo SET bar = 'test';`,
expected: []string{
"-- This is a comment\nALTER TABLE foo ADD COLUMN bar TEXT",
"-- Another comment\nUPDATE foo SET bar = 'test'",
},
},
{
name: "comment-only sections filtered",
query: `-- Just a comment
;
SELECT 1;`,
expected: []string{"SELECT 1"},
},
{
name: "empty query",
query: "",
expected: nil,
},
{
name: "whitespace only",
query: " \n\t ",
expected: nil,
},
{
name: "migration 0005 format",
query: `-- Add is_attestation column to track attestation manifests
-- Attestation manifests have vnd.docker.reference.type = "attestation-manifest"
ALTER TABLE manifest_references ADD COLUMN is_attestation BOOLEAN DEFAULT FALSE;
-- Mark existing unknown/unknown platforms as attestations
-- Docker BuildKit attestation manifests always have unknown/unknown platform
UPDATE manifest_references
SET is_attestation = 1
WHERE platform_os = 'unknown' AND platform_architecture = 'unknown';`,
expected: []string{
"-- Add is_attestation column to track attestation manifests\n-- Attestation manifests have vnd.docker.reference.type = \"attestation-manifest\"\nALTER TABLE manifest_references ADD COLUMN is_attestation BOOLEAN DEFAULT FALSE",
"-- Mark existing unknown/unknown platforms as attestations\n-- Docker BuildKit attestation manifests always have unknown/unknown platform\nUPDATE manifest_references\nSET is_attestation = 1\nWHERE platform_os = 'unknown' AND platform_architecture = 'unknown'",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := splitSQLStatements(tt.query)
if len(result) != len(tt.expected) {
t.Errorf("got %d statements, want %d\ngot: %v\nwant: %v",
len(result), len(tt.expected), result, tt.expected)
return
}
for i := range result {
if result[i] != tt.expected[i] {
t.Errorf("statement %d:\ngot: %q\nwant: %q", i, result[i], tt.expected[i])
}
}
})
}
}

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"log/slog"
"net/http"
"strings"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/middleware"
@@ -43,18 +44,9 @@ func (h *StarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
return
}
// Get OAuth session for the authenticated user
slog.Debug("Getting OAuth session for star", "user_did", user.DID)
session, err := h.Refresher.GetSession(r.Context(), user.DID)
if err != nil {
slog.Warn("Failed to get OAuth session for star", "user_did", user.DID, "error", err)
http.Error(w, fmt.Sprintf("Failed to get OAuth session: %v", err), http.StatusUnauthorized)
return
}
// Get user's PDS client (use indigo's API client which handles DPoP automatically)
apiClient := session.APIClient()
pdsClient := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient)
// Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety)
slog.Debug("Creating PDS client for star", "user_did", user.DID)
pdsClient := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
// Create star record
starRecord := atproto.NewStarRecord(ownerDID, repository)
@@ -63,6 +55,11 @@ func (h *StarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
// Write star record to user's PDS
_, err = pdsClient.PutRecord(r.Context(), atproto.StarCollection, rkey, starRecord)
if err != nil {
// Check if OAuth error - if so, invalidate sessions and return 401
if handleOAuthError(r.Context(), h.Refresher, user.DID, err) {
http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized)
return
}
slog.Error("Failed to create star record", "error", err)
http.Error(w, fmt.Sprintf("Failed to create star: %v", err), http.StatusInternalServerError)
return
@@ -101,18 +98,9 @@ func (h *UnstarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
return
}
// Get OAuth session for the authenticated user
slog.Debug("Getting OAuth session for unstar", "user_did", user.DID)
session, err := h.Refresher.GetSession(r.Context(), user.DID)
if err != nil {
slog.Warn("Failed to get OAuth session for unstar", "user_did", user.DID, "error", err)
http.Error(w, fmt.Sprintf("Failed to get OAuth session: %v", err), http.StatusUnauthorized)
return
}
// Get user's PDS client (use indigo's API client which handles DPoP automatically)
apiClient := session.APIClient()
pdsClient := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient)
// Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety)
slog.Debug("Creating PDS client for unstar", "user_did", user.DID)
pdsClient := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
// Delete star record from user's PDS
rkey := atproto.StarRecordKey(ownerDID, repository)
@@ -121,6 +109,11 @@ func (h *UnstarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
if err != nil {
// If record doesn't exist, still return success (idempotent)
if !errors.Is(err, atproto.ErrRecordNotFound) {
// Check if OAuth error - if so, invalidate sessions and return 401
if handleOAuthError(r.Context(), h.Refresher, user.DID, err) {
http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized)
return
}
slog.Error("Failed to delete star record", "error", err)
http.Error(w, fmt.Sprintf("Failed to delete star: %v", err), http.StatusInternalServerError)
return
@@ -162,24 +155,22 @@ func (h *CheckStarHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// Get OAuth session for the authenticated user
session, err := h.Refresher.GetSession(r.Context(), user.DID)
if err != nil {
slog.Debug("Failed to get OAuth session for check star", "user_did", user.DID, "error", err)
// No OAuth session - return not starred
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"starred": false})
return
}
// Get user's PDS client (use indigo's API client which handles DPoP automatically)
apiClient := session.APIClient()
pdsClient := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient)
// Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety)
// Note: Error handling moves to the PDS call - if session doesn't exist, GetRecord will fail
pdsClient := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
// Check if star record exists
rkey := atproto.StarRecordKey(ownerDID, repository)
_, err = pdsClient.GetRecord(r.Context(), atproto.StarCollection, rkey)
// Check if OAuth error - if so, invalidate sessions
if err != nil && handleOAuthError(r.Context(), h.Refresher, user.DID, err) {
// For a read operation, just return not starred instead of error
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"starred": false})
return
}
starred := err == nil
// Return result
@@ -252,3 +243,61 @@ func (h *ManifestDetailHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(manifest)
}
// CredentialHelperVersionResponse is the response for the credential helper version API
type CredentialHelperVersionResponse struct {
Latest string `json:"latest"`
DownloadURLs map[string]string `json:"download_urls"`
Checksums map[string]string `json:"checksums"`
ReleaseNotes string `json:"release_notes,omitempty"`
}
// CredentialHelperVersionHandler returns the latest credential helper version info
type CredentialHelperVersionHandler struct {
Version string
TangledRepo string
Checksums map[string]string
}
// Supported platforms for download URLs
var credentialHelperPlatforms = []struct {
key string // API key (e.g., "linux_amd64")
os string // OS name in archive (e.g., "Linux")
arch string // Arch name in archive (e.g., "x86_64")
ext string // Archive extension (e.g., "tar.gz" or "zip")
}{
{"linux_amd64", "Linux", "x86_64", "tar.gz"},
{"linux_arm64", "Linux", "arm64", "tar.gz"},
{"darwin_amd64", "Darwin", "x86_64", "tar.gz"},
{"darwin_arm64", "Darwin", "arm64", "tar.gz"},
{"windows_amd64", "Windows", "x86_64", "zip"},
{"windows_arm64", "Windows", "arm64", "zip"},
}
func (h *CredentialHelperVersionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Check if version is configured
if h.Version == "" {
http.Error(w, "Credential helper version not configured", http.StatusServiceUnavailable)
return
}
// Build download URLs for all platforms
// URL format: {TangledRepo}/tags/{version}/download/docker-credential-atcr_{version_without_v}_{OS}_{Arch}.{ext}
downloadURLs := make(map[string]string)
versionWithoutV := strings.TrimPrefix(h.Version, "v")
for _, p := range credentialHelperPlatforms {
filename := fmt.Sprintf("docker-credential-atcr_%s_%s_%s.%s", versionWithoutV, p.os, p.arch, p.ext)
downloadURLs[p.key] = fmt.Sprintf("%s/tags/%s/download/%s", h.TangledRepo, h.Version, filename)
}
response := CredentialHelperVersionResponse{
Latest: h.Version,
DownloadURLs: downloadURLs,
Checksums: h.Checksums,
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=300") // Cache for 5 minutes
json.NewEncoder(w).Encode(response)
}

View File

@@ -0,0 +1,32 @@
package handlers
import (
"html/template"
"net/http"
)
// NotFoundHandler handles 404 errors
type NotFoundHandler struct {
Templates *template.Template
RegistryURL string
}
func (h *NotFoundHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
RenderNotFound(w, r, h.Templates, h.RegistryURL)
}
// RenderNotFound renders the 404 page template.
// Use this from other handlers when a resource is not found.
func RenderNotFound(w http.ResponseWriter, r *http.Request, templates *template.Template, registryURL string) {
w.WriteHeader(http.StatusNotFound)
data := struct {
PageData
}{
PageData: NewPageData(r, registryURL),
}
if err := templates.ExecuteTemplate(w, "404", data); err != nil {
http.Error(w, "Page not found", http.StatusNotFound)
}
}

View File

@@ -3,9 +3,12 @@ package handlers
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/middleware"
@@ -30,16 +33,8 @@ func (h *DeleteTagHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
repo := chi.URLParam(r, "repository")
tag := chi.URLParam(r, "tag")
// Get OAuth session for the authenticated user
session, err := h.Refresher.GetSession(r.Context(), user.DID)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to get OAuth session: %v", err), http.StatusUnauthorized)
return
}
// Create ATProto client with OAuth credentials
apiClient := session.APIClient()
pdsClient := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient)
// Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety)
pdsClient := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
// Compute rkey for tag record (repository_tag with slashes replaced)
rkey := fmt.Sprintf("%s_%s", repo, tag)
@@ -47,6 +42,11 @@ func (h *DeleteTagHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Delete from PDS first
if err := pdsClient.DeleteRecord(r.Context(), atproto.TagCollection, rkey); err != nil {
// Check if OAuth error - if so, invalidate sessions and return 401
if handleOAuthError(r.Context(), h.Refresher, user.DID, err) {
http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized)
return
}
http.Error(w, fmt.Sprintf("Failed to delete tag from PDS: %v", err), http.StatusInternalServerError)
return
}
@@ -103,16 +103,8 @@ func (h *DeleteManifestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
return
}
// Get OAuth session for the authenticated user
session, err := h.Refresher.GetSession(r.Context(), user.DID)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to get OAuth session: %v", err), http.StatusUnauthorized)
return
}
// Create ATProto client with OAuth credentials
apiClient := session.APIClient()
pdsClient := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient)
// Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety)
pdsClient := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
// If tagged and confirmed, delete all tags first
if tagged && confirmed {
@@ -127,6 +119,11 @@ func (h *DeleteManifestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
// Delete from PDS
tagRKey := fmt.Sprintf("%s:%s", repo, tag)
if err := pdsClient.DeleteRecord(r.Context(), atproto.TagCollection, tagRKey); err != nil {
// Check if OAuth error - if so, invalidate sessions and return 401
if handleOAuthError(r.Context(), h.Refresher, user.DID, err) {
http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized)
return
}
http.Error(w, fmt.Sprintf("Failed to delete tag '%s' from PDS: %v", tag, err), http.StatusInternalServerError)
return
}
@@ -144,6 +141,11 @@ func (h *DeleteManifestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
// Delete from PDS first
if err := pdsClient.DeleteRecord(r.Context(), atproto.ManifestCollection, rkey); err != nil {
// Check if OAuth error - if so, invalidate sessions and return 401
if handleOAuthError(r.Context(), h.Refresher, user.DID, err) {
http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized)
return
}
http.Error(w, fmt.Sprintf("Failed to delete manifest from PDS: %v", err), http.StatusInternalServerError)
return
}
@@ -156,3 +158,114 @@ func (h *DeleteManifestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
w.WriteHeader(http.StatusOK)
}
// UploadAvatarHandler handles uploading/updating a repository avatar
type UploadAvatarHandler struct {
DB *sql.DB
Refresher *oauth.Refresher
}
// validImageTypes are the allowed MIME types for avatars (matches lexicon)
var validImageTypes = map[string]bool{
"image/png": true,
"image/jpeg": true,
"image/webp": true,
}
func (h *UploadAvatarHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
repo := chi.URLParam(r, "repository")
// Parse multipart form (max 3MB to match lexicon maxSize)
if err := r.ParseMultipartForm(3 << 20); err != nil {
http.Error(w, "File too large (max 3MB)", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("avatar")
if err != nil {
http.Error(w, "No file provided", http.StatusBadRequest)
return
}
defer file.Close()
// Validate MIME type
contentType := header.Header.Get("Content-Type")
if !validImageTypes[contentType] {
http.Error(w, "Invalid file type. Must be PNG, JPEG, or WebP", http.StatusBadRequest)
return
}
// Read file data
data, err := io.ReadAll(io.LimitReader(file, 3<<20+1)) // Read up to 3MB + 1 byte
if err != nil {
http.Error(w, "Failed to read file", http.StatusInternalServerError)
return
}
if len(data) > 3<<20 {
http.Error(w, "File too large (max 3MB)", http.StatusBadRequest)
return
}
// Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety)
pdsClient := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
// Upload blob to PDS
blobRef, err := pdsClient.UploadBlob(r.Context(), data, contentType)
if err != nil {
if handleOAuthError(r.Context(), h.Refresher, user.DID, err) {
http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized)
return
}
http.Error(w, fmt.Sprintf("Failed to upload image: %v", err), http.StatusInternalServerError)
return
}
// Fetch existing repo page record to preserve description
var existingDescription string
var existingCreatedAt time.Time
record, err := pdsClient.GetRecord(r.Context(), atproto.RepoPageCollection, repo)
if err == nil {
// Parse existing record to preserve description
var existingRecord atproto.RepoPageRecord
if jsonErr := json.Unmarshal(record.Value, &existingRecord); jsonErr == nil {
existingDescription = existingRecord.Description
existingCreatedAt = existingRecord.CreatedAt
}
} else if !errors.Is(err, atproto.ErrRecordNotFound) {
// Some other error - check if OAuth error
if handleOAuthError(r.Context(), h.Refresher, user.DID, err) {
http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized)
return
}
// Log but continue - we'll create a new record
}
// Create updated repo page record
repoPage := atproto.NewRepoPageRecord(repo, existingDescription, blobRef)
// Preserve original createdAt if record existed
if !existingCreatedAt.IsZero() {
repoPage.CreatedAt = existingCreatedAt
}
// Save record to PDS
_, err = pdsClient.PutRecord(r.Context(), atproto.RepoPageCollection, repo, repoPage)
if err != nil {
if handleOAuthError(r.Context(), h.Refresher, user.DID, err) {
http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized)
return
}
http.Error(w, fmt.Sprintf("Failed to update repository page: %v", err), http.StatusInternalServerError)
return
}
// Return new avatar URL
avatarURL := atproto.BlobCDNURL(user.DID, blobRef.Ref.Link)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"avatarURL": avatarURL})
}

View File

@@ -1,20 +1,16 @@
package handlers
import (
"log/slog"
"net/http"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/auth/oauth"
"github.com/bluesky-social/indigo/atproto/syntax"
)
// LogoutHandler handles user logout with proper OAuth token revocation
// LogoutHandler handles user logout from the web UI
// This only clears the current UI session cookie - it does NOT revoke OAuth tokens
// OAuth sessions remain intact so other browser tabs/devices stay logged in
type LogoutHandler struct {
OAuthApp *oauth.App
Refresher *oauth.Refresher
SessionStore *db.SessionStore
OAuthStore *db.OAuthStore
}
func (h *LogoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -26,39 +22,8 @@ func (h *LogoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// Get UI session to extract OAuth session ID and user info
uiSession, ok := h.SessionStore.Get(uiSessionID)
if ok && uiSession != nil && uiSession.DID != "" {
// Parse DID for OAuth logout
did, err := syntax.ParseDID(uiSession.DID)
if err != nil {
slog.Warn("Failed to parse DID for logout", "component", "logout", "did", uiSession.DID, "error", err)
} else {
// Attempt to revoke OAuth tokens on PDS side
if uiSession.OAuthSessionID != "" {
// Call indigo's Logout to revoke tokens on PDS
if err := h.OAuthApp.GetClientApp().Logout(r.Context(), did, uiSession.OAuthSessionID); err != nil {
// Log error but don't block logout - best effort revocation
slog.Warn("Failed to revoke OAuth tokens on PDS", "component", "logout", "did", uiSession.DID, "error", err)
} else {
slog.Info("Successfully revoked OAuth tokens on PDS", "component", "logout", "did", uiSession.DID)
}
// Invalidate refresher cache to clear local access tokens
h.Refresher.InvalidateSession(uiSession.DID)
slog.Info("Invalidated local OAuth cache", "component", "logout", "did", uiSession.DID)
// Delete OAuth session from database (cleanup, might already be done by Logout)
if err := h.OAuthStore.DeleteSession(r.Context(), did, uiSession.OAuthSessionID); err != nil {
slog.Warn("Failed to delete OAuth session from database", "component", "logout", "error", err)
}
} else {
slog.Warn("No OAuth session ID found for user", "component", "logout", "did", uiSession.DID)
}
}
}
// Always delete UI session and clear cookie, even if OAuth revocation failed
// Delete only this UI session and clear cookie
// OAuth session remains intact for other browser tabs/devices
h.SessionStore.Delete(uiSessionID)
db.ClearCookie(w)

View File

@@ -57,7 +57,6 @@ func TestLogoutHandler_WithSession(t *testing.T) {
handler := &LogoutHandler{
SessionStore: sessionStore,
OAuthStore: db.NewOAuthStore(database),
}
req := httptest.NewRequest("GET", "/auth/logout", nil)

View File

@@ -43,12 +43,14 @@ func (h *ManifestHealthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
reachable, err := h.HealthChecker.CheckHealth(ctx, endpoint)
if ctx.Err() == context.DeadlineExceeded {
// Still pending - render "Checking..." badge with HTMX retry
h.renderBadge(w, endpoint, false, true)
} else if err != nil {
// Check for HTTP errors first (connection refused, network unreachable, etc.)
// This ensures we catch real failures even when timing aligns with context timeout
if err != nil {
// Error - mark as unreachable
h.renderBadge(w, endpoint, false, false)
} else if ctx.Err() == context.DeadlineExceeded {
// Context timed out but no HTTP error yet - still pending
h.renderBadge(w, endpoint, false, true)
} else {
// Success
h.renderBadge(w, endpoint, reachable, false)
@@ -65,10 +67,10 @@ func (h *ManifestHealthHandler) renderBadge(w http.ResponseWriter, endpoint stri
w.Write([]byte(`<span class="checking-badge"
hx-get="` + retryURL + `"
hx-trigger="load delay:3s"
hx-swap="outerHTML">🔄 Checking...</span>`))
hx-swap="outerHTML"><i data-lucide="refresh-ccw"></i> Checking...</span>`))
} else if !reachable {
// Unreachable - render offline badge
w.Write([]byte(`<span class="offline-badge">⚠️ Offline</span>`))
w.Write([]byte(`<span class="offline-badge"><i data-lucide="triangle-alert"></i> Offline</span>`))
} else {
// Reachable - no badge (empty response)
w.Write([]byte(``))

View File

@@ -0,0 +1,49 @@
package handlers
import (
"context"
"log/slog"
"strings"
"atcr.io/pkg/auth/oauth"
)
// isOAuthError checks if an error indicates OAuth authentication failure
// These errors indicate the OAuth session is invalid and should be cleaned up
func isOAuthError(err error) bool {
if err == nil {
return false
}
errStr := strings.ToLower(err.Error())
return strings.Contains(errStr, "401") ||
strings.Contains(errStr, "403") ||
strings.Contains(errStr, "invalid_token") ||
strings.Contains(errStr, "invalid_grant") ||
strings.Contains(errStr, "use_dpop_nonce") ||
strings.Contains(errStr, "unauthorized") ||
strings.Contains(errStr, "token") && strings.Contains(errStr, "expired") ||
strings.Contains(errStr, "authentication failed")
}
// handleOAuthError checks if an error is OAuth-related and invalidates UI sessions if so
// Returns true if the error was an OAuth error (caller should return early)
func handleOAuthError(ctx context.Context, refresher *oauth.Refresher, did string, err error) bool {
if !isOAuthError(err) {
return false
}
slog.Warn("OAuth error detected, invalidating sessions",
"component", "handlers",
"did", did,
"error", err)
// Invalidate all UI sessions for this DID
if delErr := refresher.DeleteSession(ctx, did); delErr != nil {
slog.Warn("Failed to delete OAuth session after error",
"component", "handlers",
"did", did,
"error", delErr)
}
return true
}

View File

@@ -0,0 +1,230 @@
package handlers
import (
"database/sql"
"fmt"
"log/slog"
"net/http"
"strings"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/ogcard"
"atcr.io/pkg/atproto"
"github.com/go-chi/chi/v5"
)
// RepoOGHandler generates OpenGraph images for repository pages
type RepoOGHandler struct {
DB *sql.DB
}
func (h *RepoOGHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
handle := chi.URLParam(r, "handle")
repository := chi.URLParam(r, "repository")
// Resolve handle to DID
did, resolvedHandle, _, err := atproto.ResolveIdentity(r.Context(), handle)
if err != nil {
slog.Warn("Failed to resolve identity for OG image", "handle", handle, "error", err)
http.Error(w, "User not found", http.StatusNotFound)
return
}
// Get user info
user, err := db.GetUserByDID(h.DB, did)
if err != nil || user == nil {
slog.Warn("Failed to get user for OG image", "did", did, "error", err)
// Use resolved handle even if user not in DB
user = &db.User{DID: did, Handle: resolvedHandle}
}
// Get repository stats
stats, err := db.GetRepositoryStats(h.DB, did, repository)
if err != nil {
slog.Warn("Failed to get repo stats for OG image", "did", did, "repo", repository, "error", err)
stats = &db.RepositoryStats{}
}
// Get repository metadata (description, icon)
metadata, err := db.GetRepositoryMetadata(h.DB, did, repository)
if err != nil {
slog.Warn("Failed to get repo metadata for OG image", "did", did, "repo", repository, "error", err)
metadata = map[string]string{}
}
description := metadata["org.opencontainers.image.description"]
iconURL := metadata["io.atcr.icon"]
version := metadata["org.opencontainers.image.version"]
licenses := metadata["org.opencontainers.image.licenses"]
// Generate the OG image
card := ogcard.NewCard()
card.Fill(ogcard.ColorBackground)
layout := ogcard.StandardLayout()
// Draw icon/avatar on the left (prefer repo icon, then user avatar, then placeholder)
avatarURL := iconURL
if avatarURL == "" {
avatarURL = user.Avatar
}
card.DrawAvatarOrPlaceholder(avatarURL, layout.IconX, layout.IconY, ogcard.AvatarSize,
strings.ToUpper(string(repository[0])))
// Draw owner handle and repo name - wrap to new line if too long
ownerText := "@" + user.Handle + " / "
ownerWidth := card.MeasureText(ownerText, ogcard.FontTitle, false)
repoWidth := card.MeasureText(repository, ogcard.FontTitle, true)
combinedWidth := ownerWidth + repoWidth
textY := layout.TextY
if combinedWidth > layout.MaxWidth {
// Too long - put repo name on new line
card.DrawText("@"+user.Handle+" /", layout.TextX, textY, ogcard.FontTitle, ogcard.ColorMuted, ogcard.AlignLeft, false)
textY += ogcard.LineSpacingLarge
card.DrawText(repository, layout.TextX, textY, ogcard.FontTitle, ogcard.ColorText, ogcard.AlignLeft, true)
} else {
// Fits on one line
card.DrawText(ownerText, layout.TextX, textY, ogcard.FontTitle, ogcard.ColorMuted, ogcard.AlignLeft, false)
card.DrawText(repository, layout.TextX+float64(ownerWidth), textY, ogcard.FontTitle, ogcard.ColorText, ogcard.AlignLeft, true)
}
// Track current Y position for description
if description != "" {
textY += ogcard.LineSpacingSmall
card.DrawTextWrapped(description, layout.TextX, textY, ogcard.FontDescription, ogcard.ColorMuted, layout.MaxWidth, false)
}
// Badges row (version, license)
badgeY := layout.IconY + ogcard.AvatarSize + 30
badgeX := int(layout.TextX)
if version != "" {
width := card.DrawBadge(version, badgeX, badgeY, ogcard.FontBadge, ogcard.ColorBadgeAccent, ogcard.ColorText)
badgeX += width + ogcard.BadgeGap
}
if licenses != "" {
// Show first license if multiple
license := strings.Split(licenses, ",")[0]
license = strings.TrimSpace(license)
card.DrawBadge(license, badgeX, badgeY, ogcard.FontBadge, ogcard.ColorBadgeBg, ogcard.ColorText)
}
// Stats at bottom
statsX := card.DrawStatWithIcon("star", fmt.Sprintf("%d", stats.StarCount),
ogcard.Padding, layout.StatsY, ogcard.ColorStar, ogcard.ColorText)
card.DrawStatWithIcon("arrow-down-to-line", fmt.Sprintf("%d pulls", stats.PullCount),
statsX, layout.StatsY, ogcard.ColorMuted, ogcard.ColorMuted)
// ATCR branding (bottom right)
card.DrawBranding()
// Set cache headers and content type
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "public, max-age=3600")
if err := card.EncodePNG(w); err != nil {
slog.Error("Failed to encode OG image", "error", err)
http.Error(w, "Failed to generate image", http.StatusInternalServerError)
}
}
// DefaultOGHandler generates the default OpenGraph image for the home page
type DefaultOGHandler struct{}
func (h *DefaultOGHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Generate the OG image
card := ogcard.NewCard()
card.Fill(ogcard.ColorBackground)
// Draw large centered "ATCR" title
centerY := float64(ogcard.CardHeight) / 2
card.DrawText("ATCR", float64(ogcard.CardWidth)/2, centerY-20, 96.0, ogcard.ColorText, ogcard.AlignCenter, true)
// Draw tagline below
card.DrawText("Distributed Container Registry", float64(ogcard.CardWidth)/2, centerY+60, ogcard.FontDescription, ogcard.ColorMuted, ogcard.AlignCenter, false)
// Draw subtitle
card.DrawText("Push and pull Docker images on the AT Protocol", float64(ogcard.CardWidth)/2, centerY+110, ogcard.FontStats, ogcard.ColorMuted, ogcard.AlignCenter, false)
// Set cache headers and content type (cache longer since it's static content)
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "public, max-age=86400")
if err := card.EncodePNG(w); err != nil {
slog.Error("Failed to encode default OG image", "error", err)
http.Error(w, "Failed to generate image", http.StatusInternalServerError)
}
}
// UserOGHandler generates OpenGraph images for user profile pages
type UserOGHandler struct {
DB *sql.DB
}
func (h *UserOGHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
handle := chi.URLParam(r, "handle")
// Resolve handle to DID
did, resolvedHandle, _, err := atproto.ResolveIdentity(r.Context(), handle)
if err != nil {
slog.Warn("Failed to resolve identity for OG image", "handle", handle, "error", err)
http.Error(w, "User not found", http.StatusNotFound)
return
}
// Get user info
user, err := db.GetUserByDID(h.DB, did)
if err != nil || user == nil {
// Use resolved handle even if user not in DB
user = &db.User{DID: did, Handle: resolvedHandle}
}
// Get repository count
repos, err := db.GetUserRepositories(h.DB, did)
repoCount := 0
if err == nil {
repoCount = len(repos)
}
// Generate the OG image
card := ogcard.NewCard()
card.Fill(ogcard.ColorBackground)
layout := ogcard.StandardLayout()
// Draw avatar on the left
firstChar := "?"
if len(user.Handle) > 0 {
firstChar = strings.ToUpper(string(user.Handle[0]))
}
card.DrawAvatarOrPlaceholder(user.Avatar, layout.IconX, layout.IconY, ogcard.AvatarSize, firstChar)
// Draw handle
handleText := "@" + user.Handle
card.DrawText(handleText, layout.TextX, layout.TextY, ogcard.FontTitle, ogcard.ColorText, ogcard.AlignLeft, true)
// Repository count below (using description font size)
textY := layout.TextY + ogcard.LineSpacingLarge
repoText := fmt.Sprintf("%d repositories", repoCount)
if repoCount == 1 {
repoText = "1 repository"
}
// Draw package icon with description-sized text
if err := card.DrawIcon("package", int(layout.TextX), int(textY)-int(ogcard.FontDescription), int(ogcard.FontDescription), ogcard.ColorMuted); err != nil {
slog.Warn("Failed to draw package icon", "error", err)
}
card.DrawText(repoText, layout.TextX+42, textY, ogcard.FontDescription, ogcard.ColorMuted, ogcard.AlignLeft, false)
// ATCR branding (bottom right)
card.DrawBranding()
// Set cache headers and content type
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "public, max-age=3600")
if err := card.EncodePNG(w); err != nil {
slog.Error("Failed to encode OG image", "error", err)
http.Error(w, "Failed to generate image", http.StatusInternalServerError)
}
}

View File

@@ -3,14 +3,9 @@ package handlers
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"html/template"
"io"
"log/slog"
"net/http"
"net/url"
"strconv"
"sync"
"time"
@@ -32,195 +27,37 @@ type RepositoryPageHandler struct {
Directory identity.Directory
Refresher *oauth.Refresher
HealthChecker *holdhealth.Checker
ReadmeCache *readme.Cache
}
// queryVulnerabilities queries the hold service for vulnerability scan results
func (h *RepositoryPageHandler) queryVulnerabilities(ctx context.Context, holdEndpoint string, digest string) (*db.VulnerabilitySummary, error) {
// Skip if no hold endpoint
if holdEndpoint == "" {
return nil, nil
}
// Query referrers endpoint for vulnerability scan results
// Match the artifactType used by the scanner in pkg/hold/scanner/storage.go
artifactType := "application/vnd.atcr.vulnerabilities+json"
// Properly encode query parameters (especially the + in the media type)
queryParams := url.Values{}
queryParams.Set("digest", digest)
queryParams.Set("artifactType", artifactType)
requestURL := fmt.Sprintf("%s/xrpc/io.atcr.hold.getReferrers?%s", holdEndpoint, queryParams.Encode())
req, err := http.NewRequestWithContext(ctx, "GET", requestURL, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
// No scan results found
return nil, nil
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("failed to query referrers: %s - %s", resp.Status, string(body))
}
// Parse response
var result struct {
Referrers []struct {
Annotations map[string]string `json:"annotations"`
} `json:"referrers"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode referrers response: %w", err)
}
slog.Debug("Received referrers response",
"digest", digest,
"referrerCount", len(result.Referrers))
// Find the most recent vulnerability scan result
if len(result.Referrers) == 0 {
return nil, nil
}
// Parse vulnerability counts from annotations
// Match the annotation keys used by the scanner in pkg/hold/scanner/storage.go
annotations := result.Referrers[0].Annotations
slog.Debug("First referrer annotations",
"digest", digest,
"annotations", annotations,
"annotationsLen", len(annotations))
summary := &db.VulnerabilitySummary{}
if critical, ok := annotations["io.atcr.vuln.critical"]; ok {
summary.Critical, _ = strconv.Atoi(critical)
}
if high, ok := annotations["io.atcr.vuln.high"]; ok {
summary.High, _ = strconv.Atoi(high)
}
if medium, ok := annotations["io.atcr.vuln.medium"]; ok {
summary.Medium, _ = strconv.Atoi(medium)
}
if low, ok := annotations["io.atcr.vuln.low"]; ok {
summary.Low, _ = strconv.Atoi(low)
}
if total, ok := annotations["io.atcr.vuln.total"]; ok {
summary.Total, _ = strconv.Atoi(total)
}
// If Total is missing or 0, calculate from individual counts
if summary.Total == 0 {
summary.Total = summary.Critical + summary.High + summary.Medium + summary.Low
}
slog.Debug("Parsed vulnerability summary",
"digest", digest,
"critical", summary.Critical,
"high", summary.High,
"medium", summary.Medium,
"low", summary.Low,
"total", summary.Total)
return summary, nil
}
// HandleVulnerabilityDetails returns the full vulnerability report for a manifest
func (h *RepositoryPageHandler) HandleVulnerabilityDetails(w http.ResponseWriter, r *http.Request) {
digest := r.URL.Query().Get("digest")
holdEndpoint := r.URL.Query().Get("holdEndpoint")
if digest == "" || holdEndpoint == "" {
http.Error(w, "digest and holdEndpoint required", http.StatusBadRequest)
return
}
// Query referrers to get the vulnerability report digest
artifactType := "application/vnd.atcr.vulnerabilities+json"
queryParams := url.Values{}
queryParams.Set("digest", digest)
queryParams.Set("artifactType", artifactType)
requestURL := fmt.Sprintf("%s/xrpc/io.atcr.hold.getReferrers?%s", holdEndpoint, queryParams.Encode())
req, err := http.NewRequestWithContext(r.Context(), "GET", requestURL, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
http.Error(w, "No vulnerability scan found", http.StatusNotFound)
return
}
if resp.StatusCode != http.StatusOK {
http.Error(w, "Failed to query referrers", resp.StatusCode)
return
}
// Parse response - now includes the vulnerability report data directly
var result struct {
Referrers []struct {
Digest string `json:"digest"`
Annotations map[string]string `json:"annotations"`
ReportData map[string]interface{} `json:"reportData"` // The actual vulnerability report
} `json:"referrers"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
http.Error(w, "Failed to decode referrers response", http.StatusInternalServerError)
return
}
if len(result.Referrers) == 0 {
http.Error(w, "No vulnerability scan found", http.StatusNotFound)
return
}
// Check if reportData is included
if result.Referrers[0].ReportData == nil {
http.Error(w, "Vulnerability report data not available", http.StatusNotFound)
return
}
// Return the vulnerability report JSON directly
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result.Referrers[0].ReportData)
ReadmeFetcher *readme.Fetcher // For rendering repo page descriptions
}
func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
handle := chi.URLParam(r, "handle")
identifier := chi.URLParam(r, "handle")
repository := chi.URLParam(r, "repository")
// Look up user by handle
owner, err := db.GetUserByHandle(h.DB, handle)
// Resolve identifier (handle or DID) to canonical DID and current handle
did, resolvedHandle, _, err := atproto.ResolveIdentity(r.Context(), identifier)
if err != nil {
RenderNotFound(w, r, h.Templates, h.RegistryURL)
return
}
// Look up user by DID
owner, err := db.GetUserByDID(h.DB, did)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if owner == nil {
http.Error(w, "User not found", http.StatusNotFound)
RenderNotFound(w, r, h.Templates, h.RegistryURL)
return
}
// Opportunistically update cached handle if it changed
if owner.Handle != resolvedHandle {
_ = db.UpdateUserHandle(h.DB, did, resolvedHandle)
owner.Handle = resolvedHandle
}
// Fetch tags with platform information
tagsWithPlatforms, err := db.GetTagsWithPlatforms(h.DB, owner.DID, repository)
if err != nil {
@@ -235,44 +72,6 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
return
}
// Query vulnerability scan results for each manifest (concurrent with 2s timeout)
{
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
var wg sync.WaitGroup
var mu sync.Mutex
for i := range manifests {
// Skip manifest lists - only query for image manifests
if manifests[i].IsManifestList {
continue
}
wg.Add(1)
go func(idx int) {
defer wg.Done()
vulnerabilities, err := h.queryVulnerabilities(ctx, manifests[idx].Manifest.HoldEndpoint, manifests[idx].Manifest.Digest)
if err != nil {
slog.Warn("Failed to query vulnerabilities",
"digest", manifests[idx].Manifest.Digest,
"error", err)
return
}
mu.Lock()
if vulnerabilities != nil && vulnerabilities.Total > 0 {
manifests[idx].Vulnerabilities = vulnerabilities
manifests[idx].HasVulnerabilities = true
}
mu.Unlock()
}(i)
}
wg.Wait()
}
// Check health status for each manifest's hold endpoint (concurrent with 1s timeout)
if h.HealthChecker != nil {
// Create context with 1 second deadline for fast-fail
@@ -337,7 +136,7 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
}
if len(tagsWithPlatforms) == 0 && len(manifests) == 0 {
http.Error(w, "Repository not found", http.StatusNotFound)
RenderNotFound(w, r, h.Templates, h.RegistryURL)
return
}
@@ -376,18 +175,13 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
isStarred := false
user := middleware.GetUser(r)
if user != nil && h.Refresher != nil && h.Directory != nil {
// Get OAuth session for the authenticated user
session, err := h.Refresher.GetSession(r.Context(), user.DID)
if err == nil {
// Get user's PDS client
apiClient := session.APIClient()
pdsClient := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient)
// Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety)
pdsClient := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
// Check if star record exists
rkey := atproto.StarRecordKey(owner.DID, repository)
_, err = pdsClient.GetRecord(r.Context(), atproto.StarCollection, rkey)
isStarred = (err == nil)
}
// Check if star record exists
rkey := atproto.StarRecordKey(owner.DID, repository)
_, err := pdsClient.GetRecord(r.Context(), atproto.StarCollection, rkey)
isStarred = (err == nil)
}
// Check if current user is the repository owner
@@ -396,19 +190,44 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
isOwner = (user.DID == owner.DID)
}
// Fetch README content if available
// Fetch README content from repo page record or annotations
var readmeHTML template.HTML
if repo.ReadmeURL != "" && h.ReadmeCache != nil {
// Fetch with timeout
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
html, err := h.ReadmeCache.Get(ctx, repo.ReadmeURL)
if err != nil {
slog.Warn("Failed to fetch README", "url", repo.ReadmeURL, "error", err)
// Continue without README on error
} else {
readmeHTML = template.HTML(html)
// Try repo page record from database (synced from PDS via Jetstream)
repoPage, err := db.GetRepoPage(h.DB, owner.DID, repository)
if err == nil && repoPage != nil {
// Use repo page avatar if present
if repoPage.AvatarCID != "" {
repo.IconURL = atproto.BlobCDNURL(owner.DID, repoPage.AvatarCID)
}
// Render description as markdown if present
if repoPage.Description != "" && h.ReadmeFetcher != nil {
html, err := h.ReadmeFetcher.RenderMarkdown([]byte(repoPage.Description))
if err != nil {
slog.Warn("Failed to render repo page description", "error", err)
} else {
readmeHTML = template.HTML(html)
}
}
}
// Fall back to fetching README from URL annotations if no description in repo page
if readmeHTML == "" && h.ReadmeFetcher != nil {
// Fall back to fetching from URL annotations
readmeURL := repo.ReadmeURL
if readmeURL == "" && repo.SourceURL != "" {
// Try to derive README URL from source URL
readmeURL = readme.DeriveReadmeURL(repo.SourceURL, "main")
if readmeURL == "" {
readmeURL = readme.DeriveReadmeURL(repo.SourceURL, "master")
}
}
if readmeURL != "" {
html, err := h.ReadmeFetcher.FetchAndRender(r.Context(), readmeURL)
if err != nil {
slog.Debug("Failed to fetch README from URL", "url", readmeURL, "error", err)
} else {
readmeHTML = template.HTML(html)
}
}
}

View File

@@ -26,20 +26,8 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// Get OAuth session for the user
session, err := h.Refresher.GetSession(r.Context(), user.DID)
if err != nil {
// OAuth session not found or expired - redirect to re-authenticate
slog.Warn("OAuth session not found, redirecting to login", "component", "settings", "did", user.DID, "error", err)
http.Redirect(w, r, "/auth/oauth/login?return_to=/settings", http.StatusFound)
return
}
// Use indigo's API client directly - it handles all auth automatically
apiClient := session.APIClient()
// Create ATProto client with indigo's XRPC client
client := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient)
// Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety)
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
// Fetch sailor profile
profile, err := storage.GetProfile(r.Context(), client)
@@ -96,20 +84,8 @@ func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
holdEndpoint := r.FormValue("hold_endpoint")
// Get OAuth session for the user
session, err := h.Refresher.GetSession(r.Context(), user.DID)
if err != nil {
// OAuth session not found or expired - redirect to re-authenticate
slog.Warn("OAuth session not found, redirecting to login", "component", "settings", "did", user.DID, "error", err)
http.Redirect(w, r, "/auth/oauth/login?return_to=/settings", http.StatusFound)
return
}
// Use indigo's API client directly - it handles all auth automatically
apiClient := session.APIClient()
// Create ATProto client with indigo's XRPC client
client := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient)
// Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety)
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
// Fetch existing profile or create new one
profile, err := storage.GetProfile(r.Context(), client)

View File

@@ -6,6 +6,7 @@ import (
"net/http"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/atproto"
"github.com/go-chi/chi/v5"
)
@@ -17,18 +18,36 @@ type UserPageHandler struct {
}
func (h *UserPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
handle := chi.URLParam(r, "handle")
identifier := chi.URLParam(r, "handle")
// Look up user by handle
viewedUser, err := db.GetUserByHandle(h.DB, handle)
// Resolve identifier (handle or DID) to canonical DID and current handle
did, resolvedHandle, pdsEndpoint, err := atproto.ResolveIdentity(r.Context(), identifier)
if err != nil {
RenderNotFound(w, r, h.Templates, h.RegistryURL)
return
}
// Look up user by DID
viewedUser, err := db.GetUserByDID(h.DB, did)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
hasProfile := true
if viewedUser == nil {
http.Error(w, "User not found", http.StatusNotFound)
return
// Valid ATProto user but hasn't set up ATCR profile
hasProfile = false
viewedUser = &db.User{
DID: did,
Handle: resolvedHandle,
PDSEndpoint: pdsEndpoint,
// Avatar intentionally empty - template shows '?' placeholder
}
} else if viewedUser.Handle != resolvedHandle {
// Opportunistically update cached handle if it changed
_ = db.UpdateUserHandle(h.DB, did, resolvedHandle)
viewedUser.Handle = resolvedHandle
}
// Fetch repositories for this user
@@ -64,10 +83,12 @@ func (h *UserPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
PageData
ViewedUser *db.User // User whose page we're viewing
Repositories []db.RepoCardData
HasProfile bool
}{
PageData: NewPageData(r, h.RegistryURL),
ViewedUser: viewedUser,
Repositories: cards,
HasProfile: hasProfile,
}
if err := h.Templates.ExecuteTemplate(w, "user", data); err != nil {

View File

@@ -5,21 +5,26 @@ import (
"database/sql"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/readme"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth/oauth"
)
// BackfillWorker uses com.atproto.sync.listReposByCollection to backfill historical data
type BackfillWorker struct {
db *sql.DB
client *atproto.Client
processor *Processor // Shared processor for DB operations
defaultHoldDID string // Default hold DID from AppView config (e.g., "did:web:hold01.atcr.io")
testMode bool // If true, suppress warnings for external holds
processor *Processor // Shared processor for DB operations
defaultHoldDID string // Default hold DID from AppView config (e.g., "did:web:hold01.atcr.io")
testMode bool // If true, suppress warnings for external holds
refresher *oauth.Refresher // OAuth refresher for PDS writes (optional, can be nil)
}
// BackfillState tracks backfill progress
@@ -36,7 +41,8 @@ type BackfillState struct {
// NewBackfillWorker creates a backfill worker using sync API
// defaultHoldDID should be in format "did:web:hold01.atcr.io"
// To find a hold's DID, visit: https://hold-url/.well-known/did.json
func NewBackfillWorker(database *sql.DB, relayEndpoint, defaultHoldDID string, testMode bool) (*BackfillWorker, error) {
// refresher is optional - if provided, backfill will try to update PDS records when fetching README content
func NewBackfillWorker(database *sql.DB, relayEndpoint, defaultHoldDID string, testMode bool, refresher *oauth.Refresher) (*BackfillWorker, error) {
// Create client for relay - used only for listReposByCollection
client := atproto.NewClient(relayEndpoint, "", "")
@@ -46,6 +52,7 @@ func NewBackfillWorker(database *sql.DB, relayEndpoint, defaultHoldDID string, t
processor: NewProcessor(database, false), // No cache for batch processing
defaultHoldDID: defaultHoldDID,
testMode: testMode,
refresher: refresher,
}, nil
}
@@ -67,6 +74,7 @@ func (b *BackfillWorker) Start(ctx context.Context) error {
atproto.TagCollection, // io.atcr.tag
atproto.StarCollection, // io.atcr.sailor.star
atproto.SailorProfileCollection, // io.atcr.sailor.profile
atproto.RepoPageCollection, // io.atcr.repo.page
}
for _, collection := range collections {
@@ -217,6 +225,13 @@ func (b *BackfillWorker) backfillRepo(ctx context.Context, did, collection strin
}
}
// After processing repo pages, fetch descriptions from external sources if empty
if collection == atproto.RepoPageCollection {
if err := b.reconcileRepoPageDescriptions(ctx, did, pdsEndpoint); err != nil {
slog.Warn("Backfill failed to reconcile repo page descriptions", "did", did, "error", err)
}
}
return recordCount, nil
}
@@ -282,6 +297,9 @@ func (b *BackfillWorker) processRecord(ctx context.Context, did, collection stri
return b.processor.ProcessStar(context.Background(), did, record.Value)
case atproto.SailorProfileCollection:
return b.processor.ProcessSailorProfile(ctx, did, record.Value, b.queryCaptainRecordWrapper)
case atproto.RepoPageCollection:
// rkey is extracted from the record URI, but for repo pages we use Repository field
return b.processor.ProcessRepoPage(ctx, did, record.URI, record.Value, false)
default:
return fmt.Errorf("unsupported collection: %s", collection)
}
@@ -413,3 +431,186 @@ func (b *BackfillWorker) reconcileAnnotations(ctx context.Context, did string, p
return nil
}
// reconcileRepoPageDescriptions fetches README content from external sources for repo pages with empty descriptions
// If the user has an OAuth session, it updates the PDS record (source of truth)
// Otherwise, it just stores the fetched content in the database
func (b *BackfillWorker) reconcileRepoPageDescriptions(ctx context.Context, did, pdsEndpoint string) error {
// Get all repo pages for this DID
repoPages, err := db.GetRepoPagesByDID(b.db, did)
if err != nil {
return fmt.Errorf("failed to get repo pages: %w", err)
}
for _, page := range repoPages {
// Skip pages that already have a description
if page.Description != "" {
continue
}
// Get annotations from the repository's manifest
annotations, err := db.GetRepositoryAnnotations(b.db, did, page.Repository)
if err != nil {
slog.Debug("Failed to get annotations for repo page", "did", did, "repository", page.Repository, "error", err)
continue
}
// Try to fetch README content from external sources
description := b.fetchReadmeContent(ctx, annotations)
if description == "" {
// No README content available, skip
continue
}
slog.Info("Fetched README for repo page", "did", did, "repository", page.Repository, "descriptionLength", len(description))
// Try to update PDS if we have OAuth session
pdsUpdated := false
if b.refresher != nil {
if err := b.updateRepoPageInPDS(ctx, did, pdsEndpoint, page.Repository, description, page.AvatarCID); err != nil {
slog.Debug("Could not update repo page in PDS, falling back to DB-only", "did", did, "repository", page.Repository, "error", err)
} else {
pdsUpdated = true
slog.Info("Updated repo page in PDS with fetched description", "did", did, "repository", page.Repository)
}
}
// Always update database with the fetched content
if err := db.UpsertRepoPage(b.db, did, page.Repository, description, page.AvatarCID, page.CreatedAt, time.Now()); err != nil {
slog.Warn("Failed to update repo page in database", "did", did, "repository", page.Repository, "error", err)
} else if !pdsUpdated {
slog.Info("Updated repo page in database (PDS not updated)", "did", did, "repository", page.Repository)
}
}
return nil
}
// fetchReadmeContent attempts to fetch README content from external sources based on annotations
// Priority: io.atcr.readme annotation > derived from org.opencontainers.image.source
func (b *BackfillWorker) fetchReadmeContent(ctx context.Context, annotations map[string]string) string {
// Create a context with timeout for README fetching
fetchCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
// Priority 1: Direct README URL from io.atcr.readme annotation
if readmeURL := annotations["io.atcr.readme"]; readmeURL != "" {
content, err := b.fetchRawReadme(fetchCtx, readmeURL)
if err != nil {
slog.Debug("Failed to fetch README from io.atcr.readme annotation", "url", readmeURL, "error", err)
} else if content != "" {
return content
}
}
// Priority 2: Derive README URL from org.opencontainers.image.source
if sourceURL := annotations["org.opencontainers.image.source"]; sourceURL != "" {
// Try main branch first, then master
for _, branch := range []string{"main", "master"} {
readmeURL := readme.DeriveReadmeURL(sourceURL, branch)
if readmeURL == "" {
continue
}
content, err := b.fetchRawReadme(fetchCtx, readmeURL)
if err != nil {
// Only log non-404 errors (404 is expected when trying main vs master)
if !readme.Is404(err) {
slog.Debug("Failed to fetch README from source URL", "url", readmeURL, "branch", branch, "error", err)
}
continue
}
if content != "" {
return content
}
}
}
return ""
}
// fetchRawReadme fetches raw markdown content from a URL
func (b *BackfillWorker) fetchRawReadme(ctx context.Context, readmeURL string) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", readmeURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "ATCR-Backfill-README-Fetcher/1.0")
client := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("too many redirects")
}
return nil
},
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("status %d", resp.StatusCode)
}
// Limit content size to 100KB
limitedReader := io.LimitReader(resp.Body, 100*1024)
content, err := io.ReadAll(limitedReader)
if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err)
}
return string(content), nil
}
// updateRepoPageInPDS updates the repo page record in the user's PDS using OAuth
func (b *BackfillWorker) updateRepoPageInPDS(ctx context.Context, did, pdsEndpoint, repository, description, avatarCID string) error {
if b.refresher == nil {
return fmt.Errorf("no OAuth refresher available")
}
// Create ATProto client with session provider
pdsClient := atproto.NewClientWithSessionProvider(pdsEndpoint, did, b.refresher)
// Get existing repo page record to preserve other fields
existingRecord, err := pdsClient.GetRecord(ctx, atproto.RepoPageCollection, repository)
var createdAt time.Time
var avatarRef *atproto.ATProtoBlobRef
if err == nil && existingRecord != nil {
// Parse existing record
var existingPage atproto.RepoPageRecord
if err := json.Unmarshal(existingRecord.Value, &existingPage); err == nil {
createdAt = existingPage.CreatedAt
avatarRef = existingPage.Avatar
}
}
if createdAt.IsZero() {
createdAt = time.Now()
}
// Create updated repo page record
repoPage := &atproto.RepoPageRecord{
Type: atproto.RepoPageCollection,
Repository: repository,
Description: description,
Avatar: avatarRef,
CreatedAt: createdAt,
UpdatedAt: time.Now(),
}
// Write to PDS - this will use DoWithSession internally
_, err = pdsClient.PutRecord(ctx, atproto.RepoPageCollection, repository, repoPage)
if err != nil {
return fmt.Errorf("failed to write to PDS: %w", err)
}
return nil
}

View File

@@ -107,11 +107,13 @@ func (p *Processor) ProcessManifest(ctx context.Context, did string, recordData
// Detect manifest type
isManifestList := len(manifestRecord.Manifests) > 0
// Parse CreatedAt timestamp
createdAt, err := time.Parse(time.RFC3339, manifestRecord.CreatedAt)
if err != nil {
// If parsing fails, use current time
createdAt = time.Now()
// Extract hold DID from manifest (with fallback for legacy manifests)
// New manifests use holdDid field (DID format)
// Old manifests use holdEndpoint field (URL format) - convert to DID
holdDID := manifestRecord.HoldDID
if holdDID == "" && manifestRecord.HoldEndpoint != "" {
// Legacy manifest - convert URL to DID
holdDID = atproto.ResolveHoldDIDFromURL(manifestRecord.HoldEndpoint)
}
// Prepare manifest for insertion (WITHOUT annotation fields)
@@ -120,9 +122,9 @@ func (p *Processor) ProcessManifest(ctx context.Context, did string, recordData
Repository: manifestRecord.Repository,
Digest: manifestRecord.Digest,
MediaType: manifestRecord.MediaType,
SchemaVersion: int(manifestRecord.SchemaVersion),
HoldEndpoint: manifestRecord.HoldEndpoint,
CreatedAt: createdAt,
SchemaVersion: manifestRecord.SchemaVersion,
HoldEndpoint: holdDID,
CreatedAt: manifestRecord.CreatedAt,
// Annotations removed - stored separately in repository_annotations table
}
@@ -187,6 +189,14 @@ func (p *Processor) ProcessManifest(ctx context.Context, did string, recordData
platformOSVersion = ref.Platform.OSVersion
}
// Detect attestation manifests from annotations
isAttestation := false
if ref.Annotations != nil {
if refType, ok := ref.Annotations["vnd.docker.reference.type"]; ok {
isAttestation = refType == "attestation-manifest"
}
}
if err := db.InsertManifestReference(p.db, &db.ManifestReference{
ManifestID: manifestID,
Digest: ref.Digest,
@@ -196,6 +206,7 @@ func (p *Processor) ProcessManifest(ctx context.Context, did string, recordData
PlatformOS: platformOS,
PlatformVariant: platformVariant,
PlatformOSVersion: platformOSVersion,
IsAttestation: isAttestation,
ReferenceIndex: i,
}); err != nil {
// Continue on error - reference might already exist
@@ -288,6 +299,30 @@ func (p *Processor) ProcessSailorProfile(ctx context.Context, did string, record
return nil
}
// ProcessRepoPage processes a repository page record
// This is called when Jetstream receives a repo page create/update event
func (p *Processor) ProcessRepoPage(ctx context.Context, did string, rkey string, recordData []byte, isDelete bool) error {
if isDelete {
// Delete the repo page from our cache
return db.DeleteRepoPage(p.db, did, rkey)
}
// Unmarshal repo page record
var pageRecord atproto.RepoPageRecord
if err := json.Unmarshal(recordData, &pageRecord); err != nil {
return fmt.Errorf("failed to unmarshal repo page: %w", err)
}
// Extract avatar CID if present
avatarCID := ""
if pageRecord.Avatar != nil && pageRecord.Avatar.Ref.Link != "" {
avatarCID = pageRecord.Avatar.Ref.Link
}
// Upsert to database
return db.UpsertRepoPage(p.db, did, pageRecord.Repository, pageRecord.Description, avatarCID, pageRecord.CreatedAt, pageRecord.UpdatedAt)
}
// ProcessIdentity handles identity change events (handle updates)
// This is called when Jetstream receives an identity event indicating a handle change.
// The identity cache is invalidated to ensure the next lookup uses the new handle,

View File

@@ -70,6 +70,7 @@ func setupTestDB(t *testing.T) *sql.DB {
platform_os TEXT,
platform_variant TEXT,
platform_os_version TEXT,
is_attestation BOOLEAN DEFAULT FALSE,
reference_index INTEGER NOT NULL,
PRIMARY KEY(manifest_id, reference_index)
);
@@ -148,7 +149,7 @@ func TestProcessManifest_ImageManifest(t *testing.T) {
MediaType: "application/vnd.oci.image.manifest.v1+json",
SchemaVersion: 2,
HoldEndpoint: "did:web:hold01.atcr.io",
CreatedAt: time.Now().String(),
CreatedAt: time.Now(),
Config: &atproto.BlobReference{
Digest: "sha256:config123",
Size: 1234,
@@ -247,7 +248,7 @@ func TestProcessManifest_ManifestList(t *testing.T) {
MediaType: "application/vnd.oci.image.index.v1+json",
SchemaVersion: 2,
HoldEndpoint: "did:web:hold01.atcr.io",
CreatedAt: time.Now().String(),
CreatedAt: time.Now(),
Manifests: []atproto.ManifestReference{
{
Digest: "sha256:amd64manifest",
@@ -471,7 +472,7 @@ func TestProcessManifest_Duplicate(t *testing.T) {
MediaType: "application/vnd.oci.image.manifest.v1+json",
SchemaVersion: 2,
HoldEndpoint: "did:web:hold01.atcr.io",
CreatedAt: time.Now().String(),
CreatedAt: time.Now(),
}
// Marshal to bytes for ProcessManifest
@@ -523,7 +524,7 @@ func TestProcessManifest_EmptyAnnotations(t *testing.T) {
MediaType: "application/vnd.oci.image.manifest.v1+json",
SchemaVersion: 2,
HoldEndpoint: "did:web:hold01.atcr.io",
CreatedAt: time.Now().String(),
CreatedAt: time.Now(),
Annotations: nil,
}

View File

@@ -61,9 +61,7 @@ func NewWorker(database *sql.DB, jetstreamURL string, startCursor int64) *Worker
jetstreamURL: jetstreamURL,
startCursor: startCursor,
wantedCollections: []string{
atproto.ManifestCollection, // io.atcr.manifest
atproto.TagCollection, // io.atcr.tag
atproto.StarCollection, // io.atcr.sailor.star
"io.atcr.*", // Subscribe to all ATCR collections
},
processor: NewProcessor(database, true), // Use cache for live streaming
}
@@ -312,6 +310,9 @@ func (w *Worker) processMessage(message []byte) error {
case atproto.StarCollection:
slog.Info("Jetstream processing star event", "did", commit.DID, "operation", commit.Operation, "rkey", commit.RKey)
return w.processStar(commit)
case atproto.RepoPageCollection:
slog.Info("Jetstream processing repo page event", "did", commit.DID, "operation", commit.Operation, "rkey", commit.RKey)
return w.processRepoPage(commit)
default:
// Ignore other collections
return nil
@@ -436,6 +437,41 @@ func (w *Worker) processStar(commit *CommitEvent) error {
return w.processor.ProcessStar(context.Background(), commit.DID, recordBytes)
}
// processRepoPage processes a repo page commit event
func (w *Worker) processRepoPage(commit *CommitEvent) error {
// Resolve and upsert user with handle/PDS endpoint
if err := w.processor.EnsureUser(context.Background(), commit.DID); err != nil {
return fmt.Errorf("failed to ensure user: %w", err)
}
isDelete := commit.Operation == "delete"
if isDelete {
// Delete - rkey is the repository name
slog.Info("Jetstream deleting repo page", "did", commit.DID, "repository", commit.RKey)
if err := w.processor.ProcessRepoPage(context.Background(), commit.DID, commit.RKey, nil, true); err != nil {
slog.Error("Jetstream ERROR deleting repo page", "error", err)
return err
}
slog.Info("Jetstream successfully deleted repo page", "did", commit.DID, "repository", commit.RKey)
return nil
}
// Parse repo page record
if commit.Record == nil {
return nil
}
// Marshal map to bytes for processing
recordBytes, err := json.Marshal(commit.Record)
if err != nil {
return fmt.Errorf("failed to marshal record: %w", err)
}
// Use shared processor for DB operations
return w.processor.ProcessRepoPage(context.Background(), commit.DID, commit.RKey, recordBytes, false)
}
// processIdentity processes an identity event (handle change)
func (w *Worker) processIdentity(event *JetstreamEvent) error {
if event.Identity == nil {

View File

@@ -11,14 +11,32 @@ import (
"net/url"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/auth"
"atcr.io/pkg/auth/oauth"
)
type contextKey string
const userKey contextKey = "user"
// WebAuthDeps contains dependencies for web auth middleware
type WebAuthDeps struct {
SessionStore *db.SessionStore
Database *sql.DB
Refresher *oauth.Refresher
DefaultHoldDID string
}
// RequireAuth is middleware that requires authentication
func RequireAuth(store *db.SessionStore, database *sql.DB) func(http.Handler) http.Handler {
return RequireAuthWithDeps(WebAuthDeps{
SessionStore: store,
Database: database,
})
}
// RequireAuthWithDeps is middleware that requires authentication and creates UserContext
func RequireAuthWithDeps(deps WebAuthDeps) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sessionID, ok := getSessionID(r)
@@ -32,7 +50,7 @@ func RequireAuth(store *db.SessionStore, database *sql.DB) func(http.Handler) ht
return
}
sess, ok := store.Get(sessionID)
sess, ok := deps.SessionStore.Get(sessionID)
if !ok {
// Build return URL with query parameters preserved
returnTo := r.URL.Path
@@ -44,7 +62,7 @@ func RequireAuth(store *db.SessionStore, database *sql.DB) func(http.Handler) ht
}
// Look up full user from database to get avatar
user, err := db.GetUserByDID(database, sess.DID)
user, err := db.GetUserByDID(deps.Database, sess.DID)
if err != nil || user == nil {
// Fallback to session data if DB lookup fails
user = &db.User{
@@ -54,7 +72,20 @@ func RequireAuth(store *db.SessionStore, database *sql.DB) func(http.Handler) ht
}
}
ctx := context.WithValue(r.Context(), userKey, user)
ctx := r.Context()
ctx = context.WithValue(ctx, userKey, user)
// Create UserContext for authenticated users (enables EnsureUserSetup)
if deps.Refresher != nil {
userCtx := auth.NewUserContext(sess.DID, auth.AuthMethodOAuth, r.Method, &auth.Dependencies{
Refresher: deps.Refresher,
DefaultHoldDID: deps.DefaultHoldDID,
})
userCtx.SetPDS(sess.Handle, sess.PDSEndpoint)
userCtx.EnsureUserSetup()
ctx = auth.WithUserContext(ctx, userCtx)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
@@ -62,13 +93,21 @@ func RequireAuth(store *db.SessionStore, database *sql.DB) func(http.Handler) ht
// OptionalAuth is middleware that optionally includes user if authenticated
func OptionalAuth(store *db.SessionStore, database *sql.DB) func(http.Handler) http.Handler {
return OptionalAuthWithDeps(WebAuthDeps{
SessionStore: store,
Database: database,
})
}
// OptionalAuthWithDeps is middleware that optionally includes user and UserContext if authenticated
func OptionalAuthWithDeps(deps WebAuthDeps) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sessionID, ok := getSessionID(r)
if ok {
if sess, ok := store.Get(sessionID); ok {
if sess, ok := deps.SessionStore.Get(sessionID); ok {
// Look up full user from database to get avatar
user, err := db.GetUserByDID(database, sess.DID)
user, err := db.GetUserByDID(deps.Database, sess.DID)
if err != nil || user == nil {
// Fallback to session data if DB lookup fails
user = &db.User{
@@ -77,7 +116,21 @@ func OptionalAuth(store *db.SessionStore, database *sql.DB) func(http.Handler) h
PDSEndpoint: sess.PDSEndpoint,
}
}
ctx := context.WithValue(r.Context(), userKey, user)
ctx := r.Context()
ctx = context.WithValue(ctx, userKey, user)
// Create UserContext for authenticated users (enables EnsureUserSetup)
if deps.Refresher != nil {
userCtx := auth.NewUserContext(sess.DID, auth.AuthMethodOAuth, r.Method, &auth.Dependencies{
Refresher: deps.Refresher,
DefaultHoldDID: deps.DefaultHoldDID,
})
userCtx.SetPDS(sess.Handle, sess.PDSEndpoint)
userCtx.EnsureUserSetup()
ctx = auth.WithUserContext(ctx, userCtx)
}
r = r.WithContext(ctx)
}
}

View File

@@ -2,14 +2,13 @@ package middleware
import (
"context"
"encoding/json"
"database/sql"
"fmt"
"log/slog"
"net/http"
"strings"
"sync"
"github.com/distribution/distribution/v3"
"github.com/distribution/distribution/v3/registry/api/errcode"
registrymw "github.com/distribution/distribution/v3/registry/middleware/registry"
"github.com/distribution/distribution/v3/registry/storage/driver"
"github.com/distribution/reference"
@@ -24,14 +23,19 @@ import (
// holdDIDKey is the context key for storing hold DID
const holdDIDKey contextKey = "hold.did"
// authMethodKey is the context key for storing auth method from JWT
const authMethodKey contextKey = "auth.method"
// pullerDIDKey is the context key for storing the authenticated user's DID from JWT
const pullerDIDKey contextKey = "puller.did"
// Global variables for initialization only
// These are set by main.go during startup and copied into NamespaceResolver instances.
// After initialization, request handling uses the NamespaceResolver's instance fields.
var (
globalRefresher *oauth.Refresher
globalDatabase storage.DatabaseMetrics
globalAuthorizer auth.HoldAuthorizer
globalReadmeCache storage.ReadmeCache
globalRefresher *oauth.Refresher
globalDatabase *sql.DB
globalAuthorizer auth.HoldAuthorizer
)
// SetGlobalRefresher sets the OAuth refresher instance during initialization
@@ -42,7 +46,7 @@ func SetGlobalRefresher(refresher *oauth.Refresher) {
// SetGlobalDatabase sets the database instance during initialization
// Must be called before the registry starts serving requests
func SetGlobalDatabase(database storage.DatabaseMetrics) {
func SetGlobalDatabase(database *sql.DB) {
globalDatabase = database
}
@@ -52,12 +56,6 @@ func SetGlobalAuthorizer(authorizer auth.HoldAuthorizer) {
globalAuthorizer = authorizer
}
// SetGlobalReadmeCache sets the readme cache instance during initialization
// Must be called before the registry starts serving requests
func SetGlobalReadmeCache(readmeCache storage.ReadmeCache) {
globalReadmeCache = readmeCache
}
func init() {
// Register the name resolution middleware
registrymw.Register("atproto-resolver", initATProtoResolver)
@@ -66,14 +64,12 @@ func init() {
// NamespaceResolver wraps a namespace and resolves names
type NamespaceResolver struct {
distribution.Namespace
defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
baseURL string // Base URL for error messages (e.g., "https://atcr.io")
testMode bool // If true, fallback to default hold when user's hold is unreachable
repositories sync.Map // Cache of RoutingRepository instances by key (did:reponame)
refresher *oauth.Refresher // OAuth session manager (copied from global on init)
database storage.DatabaseMetrics // Metrics database (copied from global on init)
authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init)
readmeCache storage.ReadmeCache // README cache (copied from global on init)
defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
baseURL string // Base URL for error messages (e.g., "https://atcr.io")
testMode bool // If true, fallback to default hold when user's hold is unreachable
refresher *oauth.Refresher // OAuth session manager (copied from global on init)
sqlDB *sql.DB // Database for hold DID lookup and metrics (copied from global on init)
authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init)
}
// initATProtoResolver initializes the name resolution middleware
@@ -105,19 +101,11 @@ func initATProtoResolver(ctx context.Context, ns distribution.Namespace, _ drive
baseURL: baseURL,
testMode: testMode,
refresher: globalRefresher,
database: globalDatabase,
sqlDB: globalDatabase,
authorizer: globalAuthorizer,
readmeCache: globalReadmeCache,
}, nil
}
// authErrorMessage creates a user-friendly auth error with login URL
func (nr *NamespaceResolver) authErrorMessage(message string) error {
loginURL := fmt.Sprintf("%s/auth/oauth/login", nr.baseURL)
fullMessage := fmt.Sprintf("%s - please re-authenticate at %s", message, loginURL)
return errcode.ErrorCodeUnauthorized.WithMessage(fullMessage)
}
// Repository resolves the repository name and delegates to underlying namespace
// Handles names like:
// - atcr.io/alice/myimage → resolve alice to DID
@@ -151,26 +139,8 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name
}
ctx = context.WithValue(ctx, holdDIDKey, holdDID)
// Auto-reconcile crew membership on first push/pull
// This ensures users can push immediately after docker login without web sign-in
// EnsureCrewMembership is best-effort and logs errors without failing the request
if holdDID != "" && nr.refresher != nil {
slog.Debug("Auto-reconciling crew membership", "component", "registry/middleware", "did", did, "hold_did", holdDID)
client := atproto.NewClient(pdsEndpoint, did, "")
storage.EnsureCrewMembership(ctx, client, nr.refresher, holdDID)
}
// Get service token for hold authentication
var serviceToken string
if nr.refresher != nil {
var err error
serviceToken, err = token.GetOrFetchServiceToken(ctx, nr.refresher, did, holdDID, pdsEndpoint)
if err != nil {
slog.Error("Failed to get service token", "component", "registry/middleware", "did", did, "error", err)
slog.Error("User needs to re-authenticate via credential helper", "component", "registry/middleware")
return nil, nr.authErrorMessage("OAuth session expired")
}
}
// Note: Profile and crew membership are now ensured in UserContextMiddleware
// via EnsureUserSetup() - no need to call here
// Create a new reference with identity/image format
// Use the identity (or DID) as the namespace to ensure canonical format
@@ -187,73 +157,30 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name
return nil, err
}
// Get access token for PDS operations
// Try OAuth refresher first (for users who authorized via AppView OAuth)
// Fall back to Basic Auth token cache (for users who used app passwords)
var atprotoClient *atproto.Client
if nr.refresher != nil {
// Try OAuth flow first
session, err := nr.refresher.GetSession(ctx, did)
if err == nil {
// OAuth session available - use indigo's API client (handles DPoP automatically)
apiClient := session.APIClient()
atprotoClient = atproto.NewClientWithIndigoClient(pdsEndpoint, did, apiClient)
} else {
slog.Debug("OAuth refresh failed, falling back to Basic Auth", "component", "registry/middleware", "did", did, "error", err)
}
}
// Fall back to Basic Auth token cache if OAuth not available
if atprotoClient == nil {
accessToken, ok := auth.GetGlobalTokenCache().Get(did)
if !ok {
slog.Debug("No cached access token found (neither OAuth nor Basic Auth)", "component", "registry/middleware", "did", did)
accessToken = "" // Will fail on manifest push, but let it try
} else {
slog.Debug("Using Basic Auth access token", "component", "registry/middleware", "did", did, "token_length", len(accessToken))
}
atprotoClient = atproto.NewClient(pdsEndpoint, did, accessToken)
}
// IMPORTANT: Use only the image name (not identity/image) for ATProto storage
// ATProto records are scoped to the user's DID, so we don't need the identity prefix
// Example: "evan.jarrett.net/debian" -> store as "debian"
repositoryName := imageName
// Cache key is DID + repository name
cacheKey := did + ":" + repositoryName
// Check cache first and update service token
if cached, ok := nr.repositories.Load(cacheKey); ok {
cachedRepo := cached.(*storage.RoutingRepository)
// Always update the service token even for cached repos (token may have been renewed)
cachedRepo.Ctx.ServiceToken = serviceToken
return cachedRepo, nil
// Get UserContext from request context (set by UserContextMiddleware)
userCtx := auth.FromContext(ctx)
if userCtx == nil {
return nil, fmt.Errorf("UserContext not set in request context - ensure UserContextMiddleware is configured")
}
// Set target repository info on UserContext
// ATProtoClient is cached lazily via userCtx.GetATProtoClient()
userCtx.SetTarget(did, handle, pdsEndpoint, repositoryName, holdDID)
// Create routing repository - routes manifests to ATProto, blobs to hold service
// The registry is stateless - no local storage is used
// Bundle all context into a single RegistryContext struct
registryCtx := &storage.RegistryContext{
DID: did,
Handle: handle,
HoldDID: holdDID,
PDSEndpoint: pdsEndpoint,
Repository: repositoryName,
ServiceToken: serviceToken, // Cached service token from middleware validation
ATProtoClient: atprotoClient,
Database: nr.database,
Authorizer: nr.authorizer,
Refresher: nr.refresher,
ReadmeCache: nr.readmeCache,
}
routingRepo := storage.NewRoutingRepository(repo, registryCtx)
// Cache the repository
nr.repositories.Store(cacheKey, routingRepo)
return routingRepo, nil
//
// NOTE: We create a fresh RoutingRepository on every request (no caching) because:
// 1. Each layer upload is a separate HTTP request (possibly different process)
// 2. OAuth sessions can be refreshed/invalidated between requests
// 3. The refresher already caches sessions efficiently (in-memory + DB)
// 4. ATProtoClient is now cached in UserContext via GetATProtoClient()
return storage.NewRoutingRepository(repo, userCtx, nr.sqlDB), nil
}
// Repositories delegates to underlying namespace
@@ -274,8 +201,7 @@ func (nr *NamespaceResolver) BlobStatter() distribution.BlobStatter {
// findHoldDID determines which hold DID to use for blob storage
// Priority order:
// 1. User's sailor profile defaultHold (if set)
// 2. User's own hold record (io.atcr.hold)
// 3. AppView's default hold DID
// 2. AppView's default hold DID
// Returns a hold DID (e.g., "did:web:hold01.atcr.io"), or empty string if none configured
func (nr *NamespaceResolver) findHoldDID(ctx context.Context, did, pdsEndpoint string) string {
// Create ATProto client (without auth - reading public records)
@@ -289,8 +215,8 @@ func (nr *NamespaceResolver) findHoldDID(ctx context.Context, did, pdsEndpoint s
}
if profile != nil && profile.DefaultHold != "" {
// Profile exists with defaultHold set
// In test mode, verify it's reachable before using it
// In test mode, verify the hold is reachable (fall back to default if not)
// In production, trust the user's profile and return their hold
if nr.testMode {
if nr.isHoldReachable(ctx, profile.DefaultHold) {
return profile.DefaultHold
@@ -301,28 +227,7 @@ func (nr *NamespaceResolver) findHoldDID(ctx context.Context, did, pdsEndpoint s
return profile.DefaultHold
}
// Profile doesn't exist or defaultHold is null/empty
// Check for user's own hold records
records, err := client.ListRecords(ctx, atproto.HoldCollection, 10)
if err != nil {
// Failed to query holds, use default
return nr.defaultHoldDID
}
// Find the first hold record
for _, record := range records {
var holdRecord atproto.HoldRecord
if err := json.Unmarshal(record.Value, &holdRecord); err != nil {
continue
}
// Return the endpoint from the first hold (normalize to DID if URL)
if holdRecord.Endpoint != "" {
return atproto.ResolveHoldDIDFromURL(holdRecord.Endpoint)
}
}
// No profile defaultHold and no own hold records - use AppView default
// No profile defaultHold - use AppView default
return nr.defaultHoldDID
}
@@ -344,3 +249,95 @@ func (nr *NamespaceResolver) isHoldReachable(ctx context.Context, holdDID string
return false
}
// ExtractAuthMethod is an HTTP middleware that extracts the auth method and puller DID from the JWT Authorization header
// and stores them in the request context for later use by the registry middleware.
// Also stores the HTTP method for routing decisions (GET/HEAD = pull, PUT/POST = push).
func ExtractAuthMethod(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Store HTTP method in context for routing decisions
// This is used by routing_repository.go to distinguish pull (GET/HEAD) from push (PUT/POST)
ctx = context.WithValue(ctx, "http.request.method", r.Method)
// Extract Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader != "" {
// Parse "Bearer <token>" format
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
tokenString := parts[1]
// Extract auth method from JWT (does not validate - just parses)
authMethod := token.ExtractAuthMethod(tokenString)
if authMethod != "" {
// Store in context for registry middleware
ctx = context.WithValue(ctx, authMethodKey, authMethod)
}
// Extract puller DID (Subject) from JWT
// This is the authenticated user's DID, used for service token requests
pullerDID := token.ExtractSubject(tokenString)
if pullerDID != "" {
ctx = context.WithValue(ctx, pullerDIDKey, pullerDID)
}
slog.Debug("Extracted auth info from JWT",
"component", "registry/middleware",
"authMethod", authMethod,
"pullerDID", pullerDID,
"httpMethod", r.Method)
}
}
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}
// UserContextMiddleware creates a UserContext from the extracted JWT claims
// and stores it in the request context for use throughout request processing.
// This middleware should be chained AFTER ExtractAuthMethod.
func UserContextMiddleware(deps *auth.Dependencies) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get values set by ExtractAuthMethod
authMethod, _ := ctx.Value(authMethodKey).(string)
pullerDID, _ := ctx.Value(pullerDIDKey).(string)
// Build UserContext with all dependencies
userCtx := auth.NewUserContext(pullerDID, authMethod, r.Method, deps)
// Eagerly resolve user's PDS for authenticated users
// This is a fast path that avoids lazy loading in most cases
if userCtx.IsAuthenticated {
if err := userCtx.ResolvePDS(ctx); err != nil {
slog.Warn("Failed to resolve puller's PDS",
"component", "registry/middleware",
"did", pullerDID,
"error", err)
// Continue without PDS - will fail on service token request
}
// Ensure user has profile and crew membership (runs in background, cached)
userCtx.EnsureUserSetup()
}
// Store UserContext in request context
ctx = auth.WithUserContext(ctx, userCtx)
r = r.WithContext(ctx)
slog.Debug("Created UserContext",
"component", "registry/middleware",
"isAuthenticated", userCtx.IsAuthenticated,
"authMethod", userCtx.AuthMethod,
"action", userCtx.Action.String(),
"pullerDID", pullerDID)
next.ServeHTTP(w, r)
})
}
}

View File

@@ -67,11 +67,6 @@ func TestSetGlobalAuthorizer(t *testing.T) {
// If we get here without panic, test passes
}
func TestSetGlobalReadmeCache(t *testing.T) {
SetGlobalReadmeCache(nil)
// If we get here without panic, test passes
}
// TestInitATProtoResolver tests the initialization function
func TestInitATProtoResolver(t *testing.T) {
ctx := context.Background()
@@ -134,17 +129,6 @@ func TestInitATProtoResolver(t *testing.T) {
}
}
// TestAuthErrorMessage tests the error message formatting
func TestAuthErrorMessage(t *testing.T) {
resolver := &NamespaceResolver{
baseURL: "https://atcr.io",
}
err := resolver.authErrorMessage("OAuth session expired")
assert.Contains(t, err.Error(), "OAuth session expired")
assert.Contains(t, err.Error(), "https://atcr.io/auth/oauth/login")
}
// TestFindHoldDID_DefaultFallback tests default hold DID fallback
func TestFindHoldDID_DefaultFallback(t *testing.T) {
// Start a mock PDS server that returns 404 for profile and empty list for holds
@@ -204,45 +188,6 @@ func TestFindHoldDID_SailorProfile(t *testing.T) {
assert.Equal(t, "did:web:user.hold.io", holdDID, "should use sailor profile's defaultHold")
}
// TestFindHoldDID_LegacyHoldRecords tests legacy hold record discovery
func TestFindHoldDID_LegacyHoldRecords(t *testing.T) {
// Start a mock PDS server that returns hold records
mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/xrpc/com.atproto.repo.getRecord" {
// Profile not found
w.WriteHeader(http.StatusNotFound)
return
}
if r.URL.Path == "/xrpc/com.atproto.repo.listRecords" {
// Return hold record
holdRecord := atproto.NewHoldRecord("https://legacy.hold.io", "alice", true)
recordJSON, _ := json.Marshal(holdRecord)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"records": []any{
map[string]any{
"uri": "at://did:plc:test123/io.atcr.hold/abc123",
"value": json.RawMessage(recordJSON),
},
},
})
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer mockPDS.Close()
resolver := &NamespaceResolver{
defaultHoldDID: "did:web:default.atcr.io",
}
ctx := context.Background()
holdDID := resolver.findHoldDID(ctx, "did:plc:test123", mockPDS.URL)
// Legacy URL should be converted to DID
assert.Equal(t, "did:web:legacy.hold.io", holdDID, "should use legacy hold record and convert to DID")
}
// TestFindHoldDID_Priority tests the priority order
func TestFindHoldDID_Priority(t *testing.T) {
// Start a mock PDS server that returns both profile and hold records
@@ -256,21 +201,6 @@ func TestFindHoldDID_Priority(t *testing.T) {
})
return
}
if r.URL.Path == "/xrpc/com.atproto.repo.listRecords" {
// Return hold record (should be ignored since profile exists)
holdRecord := atproto.NewHoldRecord("https://legacy.hold.io", "alice", true)
recordJSON, _ := json.Marshal(holdRecord)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"records": []any{
map[string]any{
"uri": "at://did:plc:test123/io.atcr.hold/abc123",
"value": json.RawMessage(recordJSON),
},
},
})
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer mockPDS.Close()

413
pkg/appview/ogcard/card.go Normal file
View File

@@ -0,0 +1,413 @@
// Package ogcard provides OpenGraph card image generation for ATCR.
package ogcard
import (
"image"
"image/color"
"image/draw"
_ "image/gif" // Register GIF decoder for image.Decode
_ "image/jpeg" // Register JPEG decoder for image.Decode
"image/png"
"io"
"net/http"
"time"
"github.com/goki/freetype"
"github.com/goki/freetype/truetype"
xdraw "golang.org/x/image/draw"
"golang.org/x/image/font"
_ "golang.org/x/image/webp" // Register WEBP decoder for image.Decode
)
// Text alignment constants
const (
AlignLeft = iota
AlignCenter
AlignRight
)
// Layout constants for OG cards
const (
// Card dimensions
CardWidth = 1200
CardHeight = 630
// Padding and sizing
Padding = 60
AvatarSize = 180
// Positioning offsets
IconTopOffset = 50 // Y offset from padding for icon
TextGapAfterIcon = 40 // X gap between icon and text
TextTopOffset = 50 // Y offset from icon top for text baseline
// Font sizes
FontTitle = 48.0
FontDescription = 32.0
FontStats = 40.0 // Larger for visibility when scaled down
FontBadge = 32.0 // Larger for visibility when scaled down
FontBranding = 28.0
// Spacing
LineSpacingLarge = 65 // Gap after title
LineSpacingSmall = 60 // Gap between description lines
StatsIconGap = 48 // Gap between stat icon and text
StatsItemGap = 60 // Gap between stat items
BadgeGap = 20 // Gap between badges
)
// Layout holds computed positions for a standard OG card layout
type Layout struct {
IconX int
IconY int
TextX float64
TextY float64
StatsY int
MaxWidth int // For text wrapping
}
// StandardLayout returns the standard OG card layout with computed positions
func StandardLayout() Layout {
iconX := Padding
iconY := Padding + IconTopOffset
textX := float64(iconX + AvatarSize + TextGapAfterIcon)
textY := float64(iconY + TextTopOffset)
statsY := CardHeight - Padding - 10
maxWidth := CardWidth - int(textX) - Padding
return Layout{
IconX: iconX,
IconY: iconY,
TextX: textX,
TextY: textY,
StatsY: statsY,
MaxWidth: maxWidth,
}
}
// Card represents an OG image canvas
type Card struct {
img *image.RGBA
width int
height int
}
// NewCard creates a new OG card with the standard 1200x630 dimensions
func NewCard() *Card {
return NewCardWithSize(1200, 630)
}
// NewCardWithSize creates a new OG card with custom dimensions
func NewCardWithSize(width, height int) *Card {
img := image.NewRGBA(image.Rect(0, 0, width, height))
return &Card{
img: img,
width: width,
height: height,
}
}
// Fill fills the entire card with a solid color
func (c *Card) Fill(col color.Color) {
draw.Draw(c.img, c.img.Bounds(), &image.Uniform{col}, image.Point{}, draw.Src)
}
// DrawRect draws a filled rectangle
func (c *Card) DrawRect(x, y, w, h int, col color.Color) {
rect := image.Rect(x, y, x+w, y+h)
draw.Draw(c.img, rect, &image.Uniform{col}, image.Point{}, draw.Over)
}
// DrawText draws text at the specified position
func (c *Card) DrawText(text string, x, y float64, size float64, col color.Color, align int, bold bool) error {
f := regularFont
if bold {
f = boldFont
}
if f == nil {
return nil // No font loaded
}
ctx := freetype.NewContext()
ctx.SetDPI(72)
ctx.SetFont(f)
ctx.SetFontSize(size)
ctx.SetClip(c.img.Bounds())
ctx.SetDst(c.img)
ctx.SetSrc(image.NewUniform(col))
// Calculate text width for alignment
if align != AlignLeft {
opts := truetype.Options{Size: size, DPI: 72}
face := truetype.NewFace(f, &opts)
defer face.Close()
textWidth := font.MeasureString(face, text).Round()
if align == AlignCenter {
x -= float64(textWidth) / 2
} else if align == AlignRight {
x -= float64(textWidth)
}
}
pt := freetype.Pt(int(x), int(y))
_, err := ctx.DrawString(text, pt)
return err
}
// MeasureText returns the width of text in pixels
func (c *Card) MeasureText(text string, size float64, bold bool) int {
f := regularFont
if bold {
f = boldFont
}
if f == nil {
return 0
}
opts := truetype.Options{Size: size, DPI: 72}
face := truetype.NewFace(f, &opts)
defer face.Close()
return font.MeasureString(face, text).Round()
}
// DrawTextWrapped draws text with word wrapping within maxWidth
// Returns the Y position after the last line
func (c *Card) DrawTextWrapped(text string, x, y float64, size float64, col color.Color, maxWidth int, bold bool) float64 {
words := splitWords(text)
if len(words) == 0 {
return y
}
lineHeight := size * 1.3
currentLine := ""
currentY := y
for _, word := range words {
testLine := currentLine
if testLine != "" {
testLine += " "
}
testLine += word
lineWidth := c.MeasureText(testLine, size, bold)
if lineWidth > maxWidth && currentLine != "" {
// Draw current line and start new one
c.DrawText(currentLine, x, currentY, size, col, AlignLeft, bold)
currentY += lineHeight
currentLine = word
} else {
currentLine = testLine
}
}
// Draw remaining text
if currentLine != "" {
c.DrawText(currentLine, x, currentY, size, col, AlignLeft, bold)
currentY += lineHeight
}
return currentY
}
// splitWords splits text into words
func splitWords(text string) []string {
var words []string
current := ""
for _, r := range text {
if r == ' ' || r == '\t' || r == '\n' {
if current != "" {
words = append(words, current)
current = ""
}
} else {
current += string(r)
}
}
if current != "" {
words = append(words, current)
}
return words
}
// DrawImage draws an image at the specified position
func (c *Card) DrawImage(img image.Image, x, y int) {
bounds := img.Bounds()
rect := image.Rect(x, y, x+bounds.Dx(), y+bounds.Dy())
draw.Draw(c.img, rect, img, bounds.Min, draw.Over)
}
// DrawCircularImage draws an image cropped to a circle
func (c *Card) DrawCircularImage(img image.Image, x, y, diameter int) {
// Scale image to fit diameter
scaled := scaleImage(img, diameter, diameter)
// Create circular mask
mask := createCircleMask(diameter)
// Draw with mask
rect := image.Rect(x, y, x+diameter, y+diameter)
draw.DrawMask(c.img, rect, scaled, image.Point{}, mask, image.Point{}, draw.Over)
}
// FetchAndDrawCircularImage fetches an image from URL and draws it as a circle
func (c *Card) FetchAndDrawCircularImage(url string, x, y, diameter int) error {
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
img, _, err := image.Decode(resp.Body)
if err != nil {
return err
}
c.DrawCircularImage(img, x, y, diameter)
return nil
}
// DrawPlaceholderCircle draws a colored circle with a letter
func (c *Card) DrawPlaceholderCircle(x, y, diameter int, bgColor, textColor color.Color, letter string) {
// Draw filled circle
radius := diameter / 2
centerX := x + radius
centerY := y + radius
for dy := -radius; dy <= radius; dy++ {
for dx := -radius; dx <= radius; dx++ {
if dx*dx+dy*dy <= radius*radius {
c.img.Set(centerX+dx, centerY+dy, bgColor)
}
}
}
// Draw letter in center
fontSize := float64(diameter) * 0.5
c.DrawText(letter, float64(centerX), float64(centerY)+fontSize/3, fontSize, textColor, AlignCenter, true)
}
// DrawRoundedRect draws a filled rounded rectangle
func (c *Card) DrawRoundedRect(x, y, w, h, radius int, col color.Color) {
// Draw main rectangle (without corners)
for dy := radius; dy < h-radius; dy++ {
for dx := 0; dx < w; dx++ {
c.img.Set(x+dx, y+dy, col)
}
}
// Draw top and bottom strips (without corners)
for dy := 0; dy < radius; dy++ {
for dx := radius; dx < w-radius; dx++ {
c.img.Set(x+dx, y+dy, col)
c.img.Set(x+dx, y+h-1-dy, col)
}
}
// Draw rounded corners
for dy := 0; dy < radius; dy++ {
for dx := 0; dx < radius; dx++ {
// Check if point is within circle
cx := radius - dx - 1
cy := radius - dy - 1
if cx*cx+cy*cy <= radius*radius {
// Top-left
c.img.Set(x+dx, y+dy, col)
// Top-right
c.img.Set(x+w-1-dx, y+dy, col)
// Bottom-left
c.img.Set(x+dx, y+h-1-dy, col)
// Bottom-right
c.img.Set(x+w-1-dx, y+h-1-dy, col)
}
}
}
}
// DrawBadge draws a pill-shaped badge with text
func (c *Card) DrawBadge(text string, x, y int, fontSize float64, bgColor, textColor color.Color) int {
// Measure text width
textWidth := c.MeasureText(text, fontSize, false)
paddingX := 12
paddingY := 6
height := int(fontSize) + paddingY*2
width := textWidth + paddingX*2
radius := height / 2
// Draw rounded background
c.DrawRoundedRect(x, y, width, height, radius, bgColor)
// Draw text centered in badge
textX := float64(x + paddingX)
textY := float64(y + paddingY + int(fontSize) - 2)
c.DrawText(text, textX, textY, fontSize, textColor, AlignLeft, false)
return width
}
// EncodePNG encodes the card as PNG to the writer
func (c *Card) EncodePNG(w io.Writer) error {
return png.Encode(w, c.img)
}
// DrawAvatarOrPlaceholder draws a circular avatar from URL, falling back to placeholder
func (c *Card) DrawAvatarOrPlaceholder(url string, x, y, size int, letter string) {
if url != "" {
if err := c.FetchAndDrawCircularImage(url, x, y, size); err == nil {
return
}
}
c.DrawPlaceholderCircle(x, y, size, ColorAccent, ColorText, letter)
}
// DrawStatWithIcon draws an icon + text stat and returns the next X position
func (c *Card) DrawStatWithIcon(icon string, text string, x, y int, iconColor, textColor color.Color) int {
c.DrawIcon(icon, x, y-int(FontStats), int(FontStats), iconColor)
x += StatsIconGap
c.DrawText(text, float64(x), float64(y), FontStats, textColor, AlignLeft, false)
return x + c.MeasureText(text, FontStats, false) + StatsItemGap
}
// DrawBranding draws "ATCR" in the bottom-right corner
func (c *Card) DrawBranding() {
y := CardHeight - Padding - 10
c.DrawText("ATCR", float64(CardWidth-Padding), float64(y), FontBranding, ColorMuted, AlignRight, true)
}
// scaleImage scales an image to the target dimensions
func scaleImage(src image.Image, width, height int) image.Image {
dst := image.NewRGBA(image.Rect(0, 0, width, height))
xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), xdraw.Over, nil)
return dst
}
// createCircleMask creates a circular alpha mask
func createCircleMask(diameter int) *image.Alpha {
mask := image.NewAlpha(image.Rect(0, 0, diameter, diameter))
radius := diameter / 2
centerX := radius
centerY := radius
for y := 0; y < diameter; y++ {
for x := 0; x < diameter; x++ {
dx := x - centerX
dy := y - centerY
if dx*dx+dy*dy <= radius*radius {
mask.SetAlpha(x, y, color.Alpha{A: 255})
}
}
}
return mask
}
// Common colors
var (
ColorBackground = color.RGBA{R: 22, G: 27, B: 34, A: 255} // #161b22 - GitHub dark elevated
ColorText = color.RGBA{R: 230, G: 237, B: 243, A: 255} // #e6edf3 - Light text
ColorMuted = color.RGBA{R: 125, G: 133, B: 144, A: 255} // #7d8590 - Muted text
ColorAccent = color.RGBA{R: 47, G: 129, B: 247, A: 255} // #2f81f7 - Blue accent
ColorStar = color.RGBA{R: 227, G: 179, B: 65, A: 255} // #e3b341 - Star yellow
ColorBadgeBg = color.RGBA{R: 33, G: 38, B: 45, A: 255} // #21262d - Badge background
ColorBadgeAccent = color.RGBA{R: 31, G: 111, B: 235, A: 255} // #1f6feb - Blue badge bg
)

View File

@@ -0,0 +1,45 @@
package ogcard
// Font configuration for OG card rendering.
// Currently uses Go fonts (embedded in golang.org/x/image).
//
// To use custom fonts instead, replace the init() below with:
//
// //go:embed MyFont-Regular.ttf
// var regularFontData []byte
// //go:embed MyFont-Bold.ttf
// var boldFontData []byte
//
// func init() {
// regularFont, _ = truetype.Parse(regularFontData)
// boldFont, _ = truetype.Parse(boldFontData)
// }
import (
"log"
"github.com/goki/freetype/truetype"
"golang.org/x/image/font/gofont/gobold"
"golang.org/x/image/font/gofont/goregular"
)
var (
regularFont *truetype.Font
boldFont *truetype.Font
)
func init() {
var err error
regularFont, err = truetype.Parse(goregular.TTF)
if err != nil {
log.Printf("ogcard: failed to parse Go Regular font: %v", err)
return
}
boldFont, err = truetype.Parse(gobold.TTF)
if err != nil {
log.Printf("ogcard: failed to parse Go Bold font: %v", err)
return
}
}

View File

@@ -0,0 +1,68 @@
package ogcard
import (
"bytes"
"fmt"
"image"
"image/color"
"image/draw"
"strings"
"github.com/srwiley/oksvg"
"github.com/srwiley/rasterx"
)
// Lucide icons as SVG paths (simplified from Lucide icon set)
// These are the path data for 24x24 viewBox icons
var iconPaths = map[string]string{
// Star icon - outline
"star": `<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`,
// Star filled
"star-filled": `<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="currentColor"/>`,
// Arrow down to line (download/pull icon)
"arrow-down-to-line": `<path d="M12 17V3M12 17l-5-5M12 17l5-5M19 21H5" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`,
// Package icon
"package": `<path d="M16.5 9.4l-9-5.19M21 16V8a2 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" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.27 6.96L12 12.01l8.73-5.05M12 22.08V12" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`,
}
// DrawIcon draws a Lucide icon at the specified position with the given size and color
func (c *Card) DrawIcon(name string, x, y, size int, col color.Color) error {
path, ok := iconPaths[name]
if !ok {
return fmt.Errorf("unknown icon: %s", name)
}
// Build full SVG with color
r, g, b, _ := col.RGBA()
colorStr := fmt.Sprintf("rgb(%d,%d,%d)", r>>8, g>>8, b>>8)
path = strings.ReplaceAll(path, "currentColor", colorStr)
svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">%s</svg>`, path)
// Parse SVG
icon, err := oksvg.ReadIconStream(bytes.NewReader([]byte(svg)))
if err != nil {
return fmt.Errorf("failed to parse icon SVG: %w", err)
}
// Create target image for the icon
iconImg := image.NewRGBA(image.Rect(0, 0, size, size))
// Set up scanner for rasterization
scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds())
raster := rasterx.NewDasher(size, size, scanner)
// Scale icon to target size
scale := float64(size) / 24.0
icon.SetTarget(0, 0, float64(size), float64(size))
icon.Draw(raster, scale)
// Draw icon onto card
rect := image.Rect(x, y, x+size, y+size)
draw.Draw(c.img, rect, iconImg, image.Point{}, draw.Over)
return nil
}

View File

@@ -1,111 +0,0 @@
// Package readme provides README fetching, rendering, and caching functionality
// for container repositories. It fetches markdown content from URLs, renders it
// to sanitized HTML using GitHub-flavored markdown, and caches the results in
// a database with configurable TTL.
package readme
import (
"context"
"database/sql"
"log/slog"
"time"
)
// Cache stores rendered README HTML in the database
type Cache struct {
db *sql.DB
fetcher *Fetcher
ttl time.Duration
}
// NewCache creates a new README cache
func NewCache(db *sql.DB, ttl time.Duration) *Cache {
if ttl == 0 {
ttl = 1 * time.Hour // Default TTL
}
return &Cache{
db: db,
fetcher: NewFetcher(),
ttl: ttl,
}
}
// Get retrieves a README from cache or fetches it
func (c *Cache) Get(ctx context.Context, readmeURL string) (string, error) {
// Try to get from cache
html, fetchedAt, err := c.getFromDB(readmeURL)
if err == nil {
// Check if cache is still valid
if time.Since(fetchedAt) < c.ttl {
return html, nil
}
}
// Cache miss or expired, fetch fresh content
html, err = c.fetcher.FetchAndRender(ctx, readmeURL)
if err != nil {
// If fetch fails but we have stale cache, return it
if html != "" {
return html, nil
}
return "", err
}
// Store in cache
if err := c.storeInDB(readmeURL, html); err != nil {
// Log error but don't fail - we have the content
slog.Warn("Failed to cache README", "error", err)
}
return html, nil
}
// getFromDB retrieves cached README from database
func (c *Cache) getFromDB(readmeURL string) (string, time.Time, error) {
var html string
var fetchedAt time.Time
err := c.db.QueryRow(`
SELECT html, fetched_at
FROM readme_cache
WHERE url = ?
`, readmeURL).Scan(&html, &fetchedAt)
if err != nil {
return "", time.Time{}, err
}
return html, fetchedAt, nil
}
// storeInDB stores rendered README in database
func (c *Cache) storeInDB(readmeURL, html string) error {
_, err := c.db.Exec(`
INSERT INTO readme_cache (url, html, fetched_at)
VALUES (?, ?, ?)
ON CONFLICT(url) DO UPDATE SET
html = excluded.html,
fetched_at = excluded.fetched_at
`, readmeURL, html, time.Now())
return err
}
// Invalidate removes a README from the cache
func (c *Cache) Invalidate(readmeURL string) error {
_, err := c.db.Exec(`
DELETE FROM readme_cache
WHERE url = ?
`, readmeURL)
return err
}
// Cleanup removes expired entries from the cache
func (c *Cache) Cleanup() error {
cutoff := time.Now().Add(-c.ttl * 2) // Keep for 2x TTL
_, err := c.db.Exec(`
DELETE FROM readme_cache
WHERE fetched_at < ?
`, cutoff)
return err
}

View File

@@ -1,13 +0,0 @@
package readme
import "testing"
func TestCache_Struct(t *testing.T) {
// Simple struct test
cache := &Cache{}
if cache == nil {
t.Error("Expected non-nil cache")
}
}
// TODO: Add cache operation tests

View File

@@ -7,6 +7,7 @@ import (
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
@@ -180,6 +181,27 @@ func getBaseURL(u *url.URL) string {
return fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, path)
}
// Is404 returns true if the error indicates a 404 Not Found response
func Is404(err error) bool {
return err != nil && strings.Contains(err.Error(), "unexpected status code: 404")
}
// RenderMarkdown renders a markdown string to sanitized HTML
// This is used for rendering repo page descriptions stored in the database
func (f *Fetcher) RenderMarkdown(content []byte) (string, error) {
// Render markdown to HTML (no base URL for repo page descriptions)
return f.renderMarkdown(content, "")
}
// Regex patterns for matching relative URLs that need rewriting
// These match src="..." or href="..." where the URL is relative (not absolute, not data:, not #anchor)
var (
// Match src="filename" where filename doesn't start with http://, https://, //, /, #, data:, or mailto:
relativeSrcPattern = regexp.MustCompile(`src="([^"/:][^"]*)"`)
// Match href="filename" where filename doesn't start with http://, https://, //, /, #, data:, or mailto:
relativeHrefPattern = regexp.MustCompile(`href="([^"/:][^"]*)"`)
)
// rewriteRelativeURLs converts relative URLs to absolute URLs
func rewriteRelativeURLs(html, baseURL string) string {
if baseURL == "" {
@@ -191,20 +213,51 @@ func rewriteRelativeURLs(html, baseURL string) string {
return html
}
// Simple string replacement for common patterns
// This is a basic implementation - for production, consider using an HTML parser
// Handle root-relative URLs (starting with /) first
// Must be done before bare relative URLs to avoid double-processing
if base.Scheme != "" && base.Host != "" {
root := fmt.Sprintf("%s://%s/", base.Scheme, base.Host)
// Replace src="/" and href="/" but not src="//" (protocol-relative URLs)
html = strings.ReplaceAll(html, `src="/`, fmt.Sprintf(`src="%s`, root))
html = strings.ReplaceAll(html, `href="/`, fmt.Sprintf(`href="%s`, root))
}
// Handle explicit relative paths (./something and ../something)
html = strings.ReplaceAll(html, `src="./`, fmt.Sprintf(`src="%s`, baseURL))
html = strings.ReplaceAll(html, `href="./`, fmt.Sprintf(`href="%s`, baseURL))
html = strings.ReplaceAll(html, `src="../`, fmt.Sprintf(`src="%s../`, baseURL))
html = strings.ReplaceAll(html, `href="../`, fmt.Sprintf(`href="%s../`, baseURL))
// Handle root-relative URLs (starting with /)
if base.Scheme != "" && base.Host != "" {
root := fmt.Sprintf("%s://%s/", base.Scheme, base.Host)
// Replace src="/" and href="/" but not src="//" (absolute URLs)
html = strings.ReplaceAll(html, `src="/`, fmt.Sprintf(`src="%s`, root))
html = strings.ReplaceAll(html, `href="/`, fmt.Sprintf(`href="%s`, root))
}
// Handle bare relative URLs (e.g., src="image.png" without ./ prefix)
// Skip URLs that are already absolute (start with http://, https://, or //)
// Skip anchors (#), data URLs (data:), and mailto links
html = relativeSrcPattern.ReplaceAllStringFunc(html, func(match string) string {
// Extract the URL from src="..."
url := match[5 : len(match)-1] // Remove 'src="' and '"'
// Skip if already processed or is a special URL type
if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") ||
strings.HasPrefix(url, "//") || strings.HasPrefix(url, "#") ||
strings.HasPrefix(url, "data:") || strings.HasPrefix(url, "mailto:") {
return match
}
return fmt.Sprintf(`src="%s%s"`, baseURL, url)
})
html = relativeHrefPattern.ReplaceAllStringFunc(html, func(match string) string {
// Extract the URL from href="..."
url := match[6 : len(match)-1] // Remove 'href="' and '"'
// Skip if already processed or is a special URL type
if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") ||
strings.HasPrefix(url, "//") || strings.HasPrefix(url, "#") ||
strings.HasPrefix(url, "data:") || strings.HasPrefix(url, "mailto:") {
return match
}
return fmt.Sprintf(`href="%s%s"`, baseURL, url)
})
return html
}

View File

@@ -145,6 +145,48 @@ func TestRewriteRelativeURLs(t *testing.T) {
baseURL: "https://example.com/docs/",
expected: `<img src="https://example.com//cdn.example.com/image.png">`,
},
{
name: "bare relative src (no ./ prefix)",
html: `<img src="image.png">`,
baseURL: "https://example.com/docs/",
expected: `<img src="https://example.com/docs/image.png">`,
},
{
name: "bare relative href (no ./ prefix)",
html: `<a href="page.html">link</a>`,
baseURL: "https://example.com/docs/",
expected: `<a href="https://example.com/docs/page.html">link</a>`,
},
{
name: "bare relative with path",
html: `<img src="images/logo.png">`,
baseURL: "https://example.com/docs/",
expected: `<img src="https://example.com/docs/images/logo.png">`,
},
{
name: "anchor links unchanged",
html: `<a href="#section">link</a>`,
baseURL: "https://example.com/docs/",
expected: `<a href="#section">link</a>`,
},
{
name: "data URLs unchanged",
html: `<img src="data:image/png;base64,abc123">`,
baseURL: "https://example.com/docs/",
expected: `<img src="data:image/png;base64,abc123">`,
},
{
name: "mailto links unchanged",
html: `<a href="mailto:test@example.com">email</a>`,
baseURL: "https://example.com/docs/",
expected: `<a href="mailto:test@example.com">email</a>`,
},
{
name: "mixed bare and prefixed relative URLs",
html: `<img src="slices_and_lucy.png"><a href="./other.md">link</a>`,
baseURL: "https://github.com/user/repo/blob/main/",
expected: `<img src="https://github.com/user/repo/blob/main/slices_and_lucy.png"><a href="https://github.com/user/repo/blob/main/other.md">link</a>`,
},
}
for _, tt := range tests {
@@ -157,4 +199,110 @@ func TestRewriteRelativeURLs(t *testing.T) {
}
}
func TestFetcher_RenderMarkdown(t *testing.T) {
fetcher := NewFetcher()
tests := []struct {
name string
content string
wantContain string
wantErr bool
}{
{
name: "simple paragraph",
content: "Hello, world!",
wantContain: "<p>Hello, world!</p>",
wantErr: false,
},
{
name: "heading",
content: "# My App",
wantContain: "<h1",
wantErr: false,
},
{
name: "bold text",
content: "This is **bold** text.",
wantContain: "<strong>bold</strong>",
wantErr: false,
},
{
name: "italic text",
content: "This is *italic* text.",
wantContain: "<em>italic</em>",
wantErr: false,
},
{
name: "code block",
content: "```\ncode here\n```",
wantContain: "<pre>",
wantErr: false,
},
{
name: "link",
content: "[Link text](https://example.com)",
wantContain: `href="https://example.com"`,
wantErr: false,
},
{
name: "image",
content: "![Alt text](https://example.com/image.png)",
wantContain: `src="https://example.com/image.png"`,
wantErr: false,
},
{
name: "unordered list",
content: "- Item 1\n- Item 2",
wantContain: "<ul>",
wantErr: false,
},
{
name: "ordered list",
content: "1. Item 1\n2. Item 2",
wantContain: "<ol>",
wantErr: false,
},
{
name: "empty content",
content: "",
wantContain: "",
wantErr: false,
},
{
name: "complex markdown",
content: "# Title\n\nA paragraph with **bold** and *italic* text.\n\n- List item 1\n- List item 2\n\n```go\nfunc main() {}\n```",
wantContain: "<h1",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
html, err := fetcher.RenderMarkdown([]byte(tt.content))
if (err != nil) != tt.wantErr {
t.Errorf("RenderMarkdown() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && tt.wantContain != "" {
if !containsSubstring(html, tt.wantContain) {
t.Errorf("RenderMarkdown() = %q, want to contain %q", html, tt.wantContain)
}
}
})
}
}
func containsSubstring(s, substr string) bool {
return len(substr) == 0 || (len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstringHelper(s, substr)))
}
func containsSubstringHelper(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
// TODO: Add README fetching and caching tests

View File

@@ -0,0 +1,103 @@
package readme
import (
"fmt"
"net/url"
"strings"
)
// Platform represents a supported Git hosting platform
type Platform string
const (
PlatformGitHub Platform = "github"
PlatformGitLab Platform = "gitlab"
PlatformTangled Platform = "tangled"
)
// ParseSourceURL extracts platform, user, and repo from a source repository URL.
// Returns ok=false if the URL is not a recognized pattern.
func ParseSourceURL(sourceURL string) (platform Platform, user, repo string, ok bool) {
if sourceURL == "" {
return "", "", "", false
}
parsed, err := url.Parse(sourceURL)
if err != nil {
return "", "", "", false
}
// Normalize: remove trailing slash and .git suffix
path := strings.TrimSuffix(parsed.Path, "/")
path = strings.TrimSuffix(path, ".git")
path = strings.TrimPrefix(path, "/")
if path == "" {
return "", "", "", false
}
host := strings.ToLower(parsed.Host)
switch {
case host == "github.com":
// GitHub: github.com/{user}/{repo}
parts := strings.SplitN(path, "/", 3)
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
return "", "", "", false
}
return PlatformGitHub, parts[0], parts[1], true
case host == "gitlab.com":
// GitLab: gitlab.com/{user}/{repo} or gitlab.com/{group}/{subgroup}/{repo}
// For nested groups, user = everything except last part, repo = last part
lastSlash := strings.LastIndex(path, "/")
if lastSlash == -1 || lastSlash == 0 {
return "", "", "", false
}
user = path[:lastSlash]
repo = path[lastSlash+1:]
if user == "" || repo == "" {
return "", "", "", false
}
return PlatformGitLab, user, repo, true
case host == "tangled.org" || host == "tangled.sh":
// Tangled: tangled.org/{user}/{repo} or tangled.sh/@{user}/{repo} (legacy)
// Strip leading @ from user if present
path = strings.TrimPrefix(path, "@")
parts := strings.SplitN(path, "/", 3)
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
return "", "", "", false
}
return PlatformTangled, parts[0], parts[1], true
default:
return "", "", "", false
}
}
// DeriveReadmeURL converts a source repository URL to a raw README URL.
// Returns empty string if platform is not supported.
func DeriveReadmeURL(sourceURL, branch string) string {
platform, user, repo, ok := ParseSourceURL(sourceURL)
if !ok {
return ""
}
switch platform {
case PlatformGitHub:
// https://raw.githubusercontent.com/{user}/{repo}/refs/heads/{branch}/README.md
return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/refs/heads/%s/README.md", user, repo, branch)
case PlatformGitLab:
// https://gitlab.com/{user}/{repo}/-/raw/{branch}/README.md
return fmt.Sprintf("https://gitlab.com/%s/%s/-/raw/%s/README.md", user, repo, branch)
case PlatformTangled:
// https://tangled.org/{user}/{repo}/raw/{branch}/README.md
return fmt.Sprintf("https://tangled.org/%s/%s/raw/%s/README.md", user, repo, branch)
default:
return ""
}
}

View File

@@ -0,0 +1,241 @@
package readme
import (
"testing"
)
func TestParseSourceURL(t *testing.T) {
tests := []struct {
name string
sourceURL string
wantPlatform Platform
wantUser string
wantRepo string
wantOK bool
}{
// GitHub
{
name: "github standard",
sourceURL: "https://github.com/bigmoves/quickslice",
wantPlatform: PlatformGitHub,
wantUser: "bigmoves",
wantRepo: "quickslice",
wantOK: true,
},
{
name: "github with .git suffix",
sourceURL: "https://github.com/user/repo.git",
wantPlatform: PlatformGitHub,
wantUser: "user",
wantRepo: "repo",
wantOK: true,
},
{
name: "github with trailing slash",
sourceURL: "https://github.com/user/repo/",
wantPlatform: PlatformGitHub,
wantUser: "user",
wantRepo: "repo",
wantOK: true,
},
{
name: "github with subpath (ignored)",
sourceURL: "https://github.com/user/repo/tree/main",
wantPlatform: PlatformGitHub,
wantUser: "user",
wantRepo: "repo",
wantOK: true,
},
{
name: "github user only",
sourceURL: "https://github.com/user",
wantOK: false,
},
// GitLab
{
name: "gitlab standard",
sourceURL: "https://gitlab.com/user/repo",
wantPlatform: PlatformGitLab,
wantUser: "user",
wantRepo: "repo",
wantOK: true,
},
{
name: "gitlab nested groups",
sourceURL: "https://gitlab.com/group/subgroup/repo",
wantPlatform: PlatformGitLab,
wantUser: "group/subgroup",
wantRepo: "repo",
wantOK: true,
},
{
name: "gitlab deep nested groups",
sourceURL: "https://gitlab.com/a/b/c/d/repo",
wantPlatform: PlatformGitLab,
wantUser: "a/b/c/d",
wantRepo: "repo",
wantOK: true,
},
{
name: "gitlab with .git suffix",
sourceURL: "https://gitlab.com/user/repo.git",
wantPlatform: PlatformGitLab,
wantUser: "user",
wantRepo: "repo",
wantOK: true,
},
// Tangled
{
name: "tangled standard",
sourceURL: "https://tangled.org/evan.jarrett.net/at-container-registry",
wantPlatform: PlatformTangled,
wantUser: "evan.jarrett.net",
wantRepo: "at-container-registry",
wantOK: true,
},
{
name: "tangled with legacy @ prefix",
sourceURL: "https://tangled.org/@evan.jarrett.net/at-container-registry",
wantPlatform: PlatformTangled,
wantUser: "evan.jarrett.net",
wantRepo: "at-container-registry",
wantOK: true,
},
{
name: "tangled.sh domain",
sourceURL: "https://tangled.sh/user/repo",
wantPlatform: PlatformTangled,
wantUser: "user",
wantRepo: "repo",
wantOK: true,
},
{
name: "tangled with trailing slash",
sourceURL: "https://tangled.org/user/repo/",
wantPlatform: PlatformTangled,
wantUser: "user",
wantRepo: "repo",
wantOK: true,
},
// Unsupported / Invalid
{
name: "unsupported platform",
sourceURL: "https://bitbucket.org/user/repo",
wantOK: false,
},
{
name: "empty url",
sourceURL: "",
wantOK: false,
},
{
name: "invalid url",
sourceURL: "not-a-url",
wantOK: false,
},
{
name: "just host",
sourceURL: "https://github.com",
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
platform, user, repo, ok := ParseSourceURL(tt.sourceURL)
if ok != tt.wantOK {
t.Errorf("ParseSourceURL(%q) ok = %v, want %v", tt.sourceURL, ok, tt.wantOK)
return
}
if !tt.wantOK {
return
}
if platform != tt.wantPlatform {
t.Errorf("ParseSourceURL(%q) platform = %v, want %v", tt.sourceURL, platform, tt.wantPlatform)
}
if user != tt.wantUser {
t.Errorf("ParseSourceURL(%q) user = %q, want %q", tt.sourceURL, user, tt.wantUser)
}
if repo != tt.wantRepo {
t.Errorf("ParseSourceURL(%q) repo = %q, want %q", tt.sourceURL, repo, tt.wantRepo)
}
})
}
}
func TestDeriveReadmeURL(t *testing.T) {
tests := []struct {
name string
sourceURL string
branch string
want string
}{
// GitHub
{
name: "github main",
sourceURL: "https://github.com/bigmoves/quickslice",
branch: "main",
want: "https://raw.githubusercontent.com/bigmoves/quickslice/refs/heads/main/README.md",
},
{
name: "github master",
sourceURL: "https://github.com/user/repo",
branch: "master",
want: "https://raw.githubusercontent.com/user/repo/refs/heads/master/README.md",
},
// GitLab
{
name: "gitlab main",
sourceURL: "https://gitlab.com/user/repo",
branch: "main",
want: "https://gitlab.com/user/repo/-/raw/main/README.md",
},
{
name: "gitlab nested groups",
sourceURL: "https://gitlab.com/group/subgroup/repo",
branch: "main",
want: "https://gitlab.com/group/subgroup/repo/-/raw/main/README.md",
},
// Tangled
{
name: "tangled main",
sourceURL: "https://tangled.org/evan.jarrett.net/at-container-registry",
branch: "main",
want: "https://tangled.org/evan.jarrett.net/at-container-registry/raw/main/README.md",
},
{
name: "tangled legacy @ prefix",
sourceURL: "https://tangled.org/@user/repo",
branch: "main",
want: "https://tangled.org/user/repo/raw/main/README.md",
},
// Unsupported
{
name: "unsupported platform",
sourceURL: "https://bitbucket.org/user/repo",
branch: "main",
want: "",
},
{
name: "empty url",
sourceURL: "",
branch: "main",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := DeriveReadmeURL(tt.sourceURL, tt.branch)
if got != tt.want {
t.Errorf("DeriveReadmeURL(%q, %q) = %q, want %q", tt.sourceURL, tt.branch, got, tt.want)
}
})
}
}

View File

@@ -12,22 +12,24 @@ import (
"atcr.io/pkg/appview/middleware"
"atcr.io/pkg/appview/readme"
"atcr.io/pkg/auth/oauth"
indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
"github.com/go-chi/chi/v5"
)
// UIDependencies contains all dependencies needed for UI route registration
type UIDependencies struct {
Database *sql.DB
ReadOnlyDB *sql.DB
SessionStore *db.SessionStore
OAuthApp *oauth.App
OAuthStore *db.OAuthStore
Refresher *oauth.Refresher
BaseURL string
DeviceStore *db.DeviceStore
HealthChecker *holdhealth.Checker
ReadmeCache *readme.Cache
Templates *template.Template
Database *sql.DB
ReadOnlyDB *sql.DB
SessionStore *db.SessionStore
OAuthClientApp *indigooauth.ClientApp
OAuthStore *db.OAuthStore
Refresher *oauth.Refresher
BaseURL string
DeviceStore *db.DeviceStore
HealthChecker *holdhealth.Checker
ReadmeFetcher *readme.Fetcher
Templates *template.Template
DefaultHoldDID string // For UserContext creation
}
// RegisterUIRoutes registers all web UI and API routes on the provided router
@@ -35,6 +37,14 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
// Extract trimmed registry URL for templates
registryURL := trimRegistryURL(deps.BaseURL)
// Create web auth dependencies for middleware (enables UserContext in web routes)
webAuthDeps := middleware.WebAuthDeps{
SessionStore: deps.SessionStore,
Database: deps.Database,
Refresher: deps.Refresher,
DefaultHoldDID: deps.DefaultHoldDID,
}
// OAuth login routes (public)
router.Get("/auth/oauth/login", (&uihandlers.LoginHandler{
Templates: deps.Templates,
@@ -44,7 +54,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
// Public routes (with optional auth for navbar)
// SECURITY: Public pages use read-only DB
router.Get("/", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
router.Get("/", middleware.OptionalAuthWithDeps(webAuthDeps)(
&uihandlers.HomeHandler{
DB: deps.ReadOnlyDB,
Templates: deps.Templates,
@@ -52,7 +62,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
},
).ServeHTTP)
router.Get("/api/recent-pushes", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
router.Get("/api/recent-pushes", middleware.OptionalAuthWithDeps(webAuthDeps)(
&uihandlers.RecentPushesHandler{
DB: deps.ReadOnlyDB,
Templates: deps.Templates,
@@ -62,7 +72,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
).ServeHTTP)
// SECURITY: Search uses read-only DB to prevent writes and limit access to sensitive tables
router.Get("/search", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
router.Get("/search", middleware.OptionalAuthWithDeps(webAuthDeps)(
&uihandlers.SearchHandler{
DB: deps.ReadOnlyDB,
Templates: deps.Templates,
@@ -70,7 +80,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
},
).ServeHTTP)
router.Get("/api/search-results", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
router.Get("/api/search-results", middleware.OptionalAuthWithDeps(webAuthDeps)(
&uihandlers.SearchResultsHandler{
DB: deps.ReadOnlyDB,
Templates: deps.Templates,
@@ -79,7 +89,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
).ServeHTTP)
// Install page (public)
router.Get("/install", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
router.Get("/install", middleware.OptionalAuthWithDeps(webAuthDeps)(
&uihandlers.InstallHandler{
Templates: deps.Templates,
RegistryURL: registryURL,
@@ -87,43 +97,43 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
).ServeHTTP)
// API route for repository stats (public, read-only)
router.Get("/api/stats/{handle}/{repository}", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
router.Get("/api/stats/{handle}/{repository}", middleware.OptionalAuthWithDeps(webAuthDeps)(
&uihandlers.GetStatsHandler{
DB: deps.ReadOnlyDB,
Directory: deps.OAuthApp.Directory(),
Directory: deps.OAuthClientApp.Dir,
},
).ServeHTTP)
// API routes for stars (require authentication)
router.Post("/api/stars/{handle}/{repository}", middleware.RequireAuth(deps.SessionStore, deps.Database)(
router.Post("/api/stars/{handle}/{repository}", middleware.RequireAuthWithDeps(webAuthDeps)(
&uihandlers.StarRepositoryHandler{
DB: deps.Database, // Needs write access
Directory: deps.OAuthApp.Directory(),
Directory: deps.OAuthClientApp.Dir,
Refresher: deps.Refresher,
},
).ServeHTTP)
router.Delete("/api/stars/{handle}/{repository}", middleware.RequireAuth(deps.SessionStore, deps.Database)(
router.Delete("/api/stars/{handle}/{repository}", middleware.RequireAuthWithDeps(webAuthDeps)(
&uihandlers.UnstarRepositoryHandler{
DB: deps.Database, // Needs write access
Directory: deps.OAuthApp.Directory(),
Directory: deps.OAuthClientApp.Dir,
Refresher: deps.Refresher,
},
).ServeHTTP)
router.Get("/api/stars/{handle}/{repository}", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
router.Get("/api/stars/{handle}/{repository}", middleware.OptionalAuthWithDeps(webAuthDeps)(
&uihandlers.CheckStarHandler{
DB: deps.ReadOnlyDB, // Read-only check
Directory: deps.OAuthApp.Directory(),
Directory: deps.OAuthClientApp.Dir,
Refresher: deps.Refresher,
},
).ServeHTTP)
// Manifest detail API endpoint
router.Get("/api/manifests/{handle}/{repository}/{digest}", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
router.Get("/api/manifests/{handle}/{repository}/{digest}", middleware.OptionalAuthWithDeps(webAuthDeps)(
&uihandlers.ManifestDetailHandler{
DB: deps.ReadOnlyDB,
Directory: deps.OAuthApp.Directory(),
Directory: deps.OAuthClientApp.Dir,
},
).ServeHTTP)
@@ -132,7 +142,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
HealthChecker: deps.HealthChecker,
}).ServeHTTP)
router.Get("/u/{handle}", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
router.Get("/u/{handle}", middleware.OptionalAuthWithDeps(webAuthDeps)(
&uihandlers.UserPageHandler{
DB: deps.ReadOnlyDB,
Templates: deps.Templates,
@@ -140,21 +150,32 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
},
).ServeHTTP)
router.Get("/r/{handle}/{repository}", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
// OpenGraph image generation (public, cacheable)
router.Get("/og/home", (&uihandlers.DefaultOGHandler{}).ServeHTTP)
router.Get("/og/u/{handle}", (&uihandlers.UserOGHandler{
DB: deps.ReadOnlyDB,
}).ServeHTTP)
router.Get("/og/r/{handle}/{repository}", (&uihandlers.RepoOGHandler{
DB: deps.ReadOnlyDB,
}).ServeHTTP)
router.Get("/r/{handle}/{repository}", middleware.OptionalAuthWithDeps(webAuthDeps)(
&uihandlers.RepositoryPageHandler{
DB: deps.ReadOnlyDB,
Templates: deps.Templates,
RegistryURL: registryURL,
Directory: deps.OAuthApp.Directory(),
Directory: deps.OAuthClientApp.Dir,
Refresher: deps.Refresher,
HealthChecker: deps.HealthChecker,
ReadmeCache: deps.ReadmeCache,
ReadmeFetcher: deps.ReadmeFetcher,
},
).ServeHTTP)
// Authenticated routes
router.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(deps.SessionStore, deps.Database))
r.Use(middleware.RequireAuthWithDeps(webAuthDeps))
r.Get("/settings", (&uihandlers.SettingsHandler{
Templates: deps.Templates,
@@ -176,6 +197,11 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
Refresher: deps.Refresher,
}).ServeHTTP)
r.Post("/api/images/{repository}/avatar", (&uihandlers.UploadAvatarHandler{
DB: deps.Database,
Refresher: deps.Refresher,
}).ServeHTTP)
// Device approval page (authenticated)
r.Get("/device", (&uihandlers.DeviceApprovalPageHandler{
Store: deps.DeviceStore,
@@ -200,15 +226,21 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
})
// Logout endpoint (supports both GET and POST)
// Properly revokes OAuth tokens on PDS side before clearing local session
// Only clears the current UI session cookie - does NOT revoke OAuth tokens
// OAuth sessions remain intact so other browser tabs/devices stay logged in
logoutHandler := &uihandlers.LogoutHandler{
OAuthApp: deps.OAuthApp,
Refresher: deps.Refresher,
SessionStore: deps.SessionStore,
OAuthStore: deps.OAuthStore,
}
router.Get("/auth/logout", logoutHandler.ServeHTTP)
router.Post("/auth/logout", logoutHandler.ServeHTTP)
// Custom 404 handler
router.NotFound(middleware.OptionalAuthWithDeps(webAuthDeps)(
&uihandlers.NotFoundHandler{
Templates: deps.Templates,
RegistryURL: registryURL,
},
).ServeHTTP)
}
// CORSMiddleware returns a middleware that sets CORS headers for API endpoints

View File

@@ -38,6 +38,10 @@
--version-badge-text: #7b1fa2;
--version-badge-border: #ba68c8;
/* Attestation badge */
--attestation-badge-bg: #d1fae5;
--attestation-badge-text: #065f46;
/* Hero section colors */
--hero-bg-start: #f8f9fa;
--hero-bg-end: #e9ecef;
@@ -90,6 +94,10 @@
--version-badge-text: #ffffff;
--version-badge-border: #ba68c8;
/* Attestation badge */
--attestation-badge-bg: #065f46;
--attestation-badge-text: #6ee7b7;
/* Hero section colors */
--hero-bg-start: #2d2d2d;
--hero-bg-end: #1a1a1a;
@@ -109,7 +117,9 @@
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
background: var(--bg);
color: var(--fg);
line-height: 1.6;
@@ -170,7 +180,7 @@ body {
}
.nav-links a:hover {
background:var(--secondary);
background: var(--secondary);
border-radius: 4px;
}
@@ -193,7 +203,7 @@ body {
}
.user-menu-btn:hover {
background:var(--secondary);
background: var(--secondary);
}
.user-avatar {
@@ -266,7 +276,7 @@ body {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
background:var(--bg);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: var(--shadow-lg);
@@ -287,7 +297,7 @@ body {
color: var(--fg);
text-decoration: none;
border: none;
background:var(--bg);
background: var(--bg);
cursor: pointer;
transition: background 0.2s;
font-size: 0.95rem;
@@ -309,7 +319,10 @@ body {
}
/* Buttons */
button, .btn, .btn-primary, .btn-secondary {
button,
.btn,
.btn-primary,
.btn-secondary {
padding: 0.5rem 1rem;
background: var(--button-primary);
color: var(--btn-text);
@@ -322,7 +335,10 @@ button, .btn, .btn-primary, .btn-secondary {
transition: opacity 0.2s;
}
button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
button:hover,
.btn:hover,
.btn-primary:hover,
.btn-secondary:hover {
opacity: 0.9;
}
@@ -393,12 +409,13 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
}
/* Cards */
.push-card, .repository-card {
.push-card,
.repository-card {
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
background:var(--bg);
background: var(--bg);
box-shadow: var(--shadow-sm);
}
@@ -449,7 +466,7 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
}
.digest {
font-family: 'Monaco', 'Courier New', monospace;
font-family: "Monaco", "Courier New", monospace;
font-size: 0.85rem;
background: var(--code-bg);
padding: 0.1rem 0.3rem;
@@ -492,7 +509,7 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
}
.docker-command-text {
font-family: 'Monaco', 'Courier New', monospace;
font-family: "Monaco", "Courier New", monospace;
font-size: 0.85rem;
color: var(--fg);
flex: 0 1 auto;
@@ -510,7 +527,9 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
border-radius: 4px;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
transition:
opacity 0.2s,
visibility 0.2s;
}
.docker-command:hover .copy-btn {
@@ -752,7 +771,7 @@ a.license-badge:hover {
}
.repo-stats {
color:var(--border-dark);
color: var(--border-dark);
font-size: 0.9rem;
display: flex;
gap: 0.5rem;
@@ -781,17 +800,20 @@ a.license-badge:hover {
padding-top: 1rem;
}
.tags-section, .manifests-section {
.tags-section,
.manifests-section {
margin-bottom: 1.5rem;
}
.tags-section h3, .manifests-section h3 {
.tags-section h3,
.manifests-section h3 {
font-size: 1.1rem;
margin-bottom: 0.5rem;
color: var(--secondary);
}
.tag-row, .manifest-row {
.tag-row,
.manifest-row {
display: flex;
gap: 1rem;
align-items: center;
@@ -799,7 +821,8 @@ a.license-badge:hover {
border-bottom: 1px solid var(--border);
}
.tag-row:last-child, .manifest-row:last-child {
.tag-row:last-child,
.manifest-row:last-child {
border-bottom: none;
}
@@ -821,7 +844,7 @@ a.license-badge:hover {
}
.settings-section {
background:var(--bg);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
@@ -918,7 +941,7 @@ a.license-badge:hover {
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
font-family: 'Monaco', 'Courier New', monospace;
font-family: "Monaco", "Courier New", monospace;
font-size: 0.85rem;
border: 1px solid var(--border);
}
@@ -1004,13 +1027,6 @@ a.license-badge:hover {
margin: 1rem 0;
}
/* Load More Button */
.load-more {
width: 100%;
margin-top: 1rem;
background: var(--secondary);
}
/* Login Page */
.login-page {
max-width: 450px;
@@ -1031,7 +1047,7 @@ a.license-badge:hover {
}
.login-form {
background:var(--bg);
background: var(--bg);
padding: 2rem;
border-radius: 8px;
border: 1px solid var(--border);
@@ -1083,6 +1099,98 @@ a.license-badge:hover {
text-decoration: underline;
}
/* Login Typeahead */
.login-form .form-group {
position: relative;
}
.typeahead-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 4px 4px;
box-shadow: var(--shadow-md);
max-height: 300px;
overflow-y: auto;
z-index: 1000;
margin-top: -1px;
}
.typeahead-header {
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--secondary);
border-bottom: 1px solid var(--border);
}
.typeahead-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
cursor: pointer;
transition: background-color 0.15s ease;
border-bottom: 1px solid var(--border);
}
.typeahead-item:last-child {
border-bottom: none;
}
.typeahead-item:hover,
.typeahead-item.typeahead-focused {
background: var(--hover-bg);
border-left: 3px solid var(--primary);
padding-left: calc(0.75rem - 3px);
}
.typeahead-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.typeahead-text {
flex: 1;
min-width: 0;
}
.typeahead-displayname {
font-weight: 500;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.typeahead-handle {
font-size: 0.875rem;
color: var(--secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.typeahead-recent .typeahead-handle {
font-size: 1rem;
color: var(--text);
}
.typeahead-loading {
padding: 0.75rem;
text-align: center;
color: var(--secondary);
font-size: 0.875rem;
}
/* Repository Page */
.repository-page {
/* Let container's max-width (1200px) control page width */
@@ -1090,7 +1198,7 @@ a.license-badge:hover {
}
.repository-header {
background:var(--bg);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 2rem;
@@ -1128,6 +1236,35 @@ a.license-badge:hover {
flex-shrink: 0;
}
.repo-hero-icon-wrapper {
position: relative;
display: inline-block;
flex-shrink: 0;
}
.avatar-upload-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
border-radius: 12px;
opacity: 0;
cursor: pointer;
transition: opacity 0.2s ease;
}
.avatar-upload-overlay i {
color: white;
width: 24px;
height: 24px;
}
.repo-hero-icon-wrapper:hover .avatar-upload-overlay {
opacity: 1;
}
.repo-hero-info {
flex: 1;
}
@@ -1198,7 +1335,7 @@ a.license-badge:hover {
}
.star-btn.starred {
border-color:var(--star);
border-color: var(--star);
background: var(--code-bg);
}
@@ -1282,7 +1419,7 @@ a.license-badge:hover {
}
.repo-section {
background:var(--bg);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
@@ -1297,20 +1434,23 @@ a.license-badge:hover {
border-bottom: 2px solid var(--border);
}
.tags-list, .manifests-list {
.tags-list,
.manifests-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.tag-item, .manifest-item {
.tag-item,
.manifest-item {
border: 1px solid var(--border);
border-radius: 6px;
padding: 1rem;
background: var(--hover-bg);
}
.tag-item-header, .manifest-item-header {
.tag-item-header,
.manifest-item-header {
display: flex;
justify-content: space-between;
align-items: center;
@@ -1440,7 +1580,7 @@ a.license-badge:hover {
color: var(--fg);
border: 1px solid var(--border);
white-space: nowrap;
font-family: 'Monaco', 'Courier New', monospace;
font-family: "Monaco", "Courier New", monospace;
}
.platforms-inline {
@@ -1475,6 +1615,26 @@ a.license-badge:hover {
font-style: italic;
}
.badge-attestation {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem 0.6rem;
background: var(--attestation-badge-bg);
color: var(--attestation-badge-text);
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
margin-left: 0.5rem;
vertical-align: middle;
white-space: nowrap;
}
.badge-attestation .lucide {
width: 0.75rem;
height: 0.75rem;
}
/* Featured Repositories Section */
.featured-section {
margin-bottom: 3rem;
@@ -1625,7 +1785,11 @@ a.license-badge:hover {
/* Hero Section */
.hero-section {
background: linear-gradient(135deg, var(--hero-bg-start) 0%, var(--hero-bg-end) 100%);
background: linear-gradient(
135deg,
var(--hero-bg-start) 0%,
var(--hero-bg-end) 100%
);
padding: 4rem 2rem;
border-bottom: 1px solid var(--border);
}
@@ -1690,7 +1854,7 @@ a.license-badge:hover {
.terminal-content {
padding: 1.5rem;
margin: 0;
font-family: 'Monaco', 'Courier New', monospace;
font-family: "Monaco", "Courier New", monospace;
font-size: 0.95rem;
line-height: 1.8;
color: var(--terminal-text);
@@ -1846,7 +2010,7 @@ a.license-badge:hover {
}
.code-block code {
font-family: 'Monaco', 'Menlo', monospace;
font-family: "Monaco", "Menlo", monospace;
font-size: 0.9rem;
line-height: 1.5;
white-space: pre-wrap;
@@ -1903,7 +2067,8 @@ a.license-badge:hover {
flex-wrap: wrap;
}
.tag-row, .manifest-row {
.tag-row,
.manifest-row {
flex-wrap: wrap;
}
@@ -1992,7 +2157,7 @@ a.license-badge:hover {
/* README and Repository Layout */
.repo-content-layout {
display: grid;
grid-template-columns: 7fr 3fr;
grid-template-columns: 6fr 4fr;
gap: 2rem;
margin-top: 2rem;
}
@@ -2103,7 +2268,8 @@ a.license-badge:hover {
background: var(--code-bg);
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-family:
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.9em;
}
@@ -2207,3 +2373,59 @@ a.license-badge:hover {
padding: 0.75rem;
}
}
/* 404 Error Page */
.error-page {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 60px);
text-align: center;
padding: 2rem;
}
.error-content {
max-width: 480px;
}
.error-icon {
width: 80px;
height: 80px;
color: var(--secondary);
margin-bottom: 1.5rem;
}
.error-code {
font-size: 8rem;
font-weight: 700;
color: var(--primary);
line-height: 1;
margin-bottom: 0.5rem;
}
.error-content h1 {
font-size: 2rem;
margin-bottom: 0.75rem;
color: var(--fg);
}
.error-content p {
font-size: 1.125rem;
color: var(--secondary);
margin-bottom: 2rem;
}
@media (max-width: 768px) {
.error-code {
font-size: 5rem;
}
.error-icon {
width: 60px;
height: 60px;
}
.error-content h1 {
font-size: 1.5rem;
}
}

View File

@@ -434,6 +434,69 @@ function removeManifestElement(sanitizedId) {
}
}
// Upload repository avatar
async function uploadAvatar(input, repository) {
const file = input.files[0];
if (!file) return;
// Client-side validation
const validTypes = ['image/png', 'image/jpeg', 'image/webp'];
if (!validTypes.includes(file.type)) {
alert('Please select a PNG, JPEG, or WebP image');
return;
}
if (file.size > 3 * 1024 * 1024) {
alert('Image must be less than 3MB');
return;
}
const formData = new FormData();
formData.append('avatar', file);
try {
const response = await fetch(`/api/images/${repository}/avatar`, {
method: 'POST',
credentials: 'include',
body: formData
});
if (response.status === 401) {
window.location.href = '/auth/oauth/login';
return;
}
if (!response.ok) {
const error = await response.text();
throw new Error(error);
}
const data = await response.json();
// Update the avatar image on the page
const wrapper = document.querySelector('.repo-hero-icon-wrapper');
if (!wrapper) return;
const existingImg = wrapper.querySelector('.repo-hero-icon');
const placeholder = wrapper.querySelector('.repo-hero-icon-placeholder');
if (existingImg) {
existingImg.src = data.avatarURL;
} else if (placeholder) {
const newImg = document.createElement('img');
newImg.src = data.avatarURL;
newImg.alt = repository;
newImg.className = 'repo-hero-icon';
placeholder.replaceWith(newImg);
}
} catch (err) {
console.error('Error uploading avatar:', err);
alert('Failed to upload avatar: ' + err.message);
}
// Clear input so same file can be selected again
input.value = '';
}
// Close modal when clicking outside
document.addEventListener('DOMContentLoaded', () => {
const modal = document.getElementById('manifest-delete-modal');
@@ -445,3 +508,283 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
});
// Login page typeahead functionality
class LoginTypeahead {
constructor(inputElement) {
this.input = inputElement;
this.dropdown = null;
this.debounceTimer = null;
this.currentFocus = -1;
this.results = [];
this.isLoading = false;
this.init();
}
init() {
// Create dropdown element
this.createDropdown();
// Event listeners
this.input.addEventListener('input', (e) => this.handleInput(e));
this.input.addEventListener('keydown', (e) => this.handleKeydown(e));
this.input.addEventListener('focus', () => this.handleFocus());
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!this.input.contains(e.target) && !this.dropdown.contains(e.target)) {
this.hideDropdown();
}
});
}
createDropdown() {
this.dropdown = document.createElement('div');
this.dropdown.className = 'typeahead-dropdown';
this.dropdown.style.display = 'none';
this.input.parentNode.insertBefore(this.dropdown, this.input.nextSibling);
}
handleInput(e) {
const value = e.target.value.trim();
// Clear debounce timer
clearTimeout(this.debounceTimer);
if (value.length < 2) {
this.showRecentAccounts();
return;
}
// Debounce API call (200ms)
this.debounceTimer = setTimeout(() => {
this.searchActors(value);
}, 200);
}
handleFocus() {
const value = this.input.value.trim();
if (value.length < 2) {
this.showRecentAccounts();
}
}
async searchActors(query) {
this.isLoading = true;
this.showLoading();
try {
const url = `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=3`;
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to fetch suggestions');
}
const data = await response.json();
this.results = data.actors || [];
this.renderResults();
} catch (err) {
console.error('Typeahead error:', err);
this.hideDropdown();
} finally {
this.isLoading = false;
}
}
showLoading() {
this.dropdown.innerHTML = '<div class="typeahead-loading">Searching...</div>';
this.dropdown.style.display = 'block';
}
renderResults() {
if (this.results.length === 0) {
this.hideDropdown();
return;
}
this.dropdown.innerHTML = '';
this.currentFocus = -1;
this.results.slice(0, 3).forEach((actor, index) => {
const item = this.createResultItem(actor, index);
this.dropdown.appendChild(item);
});
this.dropdown.style.display = 'block';
}
createResultItem(actor, index) {
const item = document.createElement('div');
item.className = 'typeahead-item';
item.dataset.index = index;
item.dataset.handle = actor.handle;
// Avatar
const avatar = document.createElement('img');
avatar.className = 'typeahead-avatar';
avatar.src = actor.avatar || '/static/images/default-avatar.png';
avatar.alt = actor.handle;
avatar.onerror = () => {
avatar.src = '/static/images/default-avatar.png';
};
// Text container
const textContainer = document.createElement('div');
textContainer.className = 'typeahead-text';
// Display name
const displayName = document.createElement('div');
displayName.className = 'typeahead-displayname';
displayName.textContent = actor.displayName || actor.handle;
// Handle
const handle = document.createElement('div');
handle.className = 'typeahead-handle';
handle.textContent = `@${actor.handle}`;
textContainer.appendChild(displayName);
textContainer.appendChild(handle);
item.appendChild(avatar);
item.appendChild(textContainer);
// Click handler
item.addEventListener('click', () => this.selectItem(actor.handle));
return item;
}
showRecentAccounts() {
const recent = this.getRecentAccounts();
if (recent.length === 0) {
this.hideDropdown();
return;
}
this.dropdown.innerHTML = '';
this.currentFocus = -1;
const header = document.createElement('div');
header.className = 'typeahead-header';
header.textContent = 'Recent accounts';
this.dropdown.appendChild(header);
recent.forEach((handle, index) => {
const item = document.createElement('div');
item.className = 'typeahead-item typeahead-recent';
item.dataset.index = index;
item.dataset.handle = handle;
const textContainer = document.createElement('div');
textContainer.className = 'typeahead-text';
const handleDiv = document.createElement('div');
handleDiv.className = 'typeahead-handle';
handleDiv.textContent = handle;
textContainer.appendChild(handleDiv);
item.appendChild(textContainer);
item.addEventListener('click', () => this.selectItem(handle));
this.dropdown.appendChild(item);
});
this.dropdown.style.display = 'block';
}
selectItem(handle) {
this.input.value = handle;
this.hideDropdown();
this.saveRecentAccount(handle);
// Optionally submit the form automatically
// this.input.form.submit();
}
hideDropdown() {
this.dropdown.style.display = 'none';
this.currentFocus = -1;
}
handleKeydown(e) {
// If dropdown is hidden, only respond to ArrowDown to show it
if (this.dropdown.style.display === 'none') {
if (e.key === 'ArrowDown') {
e.preventDefault();
const value = this.input.value.trim();
if (value.length >= 2) {
this.searchActors(value);
} else {
this.showRecentAccounts();
}
}
return;
}
const items = this.dropdown.querySelectorAll('.typeahead-item');
if (e.key === 'ArrowDown') {
e.preventDefault();
this.currentFocus++;
if (this.currentFocus >= items.length) this.currentFocus = 0;
this.updateFocus(items);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.currentFocus--;
if (this.currentFocus < 0) this.currentFocus = items.length - 1;
this.updateFocus(items);
} else if (e.key === 'Enter') {
if (this.currentFocus > -1 && items[this.currentFocus]) {
e.preventDefault();
const handle = items[this.currentFocus].dataset.handle;
this.selectItem(handle);
}
} else if (e.key === 'Escape') {
this.hideDropdown();
}
}
updateFocus(items) {
items.forEach((item, index) => {
if (index === this.currentFocus) {
item.classList.add('typeahead-focused');
} else {
item.classList.remove('typeahead-focused');
}
});
}
getRecentAccounts() {
try {
const recent = localStorage.getItem('atcr_recent_handles');
return recent ? JSON.parse(recent) : [];
} catch {
return [];
}
}
saveRecentAccount(handle) {
try {
let recent = this.getRecentAccounts();
// Remove if already exists
recent = recent.filter(h => h !== handle);
// Add to front
recent.unshift(handle);
// Keep only last 5
recent = recent.slice(0, 5);
localStorage.setItem('atcr_recent_handles', JSON.stringify(recent));
} catch (err) {
console.error('Failed to save recent account:', err);
}
}
}
// Initialize typeahead on login page
document.addEventListener('DOMContentLoaded', () => {
const handleInput = document.getElementById('handle');
if (handleInput && handleInput.closest('.login-form')) {
new LoginTypeahead(handleInput);
}
});

View File

@@ -6,9 +6,11 @@ $ErrorActionPreference = "Stop"
# Configuration
$BinaryName = "docker-credential-atcr.exe"
$InstallDir = if ($env:ATCR_INSTALL_DIR) { $env:ATCR_INSTALL_DIR } else { "$env:ProgramFiles\ATCR" }
$Version = "v0.0.1"
$TagHash = "c6cfbaf1723123907f9d23e300f6f72081e65006"
$TangledRepo = "https://tangled.org/@evan.jarrett.net/at-container-registry"
$ApiUrl = if ($env:ATCR_API_URL) { $env:ATCR_API_URL } else { "https://atcr.io/api/credential-helper/version" }
# Fallback configuration (used if API is unavailable)
$FallbackVersion = "v0.0.1"
$FallbackTangledRepo = "https://tangled.org/@evan.jarrett.net/at-container-registry"
Write-Host "ATCR Credential Helper Installer for Windows" -ForegroundColor Green
Write-Host ""
@@ -17,8 +19,8 @@ Write-Host ""
function Get-Architecture {
$arch = (Get-WmiObject Win32_Processor).Architecture
switch ($arch) {
9 { return "x86_64" } # x64
12 { return "arm64" } # ARM64
9 { return @{ Display = "x86_64"; Key = "amd64" } } # x64
12 { return @{ Display = "arm64"; Key = "arm64" } } # ARM64
default {
Write-Host "Unsupported architecture: $arch" -ForegroundColor Red
exit 1
@@ -26,35 +28,81 @@ function Get-Architecture {
}
}
$Arch = Get-Architecture
$ArchInfo = Get-Architecture
$Arch = $ArchInfo.Display
$ArchKey = $ArchInfo.Key
$PlatformKey = "windows_$ArchKey"
Write-Host "Detected: Windows $Arch" -ForegroundColor Green
# Fetch version info from API
function Get-VersionInfo {
Write-Host "Fetching latest version info..." -ForegroundColor Yellow
try {
$response = Invoke-WebRequest -Uri $ApiUrl -UseBasicParsing -TimeoutSec 10
$json = $response.Content | ConvertFrom-Json
if ($json.latest -and $json.download_urls.$PlatformKey) {
return @{
Version = $json.latest
DownloadUrl = $json.download_urls.$PlatformKey
}
}
} catch {
Write-Host "API unavailable, using fallback version" -ForegroundColor Yellow
}
return $null
}
# Get download URL for fallback
function Get-FallbackUrl {
param([string]$Version, [string]$Arch)
$versionClean = $Version.TrimStart('v')
# Note: Windows builds use .zip format
$fileName = "docker-credential-atcr_${versionClean}_Windows_${Arch}.zip"
return "$FallbackTangledRepo/tags/$Version/download/$fileName"
}
# Determine version and download URL
$Version = $null
$DownloadUrl = $null
if ($env:ATCR_VERSION) {
$Version = $env:ATCR_VERSION
$DownloadUrl = Get-FallbackUrl -Version $Version -Arch $Arch
Write-Host "Using specified version: $Version" -ForegroundColor Yellow
} else {
Write-Host "Using version: $Version" -ForegroundColor Green
$versionInfo = Get-VersionInfo
if ($versionInfo) {
$Version = $versionInfo.Version
$DownloadUrl = $versionInfo.DownloadUrl
Write-Host "Found latest version: $Version" -ForegroundColor Green
} else {
$Version = $FallbackVersion
$DownloadUrl = Get-FallbackUrl -Version $Version -Arch $Arch
Write-Host "Using fallback version: $Version" -ForegroundColor Yellow
}
}
Write-Host "Installing version: $Version" -ForegroundColor Green
# Download and install binary
function Install-Binary {
param (
[string]$Version,
[string]$Arch
[string]$DownloadUrl
)
$versionClean = $Version.TrimStart('v')
$fileName = "docker-credential-atcr_${versionClean}_Windows_${Arch}.zip"
$downloadUrl = "$TangledRepo/tags/$TagHash/download/$fileName"
Write-Host "Downloading from: $downloadUrl" -ForegroundColor Yellow
Write-Host "Downloading from: $DownloadUrl" -ForegroundColor Yellow
$tempDir = New-Item -ItemType Directory -Path "$env:TEMP\atcr-install-$(Get-Random)" -Force
$zipPath = Join-Path $tempDir $fileName
$zipPath = Join-Path $tempDir "docker-credential-atcr.zip"
try {
Invoke-WebRequest -Uri $downloadUrl -OutFile $zipPath -UseBasicParsing
Invoke-WebRequest -Uri $DownloadUrl -OutFile $zipPath -UseBasicParsing
} catch {
Write-Host "Failed to download release: $_" -ForegroundColor Red
exit 1
@@ -139,7 +187,7 @@ function Show-Configuration {
# Main installation flow
try {
Install-Binary -Version $Version -Arch $Arch
Install-Binary -DownloadUrl $DownloadUrl
Add-ToPath
Test-Installation
Show-Configuration

View File

@@ -13,9 +13,11 @@ NC='\033[0m' # No Color
# Configuration
BINARY_NAME="docker-credential-atcr"
INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}"
VERSION="v0.0.1"
TAG_HASH="c6cfbaf1723123907f9d23e300f6f72081e65006"
TANGLED_REPO="https://tangled.org/@evan.jarrett.net/at-container-registry"
API_URL="${ATCR_API_URL:-https://atcr.io/api/credential-helper/version}"
# Fallback configuration (used if API is unavailable)
FALLBACK_VERSION="v0.0.1"
FALLBACK_TANGLED_REPO="https://tangled.org/@evan.jarrett.net/at-container-registry"
# Detect OS and architecture
detect_platform() {
@@ -25,9 +27,11 @@ detect_platform() {
case "$os" in
linux*)
OS="Linux"
OS_KEY="linux"
;;
darwin*)
OS="Darwin"
OS_KEY="darwin"
;;
*)
echo -e "${RED}Unsupported OS: $os${NC}"
@@ -38,29 +42,69 @@ detect_platform() {
case "$arch" in
x86_64|amd64)
ARCH="x86_64"
ARCH_KEY="amd64"
;;
aarch64|arm64)
ARCH="arm64"
ARCH_KEY="arm64"
;;
*)
echo -e "${RED}Unsupported architecture: $arch${NC}"
exit 1
;;
esac
PLATFORM_KEY="${OS_KEY}_${ARCH_KEY}"
}
# Fetch version info from API
fetch_version_info() {
echo -e "${YELLOW}Fetching latest version info...${NC}"
# Try to fetch from API
local api_response
if api_response=$(curl -fsSL --max-time 10 "$API_URL" 2>/dev/null); then
# Parse JSON response (requires jq or basic parsing)
if command -v jq &> /dev/null; then
VERSION=$(echo "$api_response" | jq -r '.latest')
DOWNLOAD_URL=$(echo "$api_response" | jq -r ".download_urls.${PLATFORM_KEY}")
if [ "$VERSION" != "null" ] && [ "$DOWNLOAD_URL" != "null" ] && [ -n "$VERSION" ] && [ -n "$DOWNLOAD_URL" ]; then
echo -e "${GREEN}Found latest version: ${VERSION}${NC}"
return 0
fi
else
# Fallback: basic grep parsing if jq not available
VERSION=$(echo "$api_response" | grep -o '"latest":"[^"]*"' | cut -d'"' -f4)
# Try to extract the specific platform URL
DOWNLOAD_URL=$(echo "$api_response" | grep -o "\"${PLATFORM_KEY}\":\"[^\"]*\"" | cut -d'"' -f4)
if [ -n "$VERSION" ] && [ -n "$DOWNLOAD_URL" ]; then
echo -e "${GREEN}Found latest version: ${VERSION}${NC}"
return 0
fi
fi
fi
echo -e "${YELLOW}API unavailable, using fallback version${NC}"
return 1
}
# Set fallback download URL
use_fallback() {
VERSION="$FALLBACK_VERSION"
local version_without_v="${VERSION#v}"
DOWNLOAD_URL="${FALLBACK_TANGLED_REPO}/tags/${VERSION}/download/docker-credential-atcr_${version_without_v}_${OS}_${ARCH}.tar.gz"
}
# Download and install binary
install_binary() {
local version="${1:-$VERSION}"
local download_url="${TANGLED_REPO}/tags/${TAG_HASH}/download/docker-credential-atcr_${version#v}_${OS}_${ARCH}.tar.gz"
echo -e "${YELLOW}Downloading from: ${download_url}${NC}"
echo -e "${YELLOW}Downloading from: ${DOWNLOAD_URL}${NC}"
local tmp_dir=$(mktemp -d)
trap "rm -rf $tmp_dir" EXIT
if ! curl -fsSL "$download_url" -o "$tmp_dir/docker-credential-atcr.tar.gz"; then
if ! curl -fsSL "$DOWNLOAD_URL" -o "$tmp_dir/docker-credential-atcr.tar.gz"; then
echo -e "${RED}Failed to download release${NC}"
exit 1
fi
@@ -120,12 +164,18 @@ main() {
detect_platform
echo -e "Detected: ${GREEN}${OS} ${ARCH}${NC}"
# Allow specifying version via environment variable
if [ -z "$ATCR_VERSION" ]; then
echo -e "Using version: ${GREEN}${VERSION}${NC}"
else
# Check if version is manually specified
if [ -n "$ATCR_VERSION" ]; then
echo -e "Using specified version: ${GREEN}${ATCR_VERSION}${NC}"
VERSION="$ATCR_VERSION"
echo -e "Using specified version: ${GREEN}${VERSION}${NC}"
local version_without_v="${VERSION#v}"
DOWNLOAD_URL="${FALLBACK_TANGLED_REPO}/tags/${VERSION}/download/docker-credential-atcr_${version_without_v}_${OS}_${ARCH}.tar.gz"
else
# Try to fetch from API, fall back if unavailable
if ! fetch_version_info; then
use_fallback
fi
echo -e "Installing version: ${GREEN}${VERSION}${NC}"
fi
install_binary

View File

@@ -1,40 +0,0 @@
package storage
import (
"context"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
"atcr.io/pkg/auth/oauth"
)
// DatabaseMetrics interface for tracking pull/push counts
type DatabaseMetrics interface {
IncrementPullCount(did, repository string) error
IncrementPushCount(did, repository string) error
}
// ReadmeCache interface for README content caching
type ReadmeCache interface {
Get(ctx context.Context, url string) (string, error)
Invalidate(url string) error
}
// RegistryContext bundles all the context needed for registry operations
// This includes both per-request data (DID, hold) and shared services
type RegistryContext struct {
// Per-request identity and routing information
DID string // User's DID (e.g., "did:plc:abc123")
Handle string // User's handle (e.g., "alice.bsky.social")
HoldDID string // Hold service DID (e.g., "did:web:hold01.atcr.io")
PDSEndpoint string // User's PDS endpoint URL
Repository string // Image repository name (e.g., "debian")
ServiceToken string // Service token for hold authentication (cached by middleware)
ATProtoClient *atproto.Client // Authenticated ATProto client for this user
// Shared services (same for all requests)
Database DatabaseMetrics // Metrics tracking database
Authorizer auth.HoldAuthorizer // Hold access authorization
Refresher *oauth.Refresher // OAuth session manager
ReadmeCache ReadmeCache // README content cache
}

View File

@@ -1,123 +0,0 @@
package storage
import (
"context"
"testing"
"atcr.io/pkg/atproto"
)
// Mock implementations for testing
type mockDatabaseMetrics struct {
pullCount int
pushCount int
}
func (m *mockDatabaseMetrics) IncrementPullCount(did, repository string) error {
m.pullCount++
return nil
}
func (m *mockDatabaseMetrics) IncrementPushCount(did, repository string) error {
m.pushCount++
return nil
}
type mockReadmeCache struct{}
func (m *mockReadmeCache) Get(ctx context.Context, url string) (string, error) {
return "# Test README", nil
}
func (m *mockReadmeCache) Invalidate(url string) error {
return nil
}
type mockHoldAuthorizer struct{}
func (m *mockHoldAuthorizer) Authorize(holdDID, userDID, permission string) (bool, error) {
return true, nil
}
func TestRegistryContext_Fields(t *testing.T) {
// Create a sample RegistryContext
ctx := &RegistryContext{
DID: "did:plc:test123",
Handle: "alice.bsky.social",
HoldDID: "did:web:hold01.atcr.io",
PDSEndpoint: "https://bsky.social",
Repository: "debian",
ServiceToken: "test-token",
ATProtoClient: &atproto.Client{
// Mock client - would need proper initialization in real tests
},
Database: &mockDatabaseMetrics{},
ReadmeCache: &mockReadmeCache{},
}
// Verify fields are accessible
if ctx.DID != "did:plc:test123" {
t.Errorf("Expected DID %q, got %q", "did:plc:test123", ctx.DID)
}
if ctx.Handle != "alice.bsky.social" {
t.Errorf("Expected Handle %q, got %q", "alice.bsky.social", ctx.Handle)
}
if ctx.HoldDID != "did:web:hold01.atcr.io" {
t.Errorf("Expected HoldDID %q, got %q", "did:web:hold01.atcr.io", ctx.HoldDID)
}
if ctx.PDSEndpoint != "https://bsky.social" {
t.Errorf("Expected PDSEndpoint %q, got %q", "https://bsky.social", ctx.PDSEndpoint)
}
if ctx.Repository != "debian" {
t.Errorf("Expected Repository %q, got %q", "debian", ctx.Repository)
}
if ctx.ServiceToken != "test-token" {
t.Errorf("Expected ServiceToken %q, got %q", "test-token", ctx.ServiceToken)
}
}
func TestRegistryContext_DatabaseInterface(t *testing.T) {
db := &mockDatabaseMetrics{}
ctx := &RegistryContext{
Database: db,
}
// Test that interface methods are callable
err := ctx.Database.IncrementPullCount("did:plc:test", "repo")
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
err = ctx.Database.IncrementPushCount("did:plc:test", "repo")
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
}
func TestRegistryContext_ReadmeCacheInterface(t *testing.T) {
cache := &mockReadmeCache{}
ctx := &RegistryContext{
ReadmeCache: cache,
}
// Test that interface methods are callable
content, err := ctx.ReadmeCache.Get(context.Background(), "https://example.com/README.md")
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if content != "# Test README" {
t.Errorf("Expected content %q, got %q", "# Test README", content)
}
err = ctx.ReadmeCache.Invalidate("https://example.com/README.md")
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
}
// TODO: Add more comprehensive tests:
// - Test ATProtoClient integration
// - Test OAuth Refresher integration
// - Test HoldAuthorizer integration
// - Test nil handling for optional fields
// - Integration tests with real components

View File

@@ -1,88 +0,0 @@
package storage
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth/oauth"
"atcr.io/pkg/auth/token"
)
// EnsureCrewMembership attempts to register the user as a crew member on their default hold.
// The hold's requestCrew endpoint handles all authorization logic (checking allowAllCrew, existing membership, etc).
// This is best-effort and does not fail on errors.
func EnsureCrewMembership(ctx context.Context, client *atproto.Client, refresher *oauth.Refresher, defaultHoldDID string) {
if defaultHoldDID == "" {
return
}
// Normalize URL to DID if needed
holdDID := atproto.ResolveHoldDIDFromURL(defaultHoldDID)
if holdDID == "" {
slog.Warn("failed to resolve hold DID", "defaultHold", defaultHoldDID)
return
}
// Resolve hold DID to HTTP endpoint
holdEndpoint := atproto.ResolveHoldURL(holdDID)
// Get service token for the hold
// Only works with OAuth (refresher required) - app passwords can't get service tokens
if refresher == nil {
slog.Debug("skipping crew registration - no OAuth refresher (app password flow)", "holdDID", holdDID)
return
}
// Wrap the refresher to match OAuthSessionRefresher interface
serviceToken, err := token.GetOrFetchServiceToken(ctx, refresher, client.DID(), holdDID, client.PDSEndpoint())
if err != nil {
slog.Warn("failed to get service token", "holdDID", holdDID, "error", err)
return
}
// Call requestCrew endpoint - it handles all the logic:
// - Checks allowAllCrew flag
// - Checks if already a crew member (returns success if so)
// - Creates crew record if authorized
if err := requestCrewMembership(ctx, holdEndpoint, serviceToken); err != nil {
slog.Warn("failed to request crew membership", "holdDID", holdDID, "error", err)
return
}
slog.Info("successfully registered as crew member", "holdDID", holdDID, "userDID", client.DID())
}
// requestCrewMembership calls the hold's requestCrew endpoint
// The endpoint handles all authorization and duplicate checking internally
func requestCrewMembership(ctx context.Context, holdEndpoint, serviceToken string) error {
url := fmt.Sprintf("%s%s", holdEndpoint, atproto.HoldRequestCrew)
req, err := http.NewRequestWithContext(ctx, "POST", url, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+serviceToken)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
// Read response body to capture actual error message from hold
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return fmt.Errorf("requestCrew failed with status %d (failed to read error body: %w)", resp.StatusCode, readErr)
}
return fmt.Errorf("requestCrew failed with status %d: %s", resp.StatusCode, string(body))
}
return nil
}

View File

@@ -1,14 +0,0 @@
package storage
import (
"context"
"testing"
)
func TestEnsureCrewMembership_EmptyHoldDID(t *testing.T) {
// Test that empty hold DID returns early without error (best-effort function)
EnsureCrewMembership(context.Background(), nil, nil, "")
// If we get here without panic, test passes
}
// TODO: Add comprehensive tests with HTTP client mocking

View File

@@ -1,98 +0,0 @@
package storage
import (
"sync"
"time"
)
// HoldCache caches hold DIDs for (DID, repository) pairs
// This avoids expensive ATProto lookups on every blob request during pulls
//
// NOTE: This is a simple in-memory cache for MVP. For production deployments:
// - Use Redis or similar for distributed caching
// - Consider implementing cache size limits
// - Monitor memory usage under high load
type HoldCache struct {
mu sync.RWMutex
cache map[string]*holdCacheEntry
}
type holdCacheEntry struct {
holdDID string
expiresAt time.Time
}
var globalHoldCache = &HoldCache{
cache: make(map[string]*holdCacheEntry),
}
func init() {
// Start background cleanup goroutine
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
globalHoldCache.Cleanup()
}
}()
}
// GetGlobalHoldCache returns the global hold cache instance
func GetGlobalHoldCache() *HoldCache {
return globalHoldCache
}
// Set stores a hold DID for a (DID, repository) pair with a TTL
func (c *HoldCache) Set(did, repository, holdDID string, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
key := did + ":" + repository
c.cache[key] = &holdCacheEntry{
holdDID: holdDID,
expiresAt: time.Now().Add(ttl),
}
}
// Get retrieves a hold DID for a (DID, repository) pair
// Returns empty string and false if not found or expired
func (c *HoldCache) Get(did, repository string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
key := did + ":" + repository
entry, ok := c.cache[key]
if !ok {
return "", false
}
// Check if expired
if time.Now().After(entry.expiresAt) {
// Don't delete here (would need write lock), let cleanup handle it
return "", false
}
return entry.holdDID, true
}
// Cleanup removes expired entries (called automatically every 5 minutes)
func (c *HoldCache) Cleanup() {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
removed := 0
for key, entry := range c.cache {
if now.After(entry.expiresAt) {
delete(c.cache, key)
removed++
}
}
// Log cleanup stats for monitoring
if removed > 0 || len(c.cache) > 100 {
// Log if we removed entries OR if cache is growing large
// This helps identify if cache size is becoming a concern
println("Hold cache cleanup: removed", removed, "entries, remaining", len(c.cache))
}
}

View File

@@ -1,150 +0,0 @@
package storage
import (
"testing"
"time"
)
func TestHoldCache_SetAndGet(t *testing.T) {
cache := &HoldCache{
cache: make(map[string]*holdCacheEntry),
}
did := "did:plc:test123"
repo := "myapp"
holdDID := "did:web:hold01.atcr.io"
ttl := 10 * time.Minute
// Set a value
cache.Set(did, repo, holdDID, ttl)
// Get the value - should succeed
gotHoldDID, ok := cache.Get(did, repo)
if !ok {
t.Fatal("Expected Get to return true, got false")
}
if gotHoldDID != holdDID {
t.Errorf("Expected hold DID %q, got %q", holdDID, gotHoldDID)
}
}
func TestHoldCache_GetNonExistent(t *testing.T) {
cache := &HoldCache{
cache: make(map[string]*holdCacheEntry),
}
// Get non-existent value
_, ok := cache.Get("did:plc:nonexistent", "repo")
if ok {
t.Error("Expected Get to return false for non-existent key")
}
}
func TestHoldCache_ExpiredEntry(t *testing.T) {
cache := &HoldCache{
cache: make(map[string]*holdCacheEntry),
}
did := "did:plc:test123"
repo := "myapp"
holdDID := "did:web:hold01.atcr.io"
// Set with very short TTL
cache.Set(did, repo, holdDID, 10*time.Millisecond)
// Wait for expiration
time.Sleep(20 * time.Millisecond)
// Get should return false
_, ok := cache.Get(did, repo)
if ok {
t.Error("Expected Get to return false for expired entry")
}
}
func TestHoldCache_Cleanup(t *testing.T) {
cache := &HoldCache{
cache: make(map[string]*holdCacheEntry),
}
// Add multiple entries with different TTLs
cache.Set("did:plc:1", "repo1", "hold1", 10*time.Millisecond)
cache.Set("did:plc:2", "repo2", "hold2", 1*time.Hour)
cache.Set("did:plc:3", "repo3", "hold3", 10*time.Millisecond)
// Wait for some to expire
time.Sleep(20 * time.Millisecond)
// Run cleanup
cache.Cleanup()
// Verify expired entries are removed
if _, ok := cache.Get("did:plc:1", "repo1"); ok {
t.Error("Expected expired entry 1 to be removed")
}
if _, ok := cache.Get("did:plc:3", "repo3"); ok {
t.Error("Expected expired entry 3 to be removed")
}
// Verify non-expired entry remains
if _, ok := cache.Get("did:plc:2", "repo2"); !ok {
t.Error("Expected non-expired entry to remain")
}
}
func TestHoldCache_ConcurrentAccess(t *testing.T) {
cache := &HoldCache{
cache: make(map[string]*holdCacheEntry),
}
done := make(chan bool)
// Concurrent writes
for i := 0; i < 10; i++ {
go func(id int) {
did := "did:plc:concurrent"
repo := "repo" + string(rune(id))
holdDID := "hold" + string(rune(id))
cache.Set(did, repo, holdDID, 1*time.Minute)
done <- true
}(i)
}
// Concurrent reads
for i := 0; i < 10; i++ {
go func(id int) {
repo := "repo" + string(rune(id))
cache.Get("did:plc:concurrent", repo)
done <- true
}(i)
}
// Wait for all goroutines
for i := 0; i < 20; i++ {
<-done
}
}
func TestHoldCache_KeyFormat(t *testing.T) {
cache := &HoldCache{
cache: make(map[string]*holdCacheEntry),
}
did := "did:plc:test"
repo := "myrepo"
holdDID := "did:web:hold"
cache.Set(did, repo, holdDID, 1*time.Minute)
// Verify the key is stored correctly (did:repo)
expectedKey := did + ":" + repo
if _, exists := cache.cache[expectedKey]; !exists {
t.Errorf("Expected key %q to exist in cache", expectedKey)
}
}
// TODO: Add more comprehensive tests:
// - Test GetGlobalHoldCache()
// - Test cache size monitoring
// - Benchmark cache performance under load
// - Test cleanup goroutine timing

Some files were not shown because too many files have changed in this diff Show More