carlos / platform Public

domains: SetClaimCapabilities — the one writer of wildcard/catchall

Paul Campbell 9e7ecf98d2d6e67f83172f24d581f487db064523 parent 8f36377
2 files changed, +106 −0
  • internal/domains/claims.go +46 −0
  • internal/domains/claims_test.go +60 −0
diff --git a/internal/domains/claims.go b/internal/domains/claims.go
index 7b00329c4..7cdb7dacc 100644
--- a/internal/domains/claims.go
+++ b/internal/domains/claims.go
@@ -249,3 +249,49 @@ func HostDenied(host string, deny []string) bool {
}
return false
}
+
+// ErrCatchallNeedsWildcard is returned by SetClaimCapabilities for a
+// catchall request without wildcard: a catch-all nobody can serve TLS for
+// is a config error, not a state — refused at the write path, never stored.
+var ErrCatchallNeedsWildcard = errors.New("catchall requires wildcard (no cert could serve the catch-all)")
+
+// SetClaimCapabilities is the ONLY writer of a claim's Wildcard/Catchall
+// fields. AttachClaim's converge deliberately repoints Route and nothing
+// else — it can preserve these fields but never set them (#151's ruling) —
+// so capability changes go through this CAS read-modify-write, which
+// preserves every other field. Owner-gated: a caller who does not hold the
+// claim gets the same neutral ErrHostUnavailable the attach door uses, and
+// a missing claim answers identically (no existence oracle).
+func SetClaimCapabilities(ctx context.Context, s release.Store, host, account, app string, wildcard, catchall bool) (changed bool, err error) {
+ if catchall && !wildcard {
+ return false, ErrCatchallNeedsWildcard
+ }
+ for attempt := 0; ; attempt++ {
+ cur, etag, err := getClaimETag(ctx, s, host)
+ if errors.Is(err, os.ErrNotExist) {
+ return false, ErrHostUnavailable
+ } else if err != nil {
+ return false, err
+ }
+ if cur.Account != account || cur.App != app {
+ return false, ErrHostUnavailable
+ }
+ if cur.Wildcard == wildcard && cur.Catchall == catchall {
+ return false, nil
+ }
+ next := *cur
+ next.Wildcard = wildcard
+ next.Catchall = catchall
+ body, err := json.Marshal(next)
+ if err != nil {
+ return false, err
+ }
+ if _, err := s.Put(ctx, ClaimKey(host), body, etag); err == nil {
+ return true, nil
+ } else if errors.Is(err, release.ErrCASConflict) && attempt < 3 {
+ continue
+ } else {
+ return false, err
+ }
+ }
+}
diff --git a/internal/domains/claims_test.go b/internal/domains/claims_test.go
index 078f10ed4..72513ef18 100644
--- a/internal/domains/claims_test.go
+++ b/internal/domains/claims_test.go
@@ -235,6 +235,66 @@ func TestNormalizeAliasHost(t *testing.T) {
}
}
+func TestSetClaimCapabilities(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if _, _, err := AttachClaim(ctx, s, Claim{Host: "woodstar.app", Account: "bab", App: "woodstar-relay", Route: "woodstar-relay.bab.oncarlos.com", CreatedAt: "2026-08-13T00:00:00Z"}); err != nil {
+ t.Fatal(err)
+ }
+
+ // Set both capabilities.
+ changed, err := SetClaimCapabilities(ctx, s, "woodstar.app", "bab", "woodstar-relay", true, true)
+ if err != nil || !changed {
+ t.Fatalf("set: changed=%v err=%v", changed, err)
+ }
+ got, err := GetClaim(ctx, s, "woodstar.app")
+ if err != nil || !got.Wildcard || !got.Catchall {
+ t.Fatalf("stored claim: %+v err=%v", got, err)
+ }
+ // Route, CreatedAt survive the capability write.
+ if got.Route != "woodstar-relay.bab.oncarlos.com" || got.CreatedAt != "2026-08-13T00:00:00Z" {
+ t.Fatalf("capability write must preserve Route+CreatedAt: %+v", got)
+ }
+
+ // Idempotent: same values = no write.
+ changed, err = SetClaimCapabilities(ctx, s, "woodstar.app", "bab", "woodstar-relay", true, true)
+ if err != nil || changed {
+ t.Fatalf("no-op set: changed=%v err=%v", changed, err)
+ }
+
+ // Turning both off works and reports changed.
+ changed, err = SetClaimCapabilities(ctx, s, "woodstar.app", "bab", "woodstar-relay", false, false)
+ if err != nil || !changed {
+ t.Fatalf("unset: changed=%v err=%v", changed, err)
+ }
+}
+
+func TestSetClaimCapabilitiesRefusals(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if _, _, err := AttachClaim(ctx, s, Claim{Host: "woodstar.app", Account: "bab", App: "woodstar-relay", CreatedAt: "2026-08-13T00:00:00Z"}); err != nil {
+ t.Fatal(err)
+ }
+
+ // Catchall without wildcard: refused, never stored.
+ if _, err := SetClaimCapabilities(ctx, s, "woodstar.app", "bab", "woodstar-relay", false, true); !errors.Is(err, ErrCatchallNeedsWildcard) {
+ t.Fatalf("want ErrCatchallNeedsWildcard, got %v", err)
+ }
+ if got, _ := GetClaim(ctx, s, "woodstar.app"); got.Catchall {
+ t.Fatal("refused catchall must not be stored")
+ }
+
+ // Wrong owner: neutral refusal, same error the attach door uses.
+ if _, err := SetClaimCapabilities(ctx, s, "woodstar.app", "bad", "other-app", true, false); !errors.Is(err, ErrHostUnavailable) {
+ t.Fatalf("want ErrHostUnavailable, got %v", err)
+ }
+
+ // No claim at all: neutral, not a bare not-found (no existence oracle).
+ if _, err := SetClaimCapabilities(ctx, s, "unclaimed.example", "bab", "woodstar-relay", true, false); !errors.Is(err, ErrHostUnavailable) {
+ t.Fatalf("want ErrHostUnavailable for missing claim, got %v", err)
+ }
+}
+
func TestHostDenied(t *testing.T) {
deny := []string{"oncarlos.com"}
cases := []struct {