carlos / platform Public

domainclaim: interim claim record package (agreed #151 shape)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell f9a8fec88d4476523efed01414712707c4560d7f parent 0e88198
2 files changed, +167 −0
  • internal/domainclaim/domainclaim.go +95 −0
  • internal/domainclaim/domainclaim_test.go +72 −0
diff --git a/internal/domainclaim/domainclaim.go b/internal/domainclaim/domainclaim.go
new file mode 100644
index 000000000..b0987ad13
--- /dev/null
+++ b/internal/domainclaim/domainclaim.go
@@ -0,0 +1,95 @@
+// Package domainclaim reads and writes the global custom-domain claim at
+// domains/<host>.json — the create-only record whose successful create IS
+// the fleet-wide uniqueness check for an attached hostname (first writer
+// wins; a lost create surfaces release.ErrCASConflict and the door answers
+// "host unavailable" without naming the holder).
+//
+// INTERIM (#82/#151 cross-session agreement, 2026-08-12): #151's branch
+// (worktree-route-hostnames) owns the canonical claim writer; this package
+// is the agreed shape built here so #82's doors can proceed, and it is
+// REPLACED by #151's export when the branches merge. The shape below is
+// frozen by that agreement — do not add fields here.
+package domainclaim
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// Claim is the wire shape at domains/<host>.json. Route names the primary
+// route this host aliases; empty Route means the host is (or will be) its
+// own primary route — the first-route static case.
+type Claim struct {
+ Schema int `json:"schema"`
+ Host string `json:"host"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Route string `json:"route,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ CreatedBy string `json:"created_by,omitempty"`
+}
+
+const prefix = "domains/"
+
+// Key is the bucket key for a host's claim.
+func Key(host string) string { return prefix + host + ".json" }
+
+// Create writes the claim create-only: a lost race (any existing claim,
+// whoever holds it) is release.ErrCASConflict, never an overwrite.
+func Create(ctx context.Context, s release.Store, c Claim) error {
+ body, err := json.MarshalIndent(c, "", " ")
+ if err != nil {
+ return err
+ }
+ _, err = s.Put(ctx, Key(c.Host), body, "")
+ return err
+}
+
+// Get reads one claim and its etag.
+func Get(ctx context.Context, s release.Store, host string) (Claim, string, error) {
+ body, etag, err := s.Get(ctx, Key(host))
+ if err != nil {
+ return Claim{}, "", err
+ }
+ var c Claim
+ if err := json.Unmarshal(body, &c); err != nil {
+ return Claim{}, "", fmt.Errorf("claim %s: malformed: %w", Key(host), err)
+ }
+ return c, etag, nil
+}
+
+// Delete releases a claim. Store.Delete is idempotent, so releasing an
+// already-gone claim is not an error.
+func Delete(ctx context.Context, s release.Store, host string) error {
+ return s.Delete(ctx, Key(host))
+}
+
+// ListForApp returns the claims held by one account+app pair. Claims that
+// fail to parse are skipped: a malformed claim is #151's reconciler's
+// problem to surface, not a reason to blank an app's Settings list.
+func ListForApp(ctx context.Context, s release.Store, account, app string) ([]Claim, error) {
+ keys, err := s.List(ctx, prefix)
+ if err != nil {
+ return nil, err
+ }
+ var out []Claim
+ for _, k := range keys {
+ if !strings.HasSuffix(k, ".json") {
+ continue
+ }
+ host := strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json")
+ c, _, err := Get(ctx, s, host)
+ if err != nil {
+ continue
+ }
+ if c.Account == account && c.App == app {
+ out = append(out, c)
+ }
+ }
+ return out, nil
+}
diff --git a/internal/domainclaim/domainclaim_test.go b/internal/domainclaim/domainclaim_test.go
new file mode 100644
index 000000000..ca890d286
--- /dev/null
+++ b/internal/domainclaim/domainclaim_test.go
@@ -0,0 +1,72 @@
+// internal/domainclaim/domainclaim_test.go
+package domainclaim
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestCreateIsCreateOnly(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ c := Claim{Schema: 1, Host: "www.example.com", Account: "bdj", App: "story", CreatedAt: time.Unix(1755000000, 0).UTC(), CreatedBy: "cli:paul@keymail.dev"}
+ if err := Create(ctx, s, c); err != nil {
+ t.Fatalf("first create: %v", err)
+ }
+ err := Create(ctx, s, Claim{Schema: 1, Host: "www.example.com", Account: "bab", App: "other", CreatedAt: time.Unix(1755000001, 0).UTC()})
+ if !errors.Is(err, release.ErrCASConflict) {
+ t.Fatalf("second create: got %v, want ErrCASConflict", err)
+ }
+}
+
+func TestGetRoundTripsAndKeyShape(t *testing.T) {
+ if got := Key("www.example.com"); got != "domains/www.example.com.json" {
+ t.Fatalf("Key = %q", got)
+ }
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ in := Claim{Schema: 1, Host: "example.com", Account: "bdj", App: "story", Route: "story.bdj.oncarlos.com", CreatedAt: time.Unix(1755000000, 0).UTC()}
+ if err := Create(ctx, s, in); err != nil {
+ t.Fatal(err)
+ }
+ out, etag, err := Get(ctx, s, "example.com")
+ if err != nil || etag == "" {
+ t.Fatalf("Get: %v etag=%q", err, etag)
+ }
+ if out != in {
+ t.Fatalf("round trip: got %+v want %+v", out, in)
+ }
+}
+
+func TestListForAppFiltersOtherOwners(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ for _, c := range []Claim{
+ {Schema: 1, Host: "a.example.com", Account: "bdj", App: "story", CreatedAt: time.Unix(1755000000, 0).UTC()},
+ {Schema: 1, Host: "b.example.com", Account: "bdj", App: "blog", CreatedAt: time.Unix(1755000000, 0).UTC()},
+ {Schema: 1, Host: "c.example.com", Account: "bab", App: "story", CreatedAt: time.Unix(1755000000, 0).UTC()},
+ } {
+ if err := Create(ctx, s, c); err != nil {
+ t.Fatal(err)
+ }
+ }
+ got, err := ListForApp(ctx, s, "bdj", "story")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 || got[0].Host != "a.example.com" {
+ t.Fatalf("ListForApp = %+v, want exactly a.example.com", got)
+ }
+}
+
+func TestDeleteIsIdempotent(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if err := Delete(ctx, s, "never-existed.example.com"); err != nil {
+ t.Fatalf("delete of absent claim: %v", err)
+ }
+}