| 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 |
| +} |