Files
at-container-registry/pkg/appview/storage/crew.go
T

135 lines
4.5 KiB
Go

package storage
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"time"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
)
// ServiceTokenFetcher returns a hold service token for the resolved holdDID.
// Implementations wire to the appropriate auth path (OAuth refresher or
// app-password access token).
type ServiceTokenFetcher func(ctx context.Context, holdDID string) (string, error)
// 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). On success, warms the approval cache and clears any cached
// denial. Best-effort: logs and returns on any error.
//
// fetchServiceToken is invoked only on cache miss. Pass nil to skip when no auth path
// is available (callers that just want the cache short-circuit behavior).
func EnsureCrewMembership(
ctx context.Context,
userDID string,
defaultHoldDID string,
authorizer auth.HoldAuthorizer,
fetchServiceToken ServiceTokenFetcher,
) {
if defaultHoldDID == "" {
return
}
// Normalize URL to DID if needed
holdDID, err := atproto.ResolveHoldDID(ctx, defaultHoldDID)
if err != nil {
slog.Warn("failed to resolve hold DID", "defaultHold", defaultHoldDID, "error", err)
return
}
// Short-circuit if we have a recent cached approval. The hold's requestCrew
// would just return success for an established crew member, so the round-trip
// is pure waste. Cache lookup uses the same key (holdDID, userDID) that
// CheckWriteAccess will hit later in the request.
if authorizer != nil {
if cached, err := authorizer.IsCachedCrewMember(ctx, holdDID, userDID); err == nil && cached {
slog.Debug("crew membership cached, skipping requestCrew",
"holdDID", holdDID, "userDID", userDID)
return
}
}
if fetchServiceToken == nil {
slog.Debug("skipping crew registration - no service token fetcher", "holdDID", holdDID, "userDID", userDID)
return
}
holdEndpoint, err := atproto.ResolveHoldURL(ctx, holdDID)
if err != nil {
slog.Warn("failed to resolve hold URL", "holdDID", holdDID, "error", err)
return
}
serviceToken, err := fetchServiceToken(ctx, holdDID)
if err != nil {
slog.Warn("failed to get service token", "holdDID", holdDID, "userDID", userDID, "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, "userDID", userDID, "error", err)
return
}
slog.Info("successfully registered as crew member", "holdDID", holdDID, "userDID", userDID)
if authorizer != nil {
// Warm the approval cache so subsequent CheckWriteAccess calls within this
// request (e.g. each layer in a multi-layer push) skip the XRPC getRecord.
if err := authorizer.RecordCrewApproval(ctx, holdDID, userDID); err != nil {
slog.Warn("failed to record crew approval after crew registration",
"holdDID", holdDID, "userDID", userDID, "error", err)
}
// Clear any cached denial to ensure immediate access
if err := authorizer.ClearCrewDenial(ctx, holdDID, userDID); err != nil {
slog.Warn("failed to clear denial cache after crew registration",
"holdDID", holdDID, "userDID", userDID, "error", err)
}
}
}
// 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 {
// Add 5 second timeout to prevent hanging on offline holds
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
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
}