Files
Evan Jarrett ab4a4ebf9d admin panel long running imrovements, billing fixes, ui cleanup
1. Multiple registry domains + per-user domain preference

The biggest feature. The appview can serve several registry domains (e.g. buoy.cr, atcr.io),
and users can now pick which one shows up in their pull/push commands.

- Lexicon/record: adds registryDomain (and documents ociClient)
to the sailor profile (lexicons/.../profile.json, pkg/atproto/lexicon.go).
- DB: new registry_domain column on users (schema.sql + migration 0027),
with GetUserByDID/Handle reads, UpdateUserRegistryDomain writer,
and Jetstream caching it on profile updates (writes unconditionally so clearing propagates).
- UI/handlers: new UpdateRegistryDomainHandler + /api/profile/registry-domain route,
a <select> in the user settings panel (only shown when >1 domain configured), and resolveRegistryURL()
which falls back to the primary domain if the user's pref is stale/removed. Tests added for all of it.

2. default_hold_did removed → first managed_holds entry is the default

Consolidates two overlapping config fields into one. ServerConfig.DefaultHoldDID is gone;
PrimaryHoldDID() now returns managed_holds[0]. managed_holds is now REQUIRED.
Updated in config, validation, server wiring, test harness, example YAML, and the deploy template.

3. Admin long-running operations → generic background-job framework

New pkg/hold/admin/jobs.go introduces a reusable startJob/jobRegistry pattern
 (a detached context.Background() job + a /admin/api/jobs/{key}/status polling endpoint).
This replaces the bespoke scan-backfill goroutine state machine, and now also wraps crew tier remap and crew import
all three previously looped synchronously on the request context and got 504'd/cancelled mid-run by the reverse proxy.
 Forms switched from POST-redirect to htmx fragments (job_progress.html, job_result.html, crew_import_results.html)
 the old crew_import_results.html page and scan_backfill_progress.html partial were deleted.
This is also captured as a new rule in CLAUDE.md.

4. Cascade-delete manifest on last-tag deletion

DeleteTagHandler now, after removing the last tag pointing to a digest, cascade-deletes the manifest itself
 (PDS + DB + hold blob purge) — but only if it's not a child of a manifest list (multi-arch parent).
 New GetTagDigest and ShouldCascadeDeleteManifest queries back it, plus cascade_delete_test.go.
 Also switches tag rkey computation to the atproto.RepositoryTagToRKey helper.

5. Billing simplification

Drops the OwnerBadge config option (hold-owner supporter badge).
The user-profile template no longer special-cases an "owner" badge value (only "Captain").
Example tiers renamed to the nautical scheme (deckhand/bosun/quartermaster).

6. Build/deploy: go generate always runs via Make

make generate is now a phony target that always runs go generate ./... (regenerating cbor_gen, icon sprites, etc.),
 and build-trixie depends on it. The deploy tooling (provision.go/update.go)
drops its own runGenerate calls since the Makefile handles it.

7. New cmd/firehose-tap tool (untracked)

A standalone CLI that subscribes to a com.atproto.sync.subscribeRepos endpoint and pretty-prints events,
with emphasis on Sync 1.1 compliance fields (per-op prev CIDs, commit prevData) and a --validate CI mode.
Fits with the recent "more sync1.1 compliant" commit.
2026-06-05 20:57:25 -05:00

396 lines
11 KiB
Go

// firehose-tap subscribes to an ATProto com.atproto.sync.subscribeRepos endpoint
// and pretty-prints every event it sees, with extra emphasis on Sync 1.1 fields
// (per-op prev CIDs, commit-level prevData) so you can verify a hold or relay
// is emitting compliant events.
//
// Typical use:
//
// # Watch new events as you trigger pushes/deletes on production
// go run ./cmd/firehose-tap wss://hold01.atcr.io
//
// # Replay from the beginning of the persisted buffer
// go run ./cmd/firehose-tap --cursor 0 wss://hold01.atcr.io
//
// # CI mode: subscribe for 30s, exit non-zero if any update/delete op is
// # missing prev (Sync 1.1 inductive-firehose requirement).
// go run ./cmd/firehose-tap --validate --duration 30s wss://hold01.atcr.io
//
// The endpoint may be a single hold or a relay. The wss:// scheme is required.
package main
import (
"context"
"errors"
"flag"
"fmt"
"net/http"
"net/url"
"os"
"os/signal"
"strings"
"sync/atomic"
"syscall"
"time"
comatproto "github.com/bluesky-social/indigo/api/atproto"
"github.com/bluesky-social/indigo/events"
"github.com/bluesky-social/indigo/events/schedulers/sequential"
"github.com/gorilla/websocket"
)
// ANSI color codes (disabled via --no-color or NO_COLOR env)
var (
cRed = "\033[31m"
cGreen = "\033[32m"
cYellow = "\033[33m"
cCyan = "\033[36m"
cBold = "\033[1m"
cDim = "\033[2m"
cReset = "\033[0m"
)
func disableColors() {
cRed, cGreen, cYellow, cCyan, cBold, cDim, cReset = "", "", "", "", "", "", ""
}
type config struct {
endpoint string
cursor int64
cursorSet bool
didFilter string
validate bool
duration time.Duration
maxEvents int64
verbose bool
noColor bool
}
// violationCounts tracks Sync 1.1 compliance issues seen so far.
type violationCounts struct {
missingPrev atomic.Int64 // update/delete op without prev
createWithPrev atomic.Int64 // create op with non-nil prev (spec forbids)
deleteWithCid atomic.Int64 // delete op with non-nil cid (spec forbids)
missingPrevData atomic.Int64 // commit with since but no prevData
}
func (v *violationCounts) total() int64 {
return v.missingPrev.Load() + v.createWithPrev.Load() + v.deleteWithCid.Load() + v.missingPrevData.Load()
}
func main() {
cfg := parseFlags()
if cfg.noColor || os.Getenv("NO_COLOR") != "" {
disableColors()
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
if cfg.duration > 0 {
var timeoutCancel context.CancelFunc
ctx, timeoutCancel = context.WithTimeout(ctx, cfg.duration)
defer timeoutCancel()
}
if err := run(ctx, cfg); err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
fmt.Fprintf(os.Stderr, "%sfirehose-tap: %v%s\n", cRed, err, cReset)
os.Exit(2)
}
}
func parseFlags() *config {
cfg := &config{}
flag.Int64Var(&cfg.cursor, "cursor", -1, "subscribeRepos cursor (-1 = new events only, 0 = replay from start)")
flag.StringVar(&cfg.didFilter, "did", "", "only show events for this DID (case-sensitive match on commit.repo)")
flag.BoolVar(&cfg.validate, "validate", false, "exit non-zero if any Sync 1.1 violation is observed")
flag.DurationVar(&cfg.duration, "duration", 0, "exit cleanly after this duration (e.g. 30s)")
flag.Int64Var(&cfg.maxEvents, "max-events", 0, "exit cleanly after seeing this many events (0 = unlimited)")
flag.BoolVar(&cfg.verbose, "verbose", false, "print full ops for create/no-prev-needed ops too")
flag.BoolVar(&cfg.noColor, "no-color", false, "disable ANSI colors")
// Detect whether --cursor was explicitly set so we can omit it from the URL
// when the user didn't pass one (default -1 means "live, no backfill").
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s [flags] <subscribeRepos-endpoint>\n\n", os.Args[0])
fmt.Fprintln(os.Stderr, "Endpoint examples:")
fmt.Fprintln(os.Stderr, " wss://hold01.atcr.io")
fmt.Fprintln(os.Stderr, " wss://relay1.us-east.bsky.network")
fmt.Fprintln(os.Stderr, " ws://localhost:8080")
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "Flags:")
flag.PrintDefaults()
}
flag.Parse()
flag.Visit(func(f *flag.Flag) {
if f.Name == "cursor" {
cfg.cursorSet = true
}
})
args := flag.Args()
if len(args) != 1 {
flag.Usage()
os.Exit(2)
}
cfg.endpoint = args[0]
return cfg
}
func run(ctx context.Context, cfg *config) error {
wsURL, err := buildWSURL(cfg.endpoint, cfg.cursor, cfg.cursorSet)
if err != nil {
return err
}
fmt.Fprintf(os.Stderr, "%sfirehose-tap%s connecting to %s%s%s\n", cBold, cReset, cCyan, wsURL, cReset)
if cfg.validate {
fmt.Fprintf(os.Stderr, "%svalidate mode: process will exit non-zero on Sync 1.1 violations%s\n", cDim, cReset)
}
header := http.Header{}
header.Set("User-Agent", "atcr-firehose-tap/1.0")
dialer := websocket.DefaultDialer
dialer.HandshakeTimeout = 30 * time.Second
conn, resp, err := dialer.DialContext(ctx, wsURL, header)
if err != nil {
if resp != nil {
return fmt.Errorf("dial %s: %w (HTTP %d)", wsURL, err, resp.StatusCode)
}
return fmt.Errorf("dial %s: %w", wsURL, err)
}
defer conn.Close()
v := &violationCounts{}
var eventsSeen atomic.Int64
callbacks := &events.RepoStreamCallbacks{
RepoCommit: func(evt *comatproto.SyncSubscribeRepos_Commit) error {
n := eventsSeen.Add(1)
if cfg.didFilter == "" || cfg.didFilter == evt.Repo {
printCommit(evt, v, cfg)
}
if cfg.maxEvents > 0 && n >= cfg.maxEvents {
return errMaxEvents
}
return nil
},
RepoIdentity: func(evt *comatproto.SyncSubscribeRepos_Identity) error {
eventsSeen.Add(1)
if cfg.didFilter == "" || cfg.didFilter == evt.Did {
printIdentity(evt)
}
return nil
},
RepoAccount: func(evt *comatproto.SyncSubscribeRepos_Account) error {
eventsSeen.Add(1)
if cfg.didFilter == "" || cfg.didFilter == evt.Did {
printAccount(evt)
}
return nil
},
RepoSync: func(evt *comatproto.SyncSubscribeRepos_Sync) error {
eventsSeen.Add(1)
if cfg.didFilter == "" || cfg.didFilter == evt.Did {
printSync(evt)
}
return nil
},
RepoInfo: func(evt *comatproto.SyncSubscribeRepos_Info) error {
fmt.Printf("%s#info%s name=%s message=%s\n", cYellow, cReset, evt.Name, derefStr(evt.Message))
return nil
},
Error: func(evt *events.ErrorFrame) error {
fmt.Printf("%s#error%s %s: %s\n", cRed, cReset, evt.Error, evt.Message)
return nil
},
}
sched := sequential.NewScheduler("firehose-tap", callbacks.EventHandler)
streamErr := events.HandleRepoStream(ctx, conn, sched, nil)
if errors.Is(streamErr, errMaxEvents) {
streamErr = nil
}
printSummary(eventsSeen.Load(), v, cfg)
if cfg.validate && v.total() > 0 {
os.Exit(1)
}
return streamErr
}
// errMaxEvents is returned from the commit handler to short-circuit
// HandleRepoStream once --max-events has been reached.
var errMaxEvents = errors.New("max events reached")
func buildWSURL(endpoint string, cursor int64, cursorSet bool) (string, error) {
if !strings.Contains(endpoint, "://") {
endpoint = "wss://" + endpoint
}
u, err := url.Parse(endpoint)
if err != nil {
return "", fmt.Errorf("parse endpoint: %w", err)
}
switch u.Scheme {
case "wss", "ws":
// already a websocket URL
case "https":
u.Scheme = "wss"
case "http":
u.Scheme = "ws"
default:
return "", fmt.Errorf("unsupported scheme %q (use ws:// or wss://)", u.Scheme)
}
if !strings.Contains(u.Path, "subscribeRepos") {
u.Path = strings.TrimRight(u.Path, "/") + "/xrpc/com.atproto.sync.subscribeRepos"
}
if cursorSet {
q := u.Query()
q.Set("cursor", fmt.Sprintf("%d", cursor))
u.RawQuery = q.Encode()
}
return u.String(), nil
}
func printCommit(evt *comatproto.SyncSubscribeRepos_Commit, v *violationCounts, cfg *config) {
prevData := "(none)"
if evt.PrevData != nil {
prevData = shortCID(evt.PrevData.String())
}
since := "(none)"
if evt.Since != nil {
since = *evt.Since
}
// Spec: commits beyond the very first one should carry prevData.
if evt.Since != nil && evt.PrevData == nil {
v.missingPrevData.Add(1)
}
fmt.Printf("%s#commit%s seq=%d repo=%s rev=%s since=%s prevData=%s ops=%d\n",
cGreen, cReset, evt.Seq, evt.Repo, evt.Rev, since, prevData, len(evt.Ops))
for _, op := range evt.Ops {
printOp(op, v, cfg)
}
}
func printOp(op *comatproto.SyncSubscribeRepos_RepoOp, v *violationCounts, cfg *config) {
action := op.Action
cidStr := "(nil)"
if op.Cid != nil {
cidStr = shortCID(op.Cid.String())
}
prevStr := "(nil)"
if op.Prev != nil {
prevStr = shortCID(op.Prev.String())
}
violated := false
var noteParts []string
switch action {
case "update", "delete":
if op.Prev == nil {
v.missingPrev.Add(1)
violated = true
noteParts = append(noteParts, "MISSING prev (Sync 1.1)")
}
case "create":
if op.Prev != nil {
v.createWithPrev.Add(1)
violated = true
noteParts = append(noteParts, "create MUST NOT have prev")
}
}
if action == "delete" && op.Cid != nil {
v.deleteWithCid.Add(1)
violated = true
noteParts = append(noteParts, "delete MUST have nil cid")
}
// In non-verbose mode, only print ops that updated/deleted (the interesting
// Sync 1.1 cases) or that violated a rule.
if !cfg.verbose && action == "create" && !violated {
return
}
actionColor := cCyan
if violated {
actionColor = cRed
}
note := ""
if len(noteParts) > 0 {
note = fmt.Sprintf(" %s[%s]%s", cRed, strings.Join(noteParts, "; "), cReset)
}
fmt.Printf(" %s%-6s%s %s cid=%s prev=%s%s\n",
actionColor, action, cReset, op.Path, cidStr, prevStr, note)
}
func printIdentity(evt *comatproto.SyncSubscribeRepos_Identity) {
handle := "(nil)"
if evt.Handle != nil {
handle = *evt.Handle
}
fmt.Printf("%s#identity%s seq=%d did=%s handle=%s\n",
cYellow, cReset, evt.Seq, evt.Did, handle)
}
func printAccount(evt *comatproto.SyncSubscribeRepos_Account) {
status := "(nil)"
if evt.Status != nil {
status = *evt.Status
}
fmt.Printf("%s#account%s seq=%d did=%s active=%v status=%s\n",
cYellow, cReset, evt.Seq, evt.Did, evt.Active, status)
}
func printSync(evt *comatproto.SyncSubscribeRepos_Sync) {
fmt.Printf("%s#sync%s seq=%d did=%s rev=%s\n",
cYellow, cReset, evt.Seq, evt.Did, evt.Rev)
}
func printSummary(seen int64, v *violationCounts, cfg *config) {
fmt.Fprintln(os.Stderr)
fmt.Fprintf(os.Stderr, "%s--- summary ---%s\n", cBold, cReset)
fmt.Fprintf(os.Stderr, "events seen: %d\n", seen)
if v.total() == 0 {
fmt.Fprintf(os.Stderr, "%sno Sync 1.1 violations%s\n", cGreen, cReset)
return
}
fmt.Fprintf(os.Stderr, "%sviolations:%s\n", cRed, cReset)
if n := v.missingPrev.Load(); n > 0 {
fmt.Fprintf(os.Stderr, " %d update/delete op(s) missing prev\n", n)
}
if n := v.createWithPrev.Load(); n > 0 {
fmt.Fprintf(os.Stderr, " %d create op(s) with non-nil prev\n", n)
}
if n := v.deleteWithCid.Load(); n > 0 {
fmt.Fprintf(os.Stderr, " %d delete op(s) with non-nil cid\n", n)
}
if n := v.missingPrevData.Load(); n > 0 {
fmt.Fprintf(os.Stderr, " %d commit(s) with since but missing prevData\n", n)
}
if !cfg.validate {
fmt.Fprintf(os.Stderr, "%s(--validate not set, exiting 0 anyway)%s\n", cDim, cReset)
}
}
func shortCID(s string) string {
if len(s) <= 16 {
return s
}
return s[:8] + "…" + s[len(s)-6:]
}
func derefStr(s *string) string {
if s == nil {
return ""
}
return *s
}