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

105 lines
2.3 KiB
Go

package handlers
import "testing"
func TestResolveRegistryURL(t *testing.T) {
primary := "buoy.cr"
domains := []string{"buoy.cr", "atcr.io"}
tests := []struct {
name string
domains []string
pref string
want string
}{
{"empty pref falls back to primary", domains, "", "buoy.cr"},
{"valid pref is used", domains, "atcr.io", "atcr.io"},
{"pref equal to primary", domains, "buoy.cr", "buoy.cr"},
{"stale pref not in list falls back", domains, "gone.example", "buoy.cr"},
{"nil domains falls back", nil, "atcr.io", "buoy.cr"},
{"empty domains falls back", []string{}, "atcr.io", "buoy.cr"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveRegistryURL(primary, tt.domains, tt.pref)
if got != tt.want {
t.Errorf("resolveRegistryURL(%q, %v, %q) = %q, want %q", primary, tt.domains, tt.pref, got, tt.want)
}
})
}
}
func TestTrimRegistryURL(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "https prefix",
input: "https://atcr.io",
expected: "atcr.io",
},
{
name: "http prefix",
input: "http://atcr.io",
expected: "atcr.io",
},
{
name: "no prefix",
input: "atcr.io",
expected: "atcr.io",
},
{
name: "with port https",
input: "https://localhost:5000",
expected: "localhost:5000",
},
{
name: "with port http",
input: "http://registry.example.com:443",
expected: "registry.example.com:443",
},
{
name: "empty string",
input: "",
expected: "",
},
{
name: "with path",
input: "https://atcr.io/v2/",
expected: "atcr.io/v2/",
},
{
name: "IP address https",
input: "https://127.0.0.1:5000",
expected: "127.0.0.1:5000",
},
{
name: "IP address http",
input: "http://192.168.1.1",
expected: "192.168.1.1",
},
{
name: "only http://",
input: "http://",
expected: "",
},
{
name: "only https://",
input: "https://",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := TrimRegistryURL(tt.input)
if result != tt.expected {
t.Errorf("TrimRegistryURL(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}