Files
at-container-registry/pkg/hold/pds/crew.go
T
Evan JarrettandClaude Opus 5 12c55ed560 billing: make Stripe webhook delivery idempotent and retryable
Webhook delivery was neither idempotent nor order-safe, and every failure
returned 400, which Stripe does not retry. A transient DB or hold error
therefore dropped a subscription change silently and permanently.

  - New stripe_processed_events table: event_id as primary key dedups
    redelivery, and event_created per customer drops stale out-of-order
    deliveries.
  - HandleWebhook distinguishes ErrWebhookSignature (400, no retry) from
    ErrWebhookProcessing (500, Stripe redelivers). The event handlers
    return errors instead of swallowing them. ErrBillingDisabled maps to
    400: the route is mounted but billing is off, so redelivery can never
    succeed and Stripe should stop rather than retry to exhaustion.
  - Refuse to boot when billing is enabled with an empty
    STRIPE_WEBHOOK_SECRET. Stripe HMACs with the empty key, so an
    attacker can reproduce the signature and the endpoint is forgeable.
  - UpdateCrewTierOnAllHolds retries each hold (3 attempts, linear
    backoff, 5s per request) and returns a joined error so the webhook
    can fail and let Stripe redeliver.

The fan-out contacts holds concurrently rather than in sequence. Serially,
one unreachable hold burns the caller's entire 10s budget on its own
retries (3 x 5s plus backoff) and the holds after it are never contacted;
because Stripe redelivers in the same order, a persistently-down first
hold means the rest are never updated at all.

On the hold, the signature-validated sub claim is now the source of truth
for updateCrewTier: a mismatched body userDid is rejected with 403 rather
than retargeting the grant to another DID. "Not crew on this hold" is a
200 no-op, since the appview fans updates out to every managed hold and a
subscriber is not crew everywhere.

That no-op has to be told apart from a storage failure. GetCrewMember
collapsed both into one generic error, so a CAR-store failure read as
"not a member", answered 200, and let the appview record the event as
processed — losing the tier grant permanently, which is exactly the
failure mode this commit exists to prevent. Missing records now carry an
ErrCrewMemberNotFound sentinel, and anything else returns 500 so Stripe
redelivers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 21:14:58 -05:00

214 lines
7.0 KiB
Go

package pds
import (
"bytes"
"context"
"errors"
"fmt"
"strings"
"time"
"atcr.io/pkg/atproto"
"github.com/bluesky-social/indigo/mst"
"github.com/bluesky-social/indigo/repo"
"github.com/ipfs/go-cid"
)
// ErrCrewMemberNotFound reports that this hold's repo has no crew record for the
// member. It is deliberately distinct from a storage failure: callers that treat
// "not crew" as a benign no-op must not swallow a CAR-store error the same way,
// or a failed lookup becomes indistinguishable from a genuine non-member.
var ErrCrewMemberNotFound = errors.New("crew member not found")
// AddCrewMember adds a new crew member to the hold and commits to carstore
// Uses deterministic rkey based on member DID hash for O(1) lookups and automatic deduplication
// If the member already exists, updates their record (upsert behavior)
func (p *HoldPDS) AddCrewMember(ctx context.Context, memberDID, role string, permissions []string, tier string) (cid.Cid, error) {
crewRecord := &atproto.CrewRecord{
Type: atproto.CrewCollection,
Member: memberDID,
Role: role,
Permissions: permissions,
Tier: tier,
AddedAt: time.Now().Format(time.RFC3339),
}
// Use deterministic rkey based on member DID hash
// UpsertRecord handles create-or-update automatically
rkey := atproto.CrewRecordKey(memberDID)
_, recordCID, _, err := p.repomgr.UpsertRecord(ctx, p.uid, atproto.CrewCollection, rkey, crewRecord)
if err != nil {
return cid.Undef, fmt.Errorf("failed to upsert crew record: %w", err)
}
return recordCID, nil
}
// GetCrewMember retrieves a crew member by their record key
func (p *HoldPDS) GetCrewMember(ctx context.Context, rkey string) (cid.Cid, *atproto.CrewRecord, error) {
// Use repomgr.GetRecord - our types are registered in init()
recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, atproto.CrewCollection, rkey, cid.Undef)
if err != nil {
if errors.Is(err, mst.ErrNotFound) {
return cid.Undef, nil, fmt.Errorf("%w: %s", ErrCrewMemberNotFound, rkey)
}
return cid.Undef, nil, fmt.Errorf("failed to get crew record: %w", err)
}
// Type assert to our concrete type
crewRecord, ok := val.(*atproto.CrewRecord)
if !ok {
return cid.Undef, nil, fmt.Errorf("unexpected type for crew record: %T", val)
}
return recordCID, crewRecord, nil
}
// GetCrewMemberByDID retrieves a crew member by their DID using O(1) lookup
// Uses deterministic rkey based on member DID hash
func (p *HoldPDS) GetCrewMemberByDID(ctx context.Context, memberDID string) (cid.Cid, *atproto.CrewRecord, error) {
rkey := atproto.CrewRecordKey(memberDID)
return p.GetCrewMember(ctx, rkey)
}
// CrewMemberWithKey pairs a crew record with its rkey and CID
type CrewMemberWithKey struct {
Rkey string
Cid cid.Cid
Record *atproto.CrewRecord
}
// ListCrewMembers returns all crew members with their rkeys
func (p *HoldPDS) ListCrewMembers(ctx context.Context) ([]*CrewMemberWithKey, error) {
var crew []*CrewMemberWithKey
// Create read-only session for ForEach access
// repomgr doesn't expose ForEach, so we need direct repo access
session, err := p.carstore.ReadOnlySession(p.uid)
if err != nil {
return nil, fmt.Errorf("failed to create read-only session: %w", err)
}
// Get repo head
head, err := p.carstore.GetUserRepoHead(ctx, p.uid)
if err != nil {
return nil, fmt.Errorf("failed to get repo head: %w", err)
}
if !head.Defined() {
return nil, fmt.Errorf("repo not initialized")
}
// Open repo
r, err := repo.OpenRepo(ctx, session, head)
if err != nil {
return nil, fmt.Errorf("failed to open repo: %w", err)
}
// Iterate over all crew records
err = r.ForEach(ctx, atproto.CrewCollection, func(k string, v cid.Cid) error {
// Extract collection and rkey from full path (k is like "io.atcr.hold.crew/3m37dr2ddit22")
parts := strings.Split(k, "/")
if len(parts) < 2 {
return nil // Skip invalid keys
}
// Extract actual collection and rkey
actualCollection := strings.Join(parts[:len(parts)-1], "/")
rkey := parts[len(parts)-1]
// MST keys are sorted, so once we hit a different collection, stop walking
if actualCollection != atproto.CrewCollection {
return repo.ErrDoneIterating
}
// Get the record directly from the repo we already have open
// (calling GetCrewMember would open a new session unnecessarily)
recordCID, recBytes, err := r.GetRecordBytes(ctx, k)
if err != nil {
return fmt.Errorf("failed to get crew record: %w", err)
}
// Unmarshal the CBOR bytes into our concrete type
var crewRecord atproto.CrewRecord
if err := crewRecord.UnmarshalCBOR(bytes.NewReader(*recBytes)); err != nil {
return fmt.Errorf("failed to decode crew record: %w", err)
}
crew = append(crew, &CrewMemberWithKey{
Rkey: rkey,
Cid: recordCID,
Record: &crewRecord,
})
return nil
})
if err != nil {
// ErrDoneIterating is expected when we stop walking early
// Use errors.Is to handle wrapped errors (indigo wraps with %w in MST walk)
if errors.Is(err, repo.ErrDoneIterating) {
// Successfully stopped at collection boundary
} else if strings.Contains(err.Error(), "not found") {
// If the collection doesn't exist yet (empty repo or no records created),
// return empty list instead of error
return []*CrewMemberWithKey{}, nil
} else {
return nil, fmt.Errorf("failed to list crew members: %w", err)
}
}
return crew, nil
}
// RemoveCrewMember removes a crew member by rkey
func (p *HoldPDS) RemoveCrewMember(ctx context.Context, rkey string) error {
// Use repomgr.DeleteRecord - it will automatically commit!
// This fixes the bug where deletions weren't being committed
err := p.repomgr.DeleteRecord(ctx, p.uid, atproto.CrewCollection, rkey)
if err != nil {
return fmt.Errorf("failed to delete crew record: %w", err)
}
return nil
}
// RemoveCrewMemberByDID removes a crew member by their DID using O(1) lookup
func (p *HoldPDS) RemoveCrewMemberByDID(ctx context.Context, memberDID string) error {
rkey := atproto.CrewRecordKey(memberDID)
return p.RemoveCrewMember(ctx, rkey)
}
// UpdateCrewMemberTier updates a crew member's tier
// Uses O(1) lookup via hash-based rkey and PutRecord for atomic upsert
func (p *HoldPDS) UpdateCrewMemberTier(ctx context.Context, memberDID, tier string) error {
// O(1) lookup using hash-based rkey
_, existing, err := p.GetCrewMemberByDID(ctx, memberDID)
if err != nil {
return fmt.Errorf("crew member not found: %w", err)
}
// If tier is already the same, no update needed
if existing.Tier == tier {
return nil
}
// Create updated record (UpdateRecord handles in-place update with same rkey)
newRecord := &atproto.CrewRecord{
Type: atproto.CrewCollection,
Member: existing.Member,
Role: existing.Role,
Permissions: existing.Permissions,
Tier: tier,
Plankowner: existing.Plankowner, // Preserve early adopter flag
AddedAt: existing.AddedAt, // Preserve original add time
}
rkey := atproto.CrewRecordKey(memberDID)
_, err = p.repomgr.UpdateRecord(ctx, p.uid, atproto.CrewCollection, rkey, newRecord)
if err != nil {
return fmt.Errorf("failed to update crew record: %w", err)
}
return nil
}