carlos / platform Public

Merge remote-tracking branch 'origin/main' into worktree-domain-attach

Paul Campbell 3ec44ec8d29ef0910fd70a4aa9023f3786c805f5 parent df5f5dfparent 8f36377
34 files changed, +4546 −149
  • account_choose.go +123 −0
  • account_choose_test.go +89 −0
  • accounts.go +71 −3
  • accounts_list_test.go +81 −0
  • accounts_resolve_test.go +12 −0
  • apimode.go +310 −6
  • apimode_test.go +536 −0
  • appconfig_cli.go +16 −10
  • apps.go +8 −7
  • auth.go +57 −6
  • auth_test.go +58 −7
  • cli.go +29 −19
  • config.go +115 −23
  • config_test.go +96 −0
  • deploy.go +2 −2
  • docs/superpowers/plans/2026-08-12-account-default-resolution.md +122 −0
  • docs/superpowers/plans/2026-08-12-delegated-wildcard-certs.md +427 −0
  • docs/superpowers/plans/2026-08-12-woodstar-w0-relay-fanout.md +751 −0
  • docs/superpowers/specs/2026-08-12-account-default-resolution-design.md +182 −0
  • docs/superpowers/specs/2026-08-12-proactive-cli-design.md +128 −0
  • docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md +424 −0
  • fleets.go +14 −13
  • instances_cli.go +7 −7
  • internal/edge/edge.go +2 −2
  • internal/edge/wildcard.go +111 −24
  • internal/edge/wildcard_test.go +134 −11
  • internal/host/agent.go +3 −0
  • logs.go +2 −2
  • main.go +9 −0
  • restart.go +1 −1
  • retention.go +1 −1
  • store.go +6 −5
  • update.go +358 −0
  • update_test.go +261 −0
diff --git a/account_choose.go b/account_choose.go
new file mode 100644
index 000000000..185083a39
--- /dev/null
+++ b/account_choose.go
@@ -0,0 +1,123 @@
+// The interactive account chooser: what an omitted -account means when the
+// caller belongs to several accounts and a human is at the terminal. The
+// non-interactive shape of the same question is severalAccountsErr — a
+// script cannot answer a prompt, and refusing loudly beats hanging; the
+// refusal doubles as membership discovery, since it names every account.
+package main
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "os"
+ "strconv"
+ "strings"
+)
+
+// apiAccountEntry is one row of GET /api/cli/apps's "accounts" list
+// (internal/console handleAPIApps) — only the fields this client reads.
+// The endpoint's order is the store's creation order, so entry 1 is the
+// personal account minted at sign-up.
+type apiAccountEntry struct {
+ Name string `json:"name"`
+ Sqid string `json:"sqid"`
+ Role string `json:"role"`
+}
+
+// severalAccountsErr refuses an ambiguous omitted -account, naming every
+// membership so the refusal itself answers "what could I have said".
+func severalAccountsErr(accts []apiAccountEntry) error {
+ names := make([]string, 0, len(accts))
+ for _, a := range accts {
+ names = append(names, fmt.Sprintf("%s (%s)", a.Name, a.Sqid))
+ }
+ return fmt.Errorf("you belong to several accounts — say which with -account: %s",
+ strings.Join(names, ", "))
+}
+
+// interactiveTerminal reports whether a prompt has a human on both ends.
+// Stdin AND stdout must be terminals: a redirected stdout means the output
+// is for a parser, and a redirected stdin has no one to answer. A var, not
+// a func: a global that reads real file descriptors is untestable in both
+// directions (under `go test` stdin is /dev/null — a char device — and only
+// the piped stdout saves the check), so tests set it explicitly.
+var interactiveTerminal = func() bool {
+ return charDevice(os.Stdin) && charDevice(os.Stdout)
+}
+
+// chooserIn is where chooseAccount reads its answers — os.Stdin, swapped by
+// tests for the same reason interactiveTerminal is a var.
+var chooserIn io.Reader = os.Stdin
+
+// chooserSc is the ONE scanner over chooserIn, rebound when a test swaps
+// the reader. One scanner, not one per prompt: a Scanner buffers ahead, so
+// a second Scanner on the same reader would find the bytes the first one
+// already swallowed — the second answer in "y\ny\n" simply vanishes.
+var chooserSc *bufio.Scanner
+var chooserScFor io.Reader
+
+func chooserScanner() *bufio.Scanner {
+ if chooserSc == nil || chooserScFor != chooserIn {
+ chooserSc = bufio.NewScanner(chooserIn)
+ chooserScFor = chooserIn
+ }
+ return chooserSc
+}
+
+// promptLine prints one prompt and reads one trimmed line; EOF answers "".
+func promptLine(out io.Writer, prompt string) string {
+ fmt.Fprint(out, prompt)
+ sc := chooserScanner()
+ if !sc.Scan() {
+ return ""
+ }
+ return strings.TrimSpace(sc.Text())
+}
+
+// promptYes asks a y/N question; only y/Y is yes, EOF and everything else
+// is no.
+func promptYes(out io.Writer, prompt string) bool {
+ return strings.EqualFold(promptLine(out, prompt), "y")
+}
+
+// chooseAccount asks which account an omitted -account means, then — when
+// saveDest names a file — whether to remember the answer there (the caller
+// writes the config; this function only asks). An empty saveDest skips the
+// save prompt entirely: from $HOME the "project" file would be the global
+// config, and a y/N inside a ship must not carry that weight. A junk answer
+// re-prompts once; two misses, or EOF at the pick, fall back to the
+// non-interactive refusal so the caller's error path is identical either
+// way. EOF or anything but y/Y at the save prompt means no — the pick
+// itself stands.
+func chooseAccount(in io.Reader, out io.Writer, console string, accts []apiAccountEntry, saveDest string) (apiAccountEntry, bool, error) {
+ fmt.Fprintf(out, "you belong to several accounts on %s:\n", console)
+ for i, a := range accts {
+ fmt.Fprintf(out, " %d. %s (%s)\n", i+1, a.Name, a.Sqid)
+ }
+ sc := bufio.NewScanner(in)
+ if in == chooserIn {
+ // The process-wide input shares the process-wide scanner — a private
+ // one here would buffer past the chooser's answers and starve any
+ // later promptLine (see chooserScanner).
+ sc = chooserScanner()
+ }
+ var picked *apiAccountEntry
+ for attempt := 0; attempt < 2 && picked == nil; attempt++ {
+ fmt.Fprintf(out, "account [1-%d]: ", len(accts))
+ if !sc.Scan() {
+ return apiAccountEntry{}, false, severalAccountsErr(accts)
+ }
+ if n, err := strconv.Atoi(strings.TrimSpace(sc.Text())); err == nil && n >= 1 && n <= len(accts) {
+ picked = &accts[n-1]
+ }
+ }
+ if picked == nil {
+ return apiAccountEntry{}, false, severalAccountsErr(accts)
+ }
+ if saveDest == "" {
+ return *picked, false, nil
+ }
+ fmt.Fprintf(out, "save %s as this directory's default account (%s)? [y/N] ", picked.Name, saveDest)
+ save := sc.Scan() && strings.EqualFold(strings.TrimSpace(sc.Text()), "y")
+ return *picked, save, nil
+}
diff --git a/account_choose_test.go b/account_choose_test.go
new file mode 100644
index 000000000..4e2d10ba6
--- /dev/null
+++ b/account_choose_test.go
@@ -0,0 +1,89 @@
+package main
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+func TestSeveralAccountsErr(t *testing.T) {
+ err := severalAccountsErr([]apiAccountEntry{
+ {Name: "tom@keymail.dev", Sqid: "bdk"}, {Name: "acme", Sqid: "bce"}})
+ want := "you belong to several accounts — say which with -account: tom@keymail.dev (bdk), acme (bce)"
+ if err.Error() != want {
+ t.Fatalf("severalAccountsErr = %q\nwant %q", err, want)
+ }
+}
+
+func TestChooseAccount(t *testing.T) {
+ accts := []apiAccountEntry{
+ {Name: "tom@keymail.dev", Sqid: "bdk"}, {Name: "acme", Sqid: "bce"}}
+ refusal := severalAccountsErr(accts).Error()
+
+ // A clean pick of the second entry, declining the save.
+ var out bytes.Buffer
+ picked, save, err := chooseAccount(strings.NewReader("2\nn\n"), &out, "https://console.example", accts, ".carlos/config")
+ if err != nil || picked.Sqid != "bce" || save {
+ t.Fatalf("pick 2 = %+v save=%v err=%v", picked, save, err)
+ }
+ for _, want := range []string{
+ "you belong to several accounts on https://console.example",
+ "1. tom@keymail.dev (bdk)", "2. acme (bce)", "account [1-2]:",
+ "save acme as this directory's default account (.carlos/config)? [y/N]",
+ } {
+ if !strings.Contains(out.String(), want) {
+ t.Fatalf("chooser output missing %q:\n%s", want, out.String())
+ }
+ }
+
+ // Saying y to the save prompt.
+ out.Reset()
+ picked, save, err = chooseAccount(strings.NewReader("1\ny\n"), &out, "https://console.example", accts, ".carlos/config")
+ if err != nil || picked.Sqid != "bdk" || !save {
+ t.Fatalf("pick 1 + save = %+v save=%v err=%v", picked, save, err)
+ }
+
+ // An empty saveDest — the caller decided saving is off the table (from
+ // $HOME the "project" file would be the global config) — asks nothing
+ // and never saves, even with a y queued up.
+ out.Reset()
+ picked, save, err = chooseAccount(strings.NewReader("1\ny\n"), &out, "https://console.example", accts, "")
+ if err != nil || picked.Sqid != "bdk" || save {
+ t.Fatalf("no-dest pick = %+v save=%v err=%v, want pick without a save", picked, save, err)
+ }
+ if strings.Contains(out.String(), "save ") {
+ t.Fatalf("no-dest chooser must not offer the save:\n%s", out.String())
+ }
+
+ // An empty save answer means no — N is the default.
+ out.Reset()
+ _, save, err = chooseAccount(strings.NewReader("1\n\n"), &out, "https://console.example", accts, ".carlos/config")
+ if err != nil || save {
+ t.Fatalf("empty save answer = save=%v err=%v, want no-save", save, err)
+ }
+
+ // EOF at the save prompt also means no; the pick itself stands.
+ out.Reset()
+ picked, save, err = chooseAccount(strings.NewReader("1\n"), &out, "https://console.example", accts, ".carlos/config")
+ if err != nil || picked.Sqid != "bdk" || save {
+ t.Fatalf("EOF at save = %+v save=%v err=%v", picked, save, err)
+ }
+
+ // Junk once re-prompts; a valid second answer lands.
+ out.Reset()
+ picked, _, err = chooseAccount(strings.NewReader("zzz\n1\nn\n"), &out, "https://console.example", accts, ".carlos/config")
+ if err != nil || picked.Sqid != "bdk" {
+ t.Fatalf("junk-then-1 = %+v err=%v", picked, err)
+ }
+ if strings.Count(out.String(), "account [1-2]:") != 2 {
+ t.Fatalf("want exactly one re-prompt:\n%s", out.String())
+ }
+
+ // Junk twice — including out-of-range numbers — is the refusal.
+ for _, input := range []string{"zzz\nzzz\n", "0\n3\n", ""} {
+ out.Reset()
+ if _, _, err = chooseAccount(strings.NewReader(input), &out, "https://console.example", accts, ".carlos/config"); err == nil || err.Error() != refusal {
+ t.Fatalf("input %q = %v, want the several-accounts refusal", input, err)
+ }
+ }
+}
diff --git a/accounts.go b/accounts.go
index b398cc583..d20bcac4f 100644
--- a/accounts.go
+++ b/accounts.go
@@ -16,6 +16,7 @@ import (
"errors"
"flag"
"fmt"
+ "io"
"log"
"net/http"
"os"
@@ -39,7 +40,13 @@ const accountsUsage = `usage: carlos accounts <create|list|migrate>
over any logged-in one.
[-console <url>] which logged-in console to mint through
(only needed with several)
- list sqid, name, and app count
+ list your accounts. Signed-in (the usual case):
+ name, sqid and your role, from the console.
+ On a box with a registry: sqid, name and
+ app count from the registry itself.
+ [-console <url>] list through a console even where a
+ registry exists (or: which logged-in
+ console, with several)
migrate -app <a> -to <sqid> copy an app's objects to the account-
qualified prefix and re-stamp its routes
[-from <sqid>] source account (default: the legacy,
@@ -203,9 +210,70 @@ func createAccountViaConsole(cred *credential, regPath, name string) {
func runAccountsList(args []string) {
fs := flag.NewFlagSet("accounts list", flag.ExitOnError)
- regPath := fs.String("registry", defaultRegistry(), "registry database")
+ regPath := fs.String("registry", defaultRegistry(), "registry database (box mode)")
+ consoleFlag := fs.String("console", "", "list through a console instead (default: the box registry when one exists, else your logged-in console)")
fs.Parse(args)
- r, err := registry.Open(*regPath)
+ // Mode split by what exists, not by resolveMode: this verb predates the
+ // console API and must keep working on a box that has a registry and no
+ // bucket env, no credentials — while a laptop with neither registry nor
+ // box access (the #157 case) asks its console. An explicit -console
+ // skips the registry even where one exists; an explicit -registry is a
+ // commitment, not a hint — a typo'd path or an EACCES must surface as
+ // registry.Open's own error, never as a silent hop to console mode
+ // answering "not logged in" to someone who asked about a database.
+ explicitRegistry := false
+ fs.Visit(func(f *flag.Flag) {
+ if f.Name == "registry" {
+ explicitRegistry = true
+ }
+ })
+ if *consoleFlag == "" {
+ if explicitRegistry {
+ runAccountsListRegistry(*regPath)
+ return
+ }
+ if _, err := os.Stat(*regPath); err == nil {
+ runAccountsListRegistry(*regPath)
+ return
+ }
+ }
+ if err := accountsListAPI(os.Stdout, *consoleFlag); err != nil {
+ log.Fatal(err)
+ }
+}
+
+// accountsListAPI is the signed-in form: the caller's own memberships from
+// GET /api/cli/apps, as a NAME/SQID/ROLE table. The one 404 this path can
+// see is a console too old to have the endpoint (no account/app segment
+// here for the membership gate to refuse), so it gets a named refusal
+// instead of notFoundErr's check-your-flags riddle.
+func accountsListAPI(out io.Writer, consoleFlag string) error {
+ cred, err := loggedInCredential(consoleFlag)
+ if err != nil {
+ return err
+ }
+ var acc struct {
+ Accounts []apiAccountEntry `json:"accounts"`
+ }
+ status, err := newConsoleClient(cred.Console, cred.Token).
+ do(context.Background(), http.MethodGet, "/api/cli/apps", nil, &acc)
+ if status == http.StatusNotFound {
+ return fmt.Errorf("%s predates account listing — upgrade the console, or run this on a box with -registry", cred.Console)
+ }
+ if err != nil {
+ return err
+ }
+ w := newTable(out)
+ fmt.Fprintln(w, "NAME\tSQID\tROLE")
+ for _, a := range acc.Accounts {
+ fmt.Fprintf(w, "%s\t%s\t%s\n", a.Name, a.Sqid, a.Role)
+ }
+ w.Flush()
+ return nil
+}
+
+func runAccountsListRegistry(regPath string) {
+ r, err := registry.Open(regPath)
if err != nil {
log.Fatal(err)
}
diff --git a/accounts_list_test.go b/accounts_list_test.go
new file mode 100644
index 000000000..3bb1a8610
--- /dev/null
+++ b/accounts_list_test.go
@@ -0,0 +1,81 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+// signedInConsole plants a credential for an httptest console that serves
+// GET /api/cli/apps, and returns that console's URL.
+func signedInConsole(t *testing.T, handler http.HandlerFunc) string {
+ t.Helper()
+ ts := httptest.NewServer(handler)
+ t.Cleanup(ts.Close)
+ f, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.put(credential{Console: ts.URL, Address: "tom@keymail.dev", Token: "tok",
+ CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)})
+ if err := f.save(); err != nil {
+ t.Fatal(err)
+ }
+ return ts.URL
+}
+
+// accounts list, signed-in: no registry on this machine, a credential and a
+// console that answers — the no-infra path #157 was filed for.
+func TestAccountsListSignedIn(t *testing.T) {
+ useTempCredentials(t)
+ signedInConsole(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/cli/apps" || r.Header.Get("Authorization") != "Bearer tok" {
+ http.NotFound(w, r)
+ return
+ }
+ json.NewEncoder(w).Encode(map[string]any{"accounts": []map[string]any{
+ {"sqid": "bdk", "name": "tom@keymail.dev", "role": "owner", "apps": []string{}},
+ {"sqid": "bce", "name": "acme", "role": "member", "apps": []string{"shop"}},
+ }})
+ })
+
+ var out bytes.Buffer
+ if err := accountsListAPI(&out, ""); err != nil {
+ t.Fatalf("accountsListAPI: %v", err)
+ }
+ for _, want := range []string{"NAME", "SQID", "ROLE",
+ "tom@keymail.dev", "bdk", "owner", "acme", "bce", "member"} {
+ if !strings.Contains(out.String(), want) {
+ t.Fatalf("table missing %q:\n%s", want, out.String())
+ }
+ }
+}
+
+// A console from before the listing endpoint gets a plain named refusal,
+// not the membership-404 riddle.
+func TestAccountsListOldConsole(t *testing.T) {
+ useTempCredentials(t)
+ url := signedInConsole(t, func(w http.ResponseWriter, r *http.Request) {
+ http.NotFound(w, r)
+ })
+
+ var out bytes.Buffer
+ err := accountsListAPI(&out, "")
+ if err == nil || !strings.Contains(err.Error(), url) || !strings.Contains(err.Error(), "predates") {
+ t.Fatalf("old console = %v, want a refusal naming the console", err)
+ }
+}
+
+// Not logged in and no registry: the error names the fix, exactly pick()'s.
+func TestAccountsListNeitherMode(t *testing.T) {
+ useTempCredentials(t)
+ var out bytes.Buffer
+ if err := accountsListAPI(&out, ""); err == nil ||
+ !strings.Contains(err.Error(), "carlos auth login") {
+ t.Fatalf("no credential = %v, want the login hint", err)
+ }
+}
diff --git a/accounts_resolve_test.go b/accounts_resolve_test.go
index 4464126ab..54f751cdf 100644
--- a/accounts_resolve_test.go
+++ b/accounts_resolve_test.go
@@ -9,6 +9,18 @@ import (
"github.com/carlosframework/platform/internal/release"
)
+// The load-bearing invariant behind the account-default branch (#157): the
+// flag default flipped from "ops" to "" for every dual-mode verb, and
+// direct mode survives ONLY because resolveAccount reads "" as ops — with
+// no registry involved, since CI and fresh boxes have none.
+func TestResolveAccountEmptyMeansOps(t *testing.T) {
+ for _, want := range []string{"", "ops", " ops ", " "} {
+ if got := resolveAccount("/nonexistent/registry.db", want, ""); got != release.OpsAccount {
+ t.Errorf("resolveAccount(%q) = %q, want %q", want, got, release.OpsAccount)
+ }
+ }
+}
+
func TestPickAccount(t *testing.T) {
accts := []registry.Account{
{ID: 1, Sqid: "bab", Name: "ops"},
diff --git a/apimode.go b/apimode.go
index c7120636d..d1bbf0ffe 100644
--- a/apimode.go
+++ b/apimode.go
@@ -22,6 +22,7 @@ import (
"errors"
"fmt"
"io"
+ "log"
"mime/multipart"
"net/http"
"net/url"
@@ -44,6 +45,11 @@ type apiSession struct {
c *consoleClient
console string // origin, for messages
address string
+ // defAcct memoizes defaultAccount's answer: resolution happens once per
+ // invocation however many times a verb asks (runSecretsSet asks twice —
+ // once for the seal key, once for the write; two chooser prompts there
+ // once sealed a value with one account's key and wrote it to another).
+ defAcct *apiAcct
}
// resolveMode decides which mode the CLI is in. (nil, nil) means direct mode
@@ -91,14 +97,20 @@ func resolveMode(consoleFlag string) (*apiSession, error) {
// them here would tell someone who has in fact logged in before
// that nothing is configured at all.
if expired := expiredCredentialFirst(); expired != nil {
- return nil, fmt.Errorf("the credential for %s expired — run: carlos auth login -console %s",
- expired.Console, expired.Console)
+ err = &notLoggedInError{Console: expired.Console, Expired: true,
+ msg: fmt.Sprintf("the credential for %s expired — run: carlos auth login -console %s",
+ expired.Console, expired.Console)}
+ } else {
+ err = errNoDeploymentTarget
}
- return nil, fmt.Errorf("no deployment target configured: " +
- "set CARLOS_DEPLOYMENT_BUCKET (or CARLOS_DEPLOYMENT_DIR) for direct bucket access, " +
- "or run carlos auth login -console <url> to work through a console")
}
- return nil, err
+ // A terminal gets the remedy offered, not just named (#162): log in
+ // right here and CONTINUE the command the user actually typed.
+ // Scripts, n, and EOF keep the exact errors built above.
+ cred, err = offerLogin(err, consoleFlag)
+ if err != nil {
+ return nil, err
+ }
}
return &apiSession{
c: newConsoleClient(cred.Console, cred.Token),
@@ -107,6 +119,143 @@ func resolveMode(consoleFlag string) (*apiSession, error) {
}, nil
}
+// consoleMovedError is a permanent-redirect answer from a console (#160):
+// the host this CLI was pointed at now lives somewhere else, and the
+// message carries the whole story — a script that hits it needs no oracle.
+type consoleMovedError struct{ From, To string }
+
+func (e *consoleMovedError) Error() string {
+ return fmt.Sprintf("this console has moved: %s -> %s — run: carlos auth login -console %s",
+ e.From, e.To, e.To)
+}
+
+// consoleMoved inspects one response for a console-shaped redirect. Only a
+// PERMANENT redirect (301/308) whose Location names an ABSOLUTE origin
+// normalizeConsole would accept (https, or http strictly on-box) and that
+// differs from the one dialed counts — 302/307 are what SSO walls and
+// identity proxies answer with, and reading those as "the console moved"
+// would offer a login to accounts.google.com; a same-origin or path-level
+// redirect stays a plain unexpected status; and a Location the CLI would
+// refuse to log in to is never surfaced as a destination. The Location is
+// trusted for DISPLAY AND PROMPTING only: nothing follows it, and no
+// credential exists until the human confirms the device flow against it.
+func consoleMoved(from string, status int, location string) *consoleMovedError {
+ switch status {
+ case http.StatusMovedPermanently, http.StatusPermanentRedirect:
+ default:
+ return nil
+ }
+ u, err := url.Parse(location)
+ if err != nil || !u.IsAbs() || u.Host == "" {
+ return nil
+ }
+ origin, err := normalizeConsole(u.Scheme + "://" + u.Host)
+ if err != nil || origin == from {
+ return nil
+ }
+ return &consoleMovedError{From: from, To: origin}
+}
+
+// consoleMovedOffered makes the offer once-per-process: polling verbs
+// (`logs -f`, authLogin's approval loop) hit the same 3xx every interval,
+// and prompting on each would eat the terminal's stdin; and the offer
+// itself runs authLogin, whose polls reach these same response sites — the
+// guard is also the re-entrancy stop for a redirect chain. After the one
+// offer (taken or declined), every further hit is the typed error.
+var consoleMovedOffered bool
+
+// quietConsoleOffers suppresses the offer entirely — set around calls
+// whose contract is to degrade silently (whoami's memberships bonus line),
+// where even one prompt would make a working command look broken.
+var quietConsoleOffers bool
+
+// offerConsoleMoved is the terminal's version of consoleMovedError: explain
+// the move, offer the standard device flow against the new host, and — when
+// a .carlos/config names the old console — offer to repoint it (keeping
+// every other key, account included: the pair moves together). The verb
+// still ends nonzero: its request died against the old host mid-flight, and
+// "logged in — re-run your command" is the honest state.
+func offerConsoleMoved(e *consoleMovedError) error {
+ if !interactiveTerminal() || quietConsoleOffers || consoleMovedOffered {
+ return e
+ }
+ consoleMovedOffered = true
+ fmt.Fprintf(stdout, "this console has moved: %s -> %s\n", e.From, e.To)
+ if !promptYes(stdout, fmt.Sprintf("log in to %s now? [y/N] ", e.To)) {
+ return e
+ }
+ if err := authLogin(stdout, []string{"-console", e.To}); err != nil {
+ return err
+ }
+ if cfgConsole, src, err := configDefault(); err == nil && cfgConsole == e.From && src != "" {
+ // The destination is named in the prompt, not only the file: a y
+ // here changes where future ships go, and that must never rest on
+ // remembering what the previous prompt said.
+ if promptYes(stdout, fmt.Sprintf("update %s to point at %s too? [y/N] ", src, e.To)) {
+ if err := saveConfigFile(src, e.To, 0o755); err != nil {
+ warnf(stdout, "not updated (%s): %v", src, err)
+ } else {
+ mutedf(stdout, "updated: %s now names %s", src, e.To)
+ }
+ }
+ }
+ return fmt.Errorf("logged in to %s — re-run your command against it", e.To)
+}
+
+// errNoDeploymentTarget is resolveMode's "nothing configured at all"
+// refusal — a var so offerLogin can recognize it by identity rather than
+// by matching its wording.
+var errNoDeploymentTarget = errors.New("no deployment target configured: " +
+ "set CARLOS_DEPLOYMENT_BUCKET (or CARLOS_DEPLOYMENT_DIR) for direct bucket access, " +
+ "or run carlos auth login -console <url> to work through a console")
+
+// offerLogin is the interactive half of resolveMode's refusals (#162). On
+// a terminal, a refusal that names a console offers to run the device flow
+// against it right now; the nothing-configured refusal asks for a URL
+// (typing one IS the consent — no second prompt). Everything else — and
+// every non-terminal caller — gets the original error back verbatim. The
+// login flow itself is authLogin, unchanged: browser approval, per-origin
+// credential, no shortcuts.
+func offerLogin(origErr error, consoleFlag string) (*credential, error) {
+ if !interactiveTerminal() {
+ return nil, origErr
+ }
+ var target string
+ var nle *notLoggedInError
+ switch {
+ case errors.As(origErr, &nle):
+ if nle.Expired {
+ fmt.Fprintf(stdout, "your login for %s has expired\n", nle.Console)
+ if !promptYes(stdout, "log in again now? [y/N] ") {
+ return nil, origErr
+ }
+ } else {
+ fmt.Fprintf(stdout, "not logged in to %s\n", nle.Console)
+ if !promptYes(stdout, "log in now? [y/N] ") {
+ return nil, origErr
+ }
+ }
+ target = nle.Console
+ case errors.Is(origErr, errNoDeploymentTarget):
+ fmt.Fprintln(stdout, "no console configured and no login found.")
+ raw := promptLine(stdout, "console URL (e.g. https://console.carloku.com): ")
+ if raw == "" {
+ return nil, origErr
+ }
+ n, err := normalizeConsole(raw)
+ if err != nil {
+ return nil, err
+ }
+ target = n
+ default:
+ return nil, origErr
+ }
+ if err := authLogin(stdout, []string{"-console", target}); err != nil {
+ return nil, err
+ }
+ return loggedInCredential(consoleFlag)
+}
+
// noCredentialsMsg is (*credentialFile).pick's exact wording (auth.go) for
// "no -console given (and no CARLOS_CONSOLE either) and there is nothing
// live to pick from" — the one pick() refusal that never mentions direct
@@ -226,6 +375,146 @@ func apiPath(a apiAcct, app, tail string) string {
return p
}
+// listAccounts fetches the caller's own memberships: GET /api/cli/apps'
+// "accounts" array (handleAPIApps — live since main 5856dc5, 2026-08-03).
+// supported=false means the console predates the endpoint: the bare mux
+// 404, which cannot be the membership gate's 404 because this path carries
+// no account or app segment for a gate to refuse. Every other non-200 is
+// a real answer (401 expired token, 500) and surfaces via apiErrorFromBody
+// — never swallowed into a fallback.
+func (s *apiSession) listAccounts(ctx context.Context) (accts []apiAccountEntry, supported bool, err error) {
+ status, body, err := s.getJSON(ctx, "/api/cli/apps")
+ if err != nil {
+ return nil, false, err
+ }
+ if status == http.StatusNotFound {
+ return nil, false, nil
+ }
+ if status != http.StatusOK {
+ return nil, false, apiErrorFromBody(status, body)
+ }
+ var out struct {
+ Accounts []apiAccountEntry `json:"accounts"`
+ }
+ if err := json.Unmarshal(body, &out); err != nil {
+ return nil, false, fmt.Errorf("unreadable accounts response from %s: %w", s.console, err)
+ }
+ return out.Accounts, true, nil
+}
+
+// defaultAccount answers an omitted -account in API mode (spec:
+// docs/superpowers/specs/2026-08-12-account-default-resolution-design.md):
+// the config default when its file's console pairs with this session, else
+// the caller's one membership, else the chooser (terminal) or the naming
+// refusal (script). Whatever is decided for the caller is printed — a
+// guessed account that stayed invisible would be the wrong-deployment
+// mistake this file already refuses everywhere else.
+func (s *apiSession) defaultAccount(ctx context.Context) (apiAcct, error) {
+ if s.defAcct != nil {
+ return *s.defAcct, nil
+ }
+ a, err := s.defaultAccountOnce(ctx)
+ if err == nil {
+ s.defAcct = &a
+ }
+ return a, err
+}
+
+func (s *apiSession) defaultAccountOnce(ctx context.Context) (apiAcct, error) {
+ name, src, fileConsole, err := configAccount()
+ if err != nil {
+ return apiAcct{}, err
+ }
+ // Honored ONLY when the file names THIS console: the pair is one fact.
+ // A consoleless {"account": …} is deliberately not a wildcard — the
+ // console key is nearly inert as committed input (no credential, no
+ // request), but an account that applied to every console would let a
+ // checked-in file silently redirect a ship between stacks the caller
+ // really is a member of. saveConfigAccount always writes the pair, so
+ // only a hand-written file can even be consoleless.
+ if name != "" && fileConsole == s.console {
+ // The name travels verbatim, like a typed -account: the console
+ // resolves it against the caller's memberships, so a stale config
+ // value fails with the same membership 404 a typo would.
+ mutedf(stdout, "account: %s (the default from %s)", name, src)
+ return apiAcct{Seg: name}, nil
+ }
+ accts, supported, err := s.listAccounts(ctx)
+ if err != nil {
+ return apiAcct{}, err
+ }
+ if !supported {
+ // No memberships answer — a console from before the listing
+ // endpoint, or any 404-shaped thing in front of one. Keep the
+ // pre-discovery default so old consoles regress nothing, but SAY
+ // so: a silent ops here followed by the membership 404 is exactly
+ // the three-failures-deep riddle #157 was filed about.
+ mutedf(stdout, "account: ops (%s did not answer with your memberships)", s.console)
+ return apiAcct{Seg: "ops"}, nil
+ }
+ switch len(accts) {
+ case 0:
+ return apiAcct{}, fmt.Errorf("your address belongs to no account on %s — create one with: carlos accounts create -name <name>", s.console)
+ case 1:
+ mutedf(stdout, "account: %s (%s)", accts[0].Name, accts[0].Sqid)
+ return apiAcct{Seg: accts[0].Sqid, Exact: true}, nil
+ }
+ if !interactiveTerminal() {
+ return apiAcct{}, severalAccountsErr(accts)
+ }
+ // ./.carlos/config in the working directory — the same "run it where
+ // you want the file" rule as `auth default -project`. Except from
+ // $HOME, where that path IS ~/.carlos/config: a y there would silently
+ // repoint the machine-wide default console from inside a ship, so the
+ // save is not offered at all.
+ dest := filepath.Join(".carlos", "config")
+ if abs, err := filepath.Abs(dest); err == nil {
+ if gp, gerr := globalConfigPath(); gerr == nil && abs == gp {
+ dest = ""
+ }
+ }
+ picked, save, err := chooseAccount(chooserIn, stdout, s.console, accts, dest)
+ if err != nil {
+ return apiAcct{}, err
+ }
+ if save && dest != "" {
+ // The save is bookkeeping, never a precondition: the human already
+ // answered, the account is known, and a read-only checkout must
+ // not turn that answer into a dead ship.
+ if err := saveConfigAccount(dest, s.console, picked.Name, 0o755); err != nil {
+ warnf(stdout, "not saved (%s): %v", dest, err)
+ } else {
+ mutedf(stdout, "saved: %s is now this directory's default account (%s)", picked.Name, dest)
+ }
+ }
+ return apiAcct{Seg: picked.Sqid, Exact: true}, nil
+}
+
+// resolvedAccount is apiAccount plus the omitted-flag default: the seam
+// every API-mode verb reaches through. Explicit flags travel verbatim and
+// never touch the network; only an empty pair asks defaultAccount. Fatal
+// on refusal because every caller is a verb body whose next line would be
+// the request itself — there is nothing for it to do with the error but
+// print it and stop.
+func resolvedAccount(sess *apiSession, raw, rawID *string) apiAcct {
+ a := apiAccount(raw, rawID)
+ if a.Seg != "" {
+ return a
+ }
+ if sess == nil {
+ // Unreachable from any current verb (each guards its mode before
+ // resolving), and kept LOUD: returning the empty segment would
+ // build /api/cli/apps//<app> and answer a client-side bug with the
+ // membership-404 riddle.
+ log.Fatal("resolvedAccount: no session — a direct-mode path reached API account resolution (a bug in this binary)")
+ }
+ r, err := sess.defaultAccount(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+ return r
+}
+
// notFoundErr is what every call here returns for a 404 whose body carries
// no JSON "error" field. apiAccountApp (internal/console/apicli.go) answers
// an unknown account, an unknown app, and someone else's account with the
@@ -276,6 +565,9 @@ func (s *apiSession) getJSON(ctx context.Context, path string) (int, []byte, err
return 0, nil, err
}
defer res.Body.Close()
+ if merr := consoleMoved(s.c.base, res.StatusCode, res.Header.Get("Location")); merr != nil {
+ return res.StatusCode, nil, offerConsoleMoved(merr)
+ }
body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return res.StatusCode, nil, err
@@ -306,6 +598,9 @@ func (s *apiSession) postJSON(ctx context.Context, path string, in any) (int, []
return 0, nil, err
}
defer res.Body.Close()
+ if merr := consoleMoved(s.c.base, res.StatusCode, res.Header.Get("Location")); merr != nil {
+ return res.StatusCode, nil, offerConsoleMoved(merr)
+ }
body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return res.StatusCode, nil, err
@@ -995,6 +1290,9 @@ func (s *apiSession) ledgerBlob(ctx context.Context, a apiAcct, app, name string
return "", err
}
defer res.Body.Close()
+ if merr := consoleMoved(s.c.base, res.StatusCode, res.Header.Get("Location")); merr != nil {
+ return "", offerConsoleMoved(merr)
+ }
respBody, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return "", err
@@ -1034,6 +1332,9 @@ func (s *apiSession) putJSON(ctx context.Context, path string, in any) (int, []b
return 0, nil, err
}
defer res.Body.Close()
+ if merr := consoleMoved(s.c.base, res.StatusCode, res.Header.Get("Location")); merr != nil {
+ return res.StatusCode, nil, offerConsoleMoved(merr)
+ }
body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return res.StatusCode, nil, err
@@ -1195,6 +1496,9 @@ func (s *apiSession) deleteJSON(ctx context.Context, path string) (int, []byte,
return 0, nil, err
}
defer res.Body.Close()
+ if merr := consoleMoved(s.c.base, res.StatusCode, res.Header.Get("Location")); merr != nil {
+ return res.StatusCode, nil, offerConsoleMoved(merr)
+ }
body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return res.StatusCode, nil, err
diff --git a/apimode_test.go b/apimode_test.go
index 3c3f98f69..6faeab651 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -114,6 +114,7 @@ func TestResolveModeAPI(t *testing.T) {
func TestResolveModeNothing(t *testing.T) {
useTempCredentials(t)
clearDeploymentEnv(t)
+ forceTerminal(t, false) // pin: this asserts the NON-interactive refusal
sess, err := resolveMode("")
if sess != nil {
@@ -137,6 +138,7 @@ func TestResolveModeNothing(t *testing.T) {
func TestResolveModeExpiredCredentialOnly(t *testing.T) {
useTempCredentials(t)
clearDeploymentEnv(t)
+ forceTerminal(t, false) // pin: this asserts the NON-interactive refusal
f, _ := loadCredentials()
f.put(credential{Console: "https://a.example", Address: "a@keymail.dev", Token: "t1", ExpiresAt: time.Now().Add(-time.Hour)})
@@ -2778,3 +2780,537 @@ func TestAPISessionCreateAppSendsPlace(t *testing.T) {
t.Fatalf("empty place must not travel as a non-empty value; got %q", gotPlace)
}
}
+
+// accountsConsole is a console stub for the omitted--account resolution
+// tests: it serves GET /api/cli/apps with the given status and body, and
+// counts how often the endpoint is consulted.
+func accountsConsole(t *testing.T, status int, body string) (*apiSession, *int) {
+ t.Helper()
+ hits := 0
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/cli/apps" || r.Method != http.MethodGet {
+ t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
+ http.NotFound(w, r)
+ return
+ }
+ hits++
+ w.WriteHeader(status)
+ io.WriteString(w, body)
+ }))
+ t.Cleanup(ts.Close)
+ return newTestSession(ts.URL), &hits
+}
+
+func TestDefaultAccountSingleMembership(t *testing.T) {
+ useTempCredentials(t)
+ out := captureStdout(t)
+ s, hits := accountsConsole(t, http.StatusOK,
+ `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","role":"owner","apps":[]}]}`)
+
+ a, err := s.defaultAccount(context.Background())
+ if err != nil || a.Seg != "bdk" || !a.Exact {
+ t.Fatalf("one membership = %+v, %v; want its sqid, exact", a, err)
+ }
+ if *hits != 1 {
+ t.Fatalf("endpoint hits = %d, want 1", *hits)
+ }
+ if !strings.Contains(out.String(), "account: tom@keymail.dev (bdk)") {
+ t.Fatalf("the resolved account must be printed; got:\n%s", out.String())
+ }
+}
+
+// forceTerminal pins interactiveTerminal for one test — the real check
+// reads process file descriptors, which a test controls in neither
+// direction (under `go test` stdin is /dev/null, a char device).
+func forceTerminal(t *testing.T, tty bool) {
+ t.Helper()
+ old := interactiveTerminal
+ interactiveTerminal = func() bool { return tty }
+ t.Cleanup(func() { interactiveTerminal = old })
+}
+
+func TestDefaultAccountSeveralNonTTY(t *testing.T) {
+ useTempCredentials(t)
+ captureStdout(t)
+ forceTerminal(t, false)
+ s, _ := accountsConsole(t, http.StatusOK,
+ `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev"},{"sqid":"bce","name":"acme"}]}`)
+
+ // No terminal: several memberships take the non-interactive refusal —
+ // which names every account.
+ _, err := s.defaultAccount(context.Background())
+ want := "you belong to several accounts — say which with -account: tom@keymail.dev (bdk), acme (bce)"
+ if err == nil || err.Error() != want {
+ t.Fatalf("several = %v\nwant %q", err, want)
+ }
+}
+
+func TestDefaultAccountChooserPicksSavesAndMemoizes(t *testing.T) {
+ useTempCredentials(t)
+ out := captureStdout(t)
+ forceTerminal(t, true)
+ oldIn := chooserIn
+ chooserIn = strings.NewReader("2\ny\n")
+ t.Cleanup(func() { chooserIn = oldIn })
+ s, hits := accountsConsole(t, http.StatusOK,
+ `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev"},{"sqid":"bce","name":"acme"}]}`)
+
+ a, err := s.defaultAccount(context.Background())
+ if err != nil || a.Seg != "bce" || !a.Exact {
+ t.Fatalf("chooser pick = %+v, %v; want acme's sqid, exact", a, err)
+ }
+ // y saved the pair to ./.carlos/config (cwd is a temp dir here).
+ cfg, err := loadConfig(filepath.Join(".carlos", "config"))
+ if err != nil || cfg.Account != "acme" || cfg.Console != s.console {
+ t.Fatalf("saved config = %+v, %v; want the acme/%s pair", cfg, err, s.console)
+ }
+ if !strings.Contains(out.String(), "saved: acme") {
+ t.Fatalf("the save must be confirmed:\n%s", out.String())
+ }
+
+ // Asking again is memoized: same answer, no second fetch, no second
+ // chooser (the input reader is already drained — a re-prompt would
+ // EOF into the refusal).
+ a2, err := s.defaultAccount(context.Background())
+ if err != nil || a2 != a {
+ t.Fatalf("second resolution = %+v, %v; want the memoized first", a2, err)
+ }
+ if *hits != 1 {
+ t.Fatalf("endpoint hits = %d, want 1 — resolution happens once per invocation", *hits)
+ }
+}
+
+func TestDefaultAccountChooserSaveFailureIsNotFatal(t *testing.T) {
+ useTempCredentials(t)
+ out := captureStdout(t)
+ forceTerminal(t, true)
+ oldIn := chooserIn
+ chooserIn = strings.NewReader("1\ny\n")
+ t.Cleanup(func() { chooserIn = oldIn })
+ s, _ := accountsConsole(t, http.StatusOK,
+ `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev"},{"sqid":"bce","name":"acme"}]}`)
+
+ // A read-only working directory: the config walk still reads fine
+ // (nothing exists to read), but MkdirAll(".carlos") cannot create.
+ cwd, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(cwd, 0o555); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.Chmod(cwd, 0o755) })
+ // The human answered; the account is known; the save is bookkeeping.
+ // A read-only checkout must not turn the answer into a dead ship.
+ a, err := s.defaultAccount(context.Background())
+ if err != nil || a.Seg != "bdk" {
+ t.Fatalf("pick with failing save = %+v, %v; want the pick to stand", a, err)
+ }
+ if !strings.Contains(out.String(), "not saved") {
+ t.Fatalf("a failed save must be said out loud:\n%s", out.String())
+ }
+}
+
+func TestDefaultAccountOldConsoleFallsBackToOps(t *testing.T) {
+ useTempCredentials(t)
+ captureStdout(t)
+ s, _ := accountsConsole(t, http.StatusNotFound, "404 page not found\n")
+
+ a, err := s.defaultAccount(context.Background())
+ if err != nil || a.Seg != "ops" || a.Exact {
+ t.Fatalf("old console = %+v, %v; want the pre-discovery ops default", a, err)
+ }
+}
+
+func TestDefaultAccountOpsFallbackIsSaidOutLoud(t *testing.T) {
+ useTempCredentials(t)
+ out := captureStdout(t)
+ s, _ := accountsConsole(t, http.StatusNotFound, "404 page not found\n")
+
+ // The fallback must not be silent: an invisible ops followed by the
+ // membership 404 is the three-failures-deep riddle #157 is about.
+ if _, err := s.defaultAccount(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out.String(), "account: ops") || !strings.Contains(out.String(), s.console) {
+ t.Fatalf("the ops fallback must name itself and the console:\n%s", out.String())
+ }
+}
+
+func TestDefaultAccountZeroMemberships(t *testing.T) {
+ useTempCredentials(t)
+ captureStdout(t)
+ s, _ := accountsConsole(t, http.StatusOK, `{"accounts":[]}`)
+
+ if _, err := s.defaultAccount(context.Background()); err == nil ||
+ !strings.Contains(err.Error(), s.console) {
+ t.Fatalf("zero memberships = %v, want an error naming the console", err)
+ }
+}
+
+func TestDefaultAccountAuthErrorSurfaces(t *testing.T) {
+ useTempCredentials(t)
+ captureStdout(t)
+ s, _ := accountsConsole(t, http.StatusUnauthorized, `{"error":"token expired"}`)
+
+ // A 401 is an answer about the TOKEN, not about memberships — it must
+ // surface, never turn into an ops fallback that then 404s confusingly.
+ if _, err := s.defaultAccount(context.Background()); err == nil ||
+ !strings.Contains(err.Error(), "token expired") {
+ t.Fatalf("401 = %v, want the console's own diagnosis", err)
+ }
+}
+
+func TestDefaultAccountConfigDefault(t *testing.T) {
+ useTempCredentials(t)
+ out := captureStdout(t)
+ s, hits := accountsConsole(t, http.StatusOK, `{"accounts":[]}`)
+
+ // A config account paired with THIS console is honored without asking
+ // the console anything.
+ cfgPath := writeConfig(t, ".", `{"console": "`+s.console+`", "account": "acme"}`)
+ a, err := s.defaultAccount(context.Background())
+ if err != nil || a.Seg != "acme" || a.Exact {
+ t.Fatalf("config account = %+v, %v; want the name, resolved server-side", a, err)
+ }
+ if *hits != 0 {
+ t.Fatalf("endpoint hits = %d, want 0 — config answered it", *hits)
+ }
+ if !strings.Contains(out.String(), "acme") || !strings.Contains(out.String(), cfgPath) {
+ t.Fatalf("the config-sourced account and its file must be printed; got:\n%s", out.String())
+ }
+
+ // A config account with NO console is ignored: the pair is one fact,
+ // and an account key that applied to every console would let a
+ // checked-in file redirect ships across stacks. With zero memberships
+ // behind it, resolution reports those — proving the fall-through.
+ // (Each check below uses a fresh session: resolution memoizes per
+ // invocation, and these are separate invocations.)
+ writeConfig(t, ".", `{"account": "acme"}`)
+ s2 := newTestSession(s.console)
+ if _, err = s2.defaultAccount(context.Background()); err == nil ||
+ !strings.Contains(err.Error(), "no account") {
+ t.Fatalf("consoleless config account = %v, want fall-through to memberships", err)
+ }
+ if *hits != 1 {
+ t.Fatalf("endpoint hits = %d, want exactly the fall-through call", *hits)
+ }
+
+ // Paired with a DIFFERENT console it is ignored the same way.
+ writeConfig(t, ".", `{"console": "https://other.example", "account": "acme"}`)
+ s3 := newTestSession(s.console)
+ if _, err = s3.defaultAccount(context.Background()); err == nil ||
+ !strings.Contains(err.Error(), "no account") {
+ t.Fatalf("mismatched config console = %v, want fall-through to memberships", err)
+ }
+ if *hits != 2 {
+ t.Fatalf("endpoint hits = %d, want one per fall-through", *hits)
+ }
+}
+
+func TestConsoleMovedDetection(t *testing.T) {
+ from := "https://dashboard.example"
+ // The real case: a permanent redirect to a new https origin.
+ if e := consoleMoved(from, 308, "https://console.example/api/cli/apps"); e == nil ||
+ e.To != "https://console.example" {
+ t.Fatalf("308 = %+v, want console.example", e)
+ }
+ want := "this console has moved: https://dashboard.example -> https://console.example — run: carlos auth login -console https://console.example"
+ if e := consoleMoved(from, 301, "https://console.example/"); e == nil || e.Error() != want {
+ t.Fatalf("moved error = %v\nwant %q", e, want)
+ }
+ // Everything that must NOT read as "moved" — including TEMPORARY
+ // redirects: 302/307 are what SSO walls answer with, and "log in to
+ // accounts.google.com" is not a remedy.
+ for name, tc := range map[string]struct {
+ status int
+ location string
+ }{
+ "non-redirect status": {404, "https://console.example"},
+ "302 sso wall": {302, "https://accounts.google.example"},
+ "307 temporary": {307, "https://console.example"},
+ "no location": {308, ""},
+ "relative location": {308, "/somewhere"},
+ "same origin": {308, from + "/new-path"},
+ "http off-box target": {308, "http://console.example"},
+ "garbage location": {308, "::not a url::"},
+ "schemeless authority": {308, "console.example/x"},
+ } {
+ if e := consoleMoved(from, tc.status, tc.location); e != nil {
+ t.Errorf("%s = %+v, want nil", name, e)
+ }
+ }
+}
+
+func TestRedirectsAreNeverFollowed(t *testing.T) {
+ useTempCredentials(t)
+ captureStdout(t)
+ forceTerminal(t, false)
+ // A "new" console that would answer if the client followed — its
+ // handler must never run.
+ followed := false
+ target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ followed = true
+ w.Write([]byte(`{}`))
+ }))
+ t.Cleanup(target.Close)
+ old := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Location", target.URL+r.URL.Path)
+ w.WriteHeader(http.StatusPermanentRedirect)
+ }))
+ t.Cleanup(old.Close)
+
+ s := newTestSession(old.URL)
+ // GET (the case Go's default policy would have silently followed with
+ // Authorization stripped) and POST both surface the typed error.
+ _, _, err := s.getJSON(context.Background(), "/api/cli/apps")
+ if err == nil || !strings.Contains(err.Error(), "this console has moved: "+old.URL+" -> "+target.URL) {
+ t.Fatalf("GET through a redirect = %v", err)
+ }
+ _, _, err = s.postJSON(context.Background(), "/api/cli/apps/a/b/promote", map[string]string{})
+ if err == nil || !strings.Contains(err.Error(), "this console has moved") {
+ t.Fatalf("POST through a redirect = %v", err)
+ }
+ if followed {
+ t.Fatal("the client followed a redirect — Authorization would have crossed hosts")
+ }
+}
+
+// resetMovedOffer clears the once-per-process offer guard for one test.
+func resetMovedOffer(t *testing.T) {
+ t.Helper()
+ consoleMovedOffered = false
+ t.Cleanup(func() { consoleMovedOffered = false })
+}
+
+func TestConsoleMovedOfferLogsInAndRepointsConfig(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ out := captureStdout(t)
+ forceTerminal(t, true)
+ resetMovedOffer(t)
+ fc := newFakeConsole(t) // the console's NEW home — real device flow
+ old := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Location", fc.ts.URL+r.URL.Path)
+ w.WriteHeader(http.StatusPermanentRedirect)
+ }))
+ t.Cleanup(old.Close)
+ cfgPath := writeConfig(t, ".", `{"console": "`+old.URL+`", "account": "acme"}`)
+ oldIn := chooserIn
+ chooserIn = strings.NewReader("y\ny\n") // log in? yes. repoint config? yes.
+ t.Cleanup(func() { chooserIn = oldIn })
+
+ s := newTestSession(old.URL)
+ _, _, err := s.getJSON(context.Background(), "/api/cli/apps")
+ if err == nil || !strings.Contains(err.Error(), "re-run your command") {
+ t.Fatalf("offer accepted = %v, want the re-run message", err)
+ }
+ // The device flow really ran against the new host…
+ f, ferr := loadCredentials()
+ if ferr != nil || f.find(fc.ts.URL) == nil {
+ t.Fatalf("no credential for the new console after the offer: %v", ferr)
+ }
+ // …and the config was repointed, keeping the account key.
+ cfg, cerr := loadConfig(cfgPath)
+ if cerr != nil || cfg.Console != fc.ts.URL || cfg.Account != "acme" {
+ t.Fatalf("config after repoint = %+v, %v; want new console + acme kept", cfg, cerr)
+ }
+ if !strings.Contains(out.String(), "this console has moved") {
+ t.Fatalf("the move must be explained:\n%s", out.String())
+ }
+ if !strings.Contains(out.String(), "to point at "+fc.ts.URL) {
+ t.Fatalf("the repoint prompt must name the destination:\n%s", out.String())
+ }
+
+ // The offer already ran once this process: a second hit — a polling
+ // verb's next tick — gets the typed error and NO prompt, even with
+ // answers queued up.
+ out.Reset()
+ chooserIn = strings.NewReader("y\ny\n")
+ _, _, err = s.getJSON(context.Background(), "/api/cli/apps")
+ if err == nil || !strings.Contains(err.Error(), "this console has moved") {
+ t.Fatalf("second hit = %v, want the plain typed error", err)
+ }
+ if strings.Contains(out.String(), "[y/N]") {
+ t.Fatalf("a second prompt fired:\n%s", out.String())
+ }
+}
+
+// whoami's memberships line degrades silently even through a MOVED console
+// — a bonus line must never turn into a prompt (the quietConsoleOffers
+// suppression).
+func TestWhoamiStaysSilentThroughAMovedConsole(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ captureStdout(t)
+ forceTerminal(t, true)
+ resetMovedOffer(t)
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /auth/cli/whoami", func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{"address": "tom@keymail.dev"})
+ })
+ mux.HandleFunc("GET /api/cli/apps", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Location", "https://console.elsewhere.example/api/cli/apps")
+ w.WriteHeader(http.StatusPermanentRedirect)
+ })
+ ts := httptest.NewServer(mux)
+ t.Cleanup(ts.Close)
+ f, _ := loadCredentials()
+ f.put(credential{Console: ts.URL, Address: "tom@keymail.dev", Token: "tok",
+ CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)})
+ if err := f.save(); err != nil {
+ t.Fatal(err)
+ }
+
+ var out bytes.Buffer
+ if err := authWhoami(&out, []string{"-console", ts.URL}); err != nil {
+ t.Fatalf("whoami through a moved memberships endpoint: %v", err)
+ }
+ if strings.Contains(out.String(), "moved") || strings.Contains(out.String(), "[y/N]") ||
+ strings.Contains(out.String(), "accounts:") {
+ t.Fatalf("whoami must degrade to identity-only silence:\n%s", out.String())
+ }
+ if consoleMovedOffered {
+ t.Fatal("whoami consumed the once-per-process offer")
+ }
+}
+
+// resolveMode on a terminal offers to run the login it would otherwise
+// only name — and then the ORIGINAL command continues (#162).
+func TestResolveModeOffersLoginAndContinues(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ captureStdout(t)
+ forceTerminal(t, true)
+ fc := newFakeConsole(t)
+ writeConfig(t, ".", `{"console": "`+fc.ts.URL+`"}`)
+ oldIn := chooserIn
+ chooserIn = strings.NewReader("y\n")
+ t.Cleanup(func() { chooserIn = oldIn })
+
+ sess, err := resolveMode("")
+ if err != nil || sess == nil {
+ t.Fatalf("resolveMode after offered login = %v, %v", sess, err)
+ }
+ if sess.console != fc.ts.URL || sess.address != "paul@keymail.dev" {
+ t.Fatalf("session = %s as %s, want the fake console as paul", sess.console, sess.address)
+ }
+}
+
+func TestResolveModeOfferDeclinedKeepsTheError(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ captureStdout(t)
+ forceTerminal(t, true)
+ fc := newFakeConsole(t)
+ writeConfig(t, ".", `{"console": "`+fc.ts.URL+`"}`)
+ oldIn := chooserIn
+ chooserIn = strings.NewReader("n\n")
+ t.Cleanup(func() { chooserIn = oldIn })
+
+ _, err := resolveMode("")
+ if err == nil || !strings.Contains(err.Error(), "not logged in to "+fc.ts.URL) ||
+ !strings.Contains(err.Error(), "carlos auth login -console "+fc.ts.URL) {
+ t.Fatalf("declined offer = %v, want the original refusal verbatim", err)
+ }
+}
+
+func TestResolveModeNonTTYErrorsUnchanged(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ captureStdout(t)
+ forceTerminal(t, false)
+ fc := newFakeConsole(t)
+ writeConfig(t, ".", `{"console": "`+fc.ts.URL+`"}`)
+
+ // No prompt machinery runs at all: scripts get the string they have
+ // always matched.
+ _, err := resolveMode("")
+ if err == nil || !strings.Contains(err.Error(), "not logged in to "+fc.ts.URL) {
+ t.Fatalf("non-TTY = %v, want today's refusal", err)
+ }
+
+ // And the nothing-configured case keeps ITS exact wording too.
+ os.RemoveAll(".carlos")
+ if _, err := resolveMode(""); err == nil ||
+ !strings.Contains(err.Error(), "no deployment target configured") {
+ t.Fatalf("non-TTY unconfigured = %v", err)
+ }
+}
+
+func TestResolveModeExpiredOffersRelogin(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ out := captureStdout(t)
+ forceTerminal(t, true)
+ fc := newFakeConsole(t)
+ f, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "stale",
+ CreatedAt: time.Now().Add(-48 * time.Hour), ExpiresAt: time.Now().Add(-time.Hour)})
+ if err := f.save(); err != nil {
+ t.Fatal(err)
+ }
+ oldIn := chooserIn
+ chooserIn = strings.NewReader("y\n")
+ t.Cleanup(func() { chooserIn = oldIn })
+
+ // Only one credential and it is expired — pick's "" branch reaches the
+ // expired-first rewrite, which must offer a re-login, and the fresh
+ // credential must carry the NEW token.
+ sess, err := resolveMode("")
+ if err != nil || sess == nil || sess.console != fc.ts.URL {
+ t.Fatalf("expired relogin = %v, %v", sess, err)
+ }
+ if !strings.Contains(out.String(), "has expired") {
+ t.Fatalf("the expiry must be said before the offer:\n%s", out.String())
+ }
+}
+
+func TestResolveModeUnconfiguredPromptsForURL(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ captureStdout(t)
+ forceTerminal(t, true)
+ fc := newFakeConsole(t)
+ oldIn := chooserIn
+ chooserIn = strings.NewReader(fc.ts.URL + "\n")
+ t.Cleanup(func() { chooserIn = oldIn })
+
+ // An empty answer keeps the original two-remedies error. (Checked
+ // FIRST — a successful login below leaves a credential behind, and
+ // resolveMode would simply use it.)
+ chooserIn = strings.NewReader("\n")
+ if _, err := resolveMode(""); err == nil ||
+ !strings.Contains(err.Error(), "no deployment target configured") {
+ t.Fatalf("empty URL = %v", err)
+ }
+
+ // Nothing configured anywhere: the URL prompt IS the consent — typing
+ // one goes straight into the login flow.
+ chooserIn = strings.NewReader(fc.ts.URL + "\n")
+ sess, err := resolveMode("")
+ if err != nil || sess == nil || sess.console != fc.ts.URL {
+ t.Fatalf("URL-prompt login = %v, %v", sess, err)
+ }
+}
+
+func TestResolvedAccountExplicitFlagsBypass(t *testing.T) {
+ useTempCredentials(t)
+ captureStdout(t)
+ s, hits := accountsConsole(t, http.StatusOK, `{"accounts":[]}`)
+
+ raw, rawID := "acme", ""
+ if a := resolvedAccount(s, &raw, &rawID); a.Seg != "acme" || a.Exact {
+ t.Fatalf("-account acme = %+v", a)
+ }
+ raw, rawID = "", "bdk"
+ if a := resolvedAccount(s, &raw, &rawID); a.Seg != "bdk" || !a.Exact {
+ t.Fatalf("-account-id bdk = %+v", a)
+ }
+ if *hits != 0 {
+ t.Fatalf("explicit flags must never consult the endpoint; hits = %d", *hits)
+ }
+}
diff --git a/appconfig_cli.go b/appconfig_cli.go
index 59b10801a..e9b382c98 100644
--- a/appconfig_cli.go
+++ b/appconfig_cli.go
@@ -215,7 +215,7 @@ func runEnvSet(args []string) {
for k, v := range kv {
set[k] = appconfig.Var{Kind: "env", Value: v}
}
- if _, err := sess.configApply(context.Background(), apiAccount(acctRaw, acctIDRaw), *app,
+ if _, err := sess.configApply(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app,
apiConfigApply{Environment: *environment, Set: set}); err != nil {
log.Fatal(err)
}
@@ -247,7 +247,7 @@ func runEnvUnset(args []string) {
}
if sess != nil {
printUsing(os.Stdout, sess)
- removed, err := sess.configApply(context.Background(), apiAccount(acctRaw, acctIDRaw), *app,
+ removed, err := sess.configApply(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app,
apiConfigApply{Environment: *environment, Unset: fs.Args()})
if err != nil {
log.Fatal(err)
@@ -281,7 +281,7 @@ func runEnvList(args []string) {
}
if sess != nil {
printUsing(os.Stdout, sess)
- cfg, err := sess.configGet(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, *environment)
+ cfg, err := sess.configGet(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, *environment)
if err != nil {
log.Fatal(err)
}
@@ -397,7 +397,7 @@ func runEnvExecDelivery(args []string) {
if value != "" {
wire = value
}
- if _, err := sess.configApply(context.Background(), apiAccount(acctRaw, acctIDRaw), *app,
+ if _, err := sess.configApply(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app,
apiConfigApply{ExecDelivery: &wire}); err != nil {
log.Fatal(err)
}
@@ -427,7 +427,7 @@ func runEnvEnvironments(args []string) {
}
if sess != nil {
printUsing(os.Stdout, sess)
- cfg, err := sess.configGet(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, "")
+ cfg, err := sess.configGet(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, "")
if err != nil {
log.Fatal(err)
}
@@ -477,7 +477,7 @@ func runEnvSync(args []string) {
}
if sess != nil {
printUsing(os.Stdout, sess)
- if err := sess.configSync(context.Background(), apiAccount(acctRaw, acctIDRaw), *app); err != nil {
+ if err := sess.configSync(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app); err != nil {
log.Fatal(err)
}
} else if err := appconfig.TouchGeneration(context.Background(), openStore(), acctOf(), *app); err != nil {
@@ -608,7 +608,13 @@ func runSecretsSet(args []string) {
}
if sess != nil {
printUsing(os.Stdout, sess)
- pub := secretsPubKeyAPI(sess, apiAccount(acctRaw, acctIDRaw), *app)
+ // Resolved ONCE, used for both the seal key and the write: two
+ // resolutions here could answer differently (the chooser asked
+ // twice), sealing the value with one account's public key and
+ // writing it into another's config — an undecryptable secret whose
+ // only symptom is a render failure in the hourly adopt log.
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ pub := secretsPubKeyAPI(sess, acct, *app)
set := make(map[string]appconfig.Var, len(kv))
for k, v := range kv {
env, err := appconfig.Seal(pub, []byte(v))
@@ -617,7 +623,7 @@ func runSecretsSet(args []string) {
}
set[k] = appconfig.Var{Kind: "secret", Envelope: env}
}
- if _, err := sess.configApply(context.Background(), apiAccount(acctRaw, acctIDRaw), *app,
+ if _, err := sess.configApply(context.Background(), acct, *app,
apiConfigApply{Environment: *environment, Set: set}); err != nil {
log.Fatal(err)
}
@@ -658,7 +664,7 @@ func runSecretsUnset(args []string) {
}
if sess != nil {
printUsing(os.Stdout, sess)
- removed, err := sess.configApply(context.Background(), apiAccount(acctRaw, acctIDRaw), *app,
+ removed, err := sess.configApply(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app,
apiConfigApply{Environment: *environment, Unset: fs.Args()})
if err != nil {
log.Fatal(err)
@@ -704,7 +710,7 @@ func runSecretsList(args []string) {
}
if sess != nil {
printUsing(os.Stdout, sess)
- cfg, err := sess.configGet(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, *environment)
+ cfg, err := sess.configGet(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, *environment)
if err != nil {
log.Fatal(err)
}
diff --git a/apps.go b/apps.go
index 6686af179..baee56d07 100644
--- a/apps.go
+++ b/apps.go
@@ -75,7 +75,8 @@ const appsUsage = `usage: carlos apps <create|place|delete|restore> [flags] <app
— but NO routes come back, not even the platform apex:
run "carlos add <host>" afterwards to serve it again.
- -account <sqid|name> account the app belongs to (default: ops) — resolved
+ -account <sqid|name> account the app belongs to (default: your only
+ account, or the configured/chosen one) — resolved
by the console against your own memberships; signed-in
mode only.
-account-id <sqid> account sqid, matched as a sqid ONLY — the only
@@ -199,8 +200,8 @@ func runAppsDelete(args []string) {
}
if mode == "signed-in" {
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
res, err := sess.trashApp(context.Background(), acct, app)
if err != nil {
log.Fatal(err)
@@ -319,8 +320,8 @@ func runAppsRestore(args []string) {
}
if mode == "signed-in" {
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
res, err := sess.restoreApp(context.Background(), acct, app)
if err != nil {
log.Fatal(err)
@@ -382,7 +383,7 @@ func runAppsCreate(args []string) {
// Not accountFlags: that helper also registers -registry for direct-mode
// resolution, and this verb never resolves locally — the console resolves
// the account against the caller's own memberships.
- acctRaw := fs.String("account", "ops", "account (sqid or name) to claim the app in")
+ acctRaw := fs.String("account", "", "account (sqid or name) to claim the app in (default: your only account, or the configured/chosen one)")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account; this is the flag an ambiguity error tells you to use")
consoleFlag := fs.String("console", "", "console to create it through (default: the only logged-in one)")
fs.Parse(args)
@@ -415,8 +416,8 @@ func runAppsCreate(args []string) {
"box-side way to hand a name to an address. To claim through a console instead,\n" +
"unset the store env and run: carlos auth login -console <url>")
}
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
res, err := sess.createApp(context.Background(), acct, strings.TrimSpace(*app), place2)
if err != nil {
log.Fatalf("apps create: %v", err)
@@ -475,7 +476,7 @@ func runAppsPlace(args []string) {
// Not accountFlags: that helper also registers -registry for direct-mode
// resolution, and this verb never resolves locally — placement is
// console-tracked state, API mode only, same as apps create above.
- acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account; this is the flag an ambiguity error tells you to use")
consoleFlag := fs.String("console", "", "console to place through (default: the only logged-in one)")
fs.Parse(args)
@@ -519,7 +520,7 @@ func runAppsPlace(args []string) {
"(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Placement is console-tracked\n" +
"state: log in instead — carlos auth login -console <url>")
}
- acct := apiAccount(acctRaw, acctIDRaw)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
if err := sess.place(context.Background(), acct, strings.TrimSpace(*app), target2); err != nil {
log.Fatalf("apps place: %v", err)
diff --git a/auth.go b/auth.go
index ade8dfe54..81c66d55f 100644
--- a/auth.go
+++ b/auth.go
@@ -285,11 +285,13 @@ func (f *credentialFile) pick(console string) (*credential, error) {
}
c := f.find(want)
if c == nil {
- return nil, fmt.Errorf("not logged in to %s%s — run: carlos auth login -console %s", want, from, want)
+ return nil, &notLoggedInError{Console: want,
+ msg: fmt.Sprintf("not logged in to %s%s — run: carlos auth login -console %s", want, from, want)}
}
if c.Expired(time.Now()) {
- return nil, fmt.Errorf("the credential for %s%s expired %s — run: carlos auth login -console %s",
- want, from, c.ExpiresAt.Format(time.RFC3339), want)
+ return nil, &notLoggedInError{Console: want, Expired: true,
+ msg: fmt.Sprintf("the credential for %s%s expired %s — run: carlos auth login -console %s",
+ want, from, c.ExpiresAt.Format(time.RFC3339), want)}
}
return c, nil
}
@@ -316,6 +318,18 @@ func (f *credentialFile) pick(console string) (*credential, error) {
}
}
+// notLoggedInError is pick's refusal when a NAMED console has no live
+// credential — typed so resolveMode's interactive path (#162) can see
+// which console to offer a login for, while Error() stays byte-identical
+// to the strings scripts have matched since the credential file existed.
+type notLoggedInError struct {
+ Console string
+ Expired bool
+ msg string
+}
+
+func (e *notLoggedInError) Error() string { return e.msg }
+
// credentialPermWarning reports a credentials file other users can read.
// A warning rather than a refusal: locking someone out of their own tooling
// over a mode bit helps nobody, but saying nothing lets a world-readable
@@ -379,7 +393,15 @@ type consoleClient struct {
}
func newConsoleClient(base, token string) *consoleClient {
- return &consoleClient{base: base, token: token, httpc: &http.Client{Timeout: 30 * time.Second}}
+ // Redirects are NEVER followed (#160): Go's default policy follows a
+ // cross-host GET with Authorization stripped (a mystery 401 from the
+ // new host) and surfaces unreplayable POSTs as raw 3xx — both wrong.
+ // Refusing uniformly lets every method see the 3xx + Location, which
+ // consoleMoved (apimode.go) turns into an explanation and an offer.
+ return &consoleClient{base: base, token: token, httpc: &http.Client{
+ Timeout: 30 * time.Second,
+ CheckRedirect: neverFollow,
+ }}
}
// do runs one JSON round-trip. A non-200 comes back carrying the console's
@@ -409,6 +431,9 @@ func (c *consoleClient) do(ctx context.Context, method, path string, in, out any
return 0, err
}
defer res.Body.Close()
+ if merr := consoleMoved(c.base, res.StatusCode, res.Header.Get("Location")); merr != nil {
+ return res.StatusCode, offerConsoleMoved(merr)
+ }
raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return res.StatusCode, err
@@ -482,13 +507,16 @@ func (c *consoleClient) doMultipart(ctx context.Context, path string, write func
}
// A client of its own — see shipHTTPClientTimeout above — never c.httpc,
// whose 30s absolute Timeout is sized for the JSON verbs, not a large
- // upload.
- client := &http.Client{Timeout: shipHTTPClientTimeout}
+ // upload. Same no-redirect rule as c.httpc, same reason.
+ client := &http.Client{Timeout: shipHTTPClientTimeout, CheckRedirect: neverFollow}
res, err := client.Do(req)
if err != nil {
return 0, nil, err
}
defer res.Body.Close()
+ if merr := consoleMoved(c.base, res.StatusCode, res.Header.Get("Location")); merr != nil {
+ return res.StatusCode, nil, offerConsoleMoved(merr)
+ }
body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return res.StatusCode, nil, err
@@ -496,6 +524,11 @@ func (c *consoleClient) doMultipart(ctx context.Context, path string, write func
return res.StatusCode, body, nil
}
+// neverFollow is the one redirect policy every console client uses.
+func neverFollow(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+}
+
type startResponse struct {
UserCode string `json:"user_code"`
DeviceCode string `json:"device_code"`
@@ -732,6 +765,24 @@ func authWhoami(out io.Writer, args []string) error {
if who.ExpiresAt != "" {
mutedf(out, " expires: %s", who.ExpiresAt)
}
+ // Memberships are a bonus line, not part of whoami's contract: identity
+ // already printed, and a console from before GET /api/cli/apps (or a
+ // blip fetching it) must not make whoami look broken — so ANY error
+ // here degrades to silence rather than a message, and the moved-console
+ // OFFER is suppressed too: a bonus line must never turn into a prompt.
+ quietConsoleOffers = true
+ defer func() { quietConsoleOffers = false }()
+ var acc struct {
+ Accounts []apiAccountEntry `json:"accounts"`
+ }
+ if _, err := newConsoleClient(c.Console, c.Token).
+ do(context.Background(), http.MethodGet, "/api/cli/apps", nil, &acc); err == nil && len(acc.Accounts) > 0 {
+ rows := make([]string, 0, len(acc.Accounts))
+ for _, a := range acc.Accounts {
+ rows = append(rows, fmt.Sprintf("%s (%s)", a.Name, a.Sqid))
+ }
+ mutedf(out, " accounts: %s", strings.Join(rows, ", "))
+ }
return nil
}
diff --git a/auth_test.go b/auth_test.go
index bb8a50c74..6ace3ddac 100644
--- a/auth_test.go
+++ b/auth_test.go
@@ -182,13 +182,14 @@ func TestPollBoundsAreEnforced(t *testing.T) {
// fakeConsole answers the CLI endpoints the way the real console does, so the
// login loop can be driven end to end without a browser.
type fakeConsole struct {
- ts *httptest.Server
- polls atomic.Int32
- approved atomic.Bool
- denied atomic.Bool
- revoked atomic.Bool
- lastAuth atomic.Value // string: the bearer the last request carried
- label atomic.Value // string
+ ts *httptest.Server
+ polls atomic.Int32
+ approved atomic.Bool
+ denied atomic.Bool
+ revoked atomic.Bool
+ noAccounts atomic.Bool // serve 404 on GET /api/cli/apps: a pre-5856dc5 console
+ lastAuth atomic.Value // string: the bearer the last request carried
+ label atomic.Value // string
}
func newFakeConsole(t *testing.T) *fakeConsole {
@@ -237,6 +238,21 @@ func newFakeConsole(t *testing.T) *fakeConsole {
"expires_at": time.Now().Add(90 * 24 * time.Hour).UTC().Format(time.RFC3339),
})
})
+ mux.HandleFunc("GET /api/cli/apps", func(w http.ResponseWriter, r *http.Request) {
+ if fc.noAccounts.Load() {
+ http.NotFound(w, r)
+ return
+ }
+ if r.Header.Get("Authorization") != "Bearer cli-token" {
+ w.WriteHeader(http.StatusUnauthorized)
+ json.NewEncoder(w).Encode(map[string]string{"error": "not signed in"})
+ return
+ }
+ json.NewEncoder(w).Encode(map[string]any{"accounts": []map[string]any{
+ {"sqid": "bab", "name": "paul@keymail.dev", "role": "owner", "apps": []string{}},
+ {"sqid": "bce", "name": "acme", "role": "member", "apps": []string{"shop"}},
+ }})
+ })
mux.HandleFunc("POST /auth/cli/revoke", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer cli-token" {
w.WriteHeader(http.StatusUnauthorized)
@@ -325,6 +341,41 @@ func TestAuthLoginStoresACredential(t *testing.T) {
}
}
+// whoami closes the loop #157 opened: who am I, AND what can I act on.
+// Memberships come from GET /api/cli/apps; a console too old to answer
+// (bare 404) must not make whoami look broken — identity prints, the
+// bonus line is simply absent.
+func TestAuthWhoamiPrintsMemberships(t *testing.T) {
+ useTempCredentials(t)
+ fc := newFakeConsole(t)
+ var out bytes.Buffer
+ if err := authLogin(&out, []string{"-console", fc.ts.URL}); err != nil {
+ t.Fatalf("authLogin: %v", err)
+ }
+
+ out.Reset()
+ if err := authWhoami(&out, []string{"-console", fc.ts.URL}); err != nil {
+ t.Fatalf("authWhoami: %v", err)
+ }
+ if !strings.Contains(out.String(), "accounts: paul@keymail.dev (bab), acme (bce)") {
+ t.Fatalf("whoami must list memberships:\n%s", out.String())
+ }
+
+ // Same command against a console that predates the endpoint: identity
+ // only, silently — no error, no accounts line.
+ fc.noAccounts.Store(true)
+ out.Reset()
+ if err := authWhoami(&out, []string{"-console", fc.ts.URL}); err != nil {
+ t.Fatalf("authWhoami against an old console: %v", err)
+ }
+ if !strings.Contains(out.String(), "paul@keymail.dev at "+fc.ts.URL) {
+ t.Fatalf("identity line missing:\n%s", out.String())
+ }
+ if strings.Contains(out.String(), "accounts:") {
+ t.Fatalf("old console must degrade silently:\n%s", out.String())
+ }
+}
+
func TestAuthLoginDenied(t *testing.T) {
useTempCredentials(t)
fc := newFakeConsole(t)
diff --git a/cli.go b/cli.go
index 15cd17b63..62653d8c7 100644
--- a/cli.go
+++ b/cli.go
@@ -1577,7 +1577,11 @@ func routeVersionCheck(ctx context.Context, store release.Store, pub ed25519.Pub
// string-coupled to this function's internal flag name the way
// fs.Lookup("account") would be.
func accountFlags(fs *flag.FlagSet) (raw, rawID *string, resolve func() string) {
- acct := fs.String("account", "ops", "account (sqid or name) the app belongs to")
+ // Empty default, two meanings by mode: API mode resolves it to the
+ // caller's own account (resolvedAccount, apimode.go); direct mode's
+ // resolveAccount reads "" as ops exactly as it always has, so box and
+ // script behavior is unchanged.
+ acct := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one; ops in direct mode)")
acctID := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account; this is the flag an ambiguity error tells you to use")
regPath := fs.String("registry", defaultRegistry(), "registry database (resolves -account)")
return acct, acctID, func() string { return resolveAccount(*regPath, *acct, *acctID) }
@@ -1713,13 +1717,19 @@ func runShip(args []string) {
log.Fatal(err)
}
if sess != nil {
- // apiAccount is resolved HERE, not inside runShipAPI: it fatals on a
- // name in -account-id, and log.Fatal skips every deferred function —
- // which is the one thing runShipAPI exists to avoid (its static
- // branch defers the cleanup of a scratch tarball up to 256MiB).
- // Evaluated as an argument, the refusal lands before runShipAPI's
- // body has registered a defer at all.
- if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, *app, *ver, *kind, *target, *label, noteText, capsSet, true); err != nil {
+ // The banner prints BEFORE the account resolves: resolution may put
+ // a muted "account: …" line — or the whole interactive chooser — on
+ // the terminal, and a prompt with no console named yet is a question
+ // with no context. The account is still resolved HERE, not inside
+ // runShipAPI: resolvedAccount fatals (on a name in -account-id, or
+ // an ambiguous omitted -account), and log.Fatal skips every deferred
+ // function — which is the one thing runShipAPI exists to avoid (its
+ // static branch defers the cleanup of a scratch tarball up to
+ // 256MiB). Resolved before the call, the refusal lands before
+ // runShipAPI's body has registered a defer at all.
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ if err := runShipAPI(sess, acct, fs, *app, *ver, *kind, *target, *label, noteText, capsSet, false); err != nil {
log.Fatal(err)
}
return
@@ -1983,8 +1993,8 @@ func runPromote(args []string) {
// flag silently do nothing.
log.Fatal("hotfix is an operator act — use the direct path (CARLOS_DEPLOYMENT_BUCKET) or the console")
}
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
res, err := sess.promote(context.Background(), acct, *app, version, channel)
if err != nil {
log.Fatal(err)
@@ -2059,8 +2069,8 @@ func runChannels(args []string) {
log.Fatal(err)
}
if sess != nil {
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
chans, err := sess.channels(context.Background(), acct, *app)
if err != nil {
log.Fatal(err)
@@ -2156,8 +2166,8 @@ func runVersionTarget(args []string) {
log.Fatal(err)
}
if sess != nil {
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
if fs.NArg() == 0 {
vt, err := sess.versionTarget(context.Background(), acct, *app)
if err != nil {
@@ -2301,8 +2311,8 @@ func runReleases(args []string) {
}
var rows []releaseRow
if sess != nil {
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
rels, err := sess.releases(context.Background(), acct, *app)
if err != nil {
log.Fatal(err)
@@ -2348,8 +2358,8 @@ func runRollback(args []string) {
log.Fatal(err)
}
if sess != nil {
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
if err := sess.rollback(context.Background(), acct, *app, fs.Arg(0)); err != nil {
log.Fatal(err)
}
@@ -2469,7 +2479,7 @@ func ledgerAPISession(consoleFlag string) *apiSession {
// sqid only, so the console's own symmetric resolution can never name-match
// it.
func ledgerAccountFlags(fs *flag.FlagSet) (raw, rawID *string) {
- return fs.String("account", "ops", "account (sqid or name) the app belongs to — resolved by the console"),
+ return fs.String("account", "", "account (sqid or name) the app belongs to — resolved by the console (default: your only account, or the configured/chosen one)"),
fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
}
@@ -2506,7 +2516,7 @@ func runLedgerAppend(args []string) {
sess := ledgerAPISession(*consoleFlag)
printUsing(stdout, sess)
- a, err := sess.ledgerAppend(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, name, json.RawMessage(raw))
+ a, err := sess.ledgerAppend(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, name, json.RawMessage(raw))
if err != nil {
log.Fatal(err)
}
@@ -2532,7 +2542,7 @@ func runLedgerHead(args []string) {
}
sess := ledgerAPISession(*consoleFlag)
- h, err := sess.ledgerHead(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, name)
+ h, err := sess.ledgerHead(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, name)
if err != nil {
log.Fatal(err)
}
@@ -2555,7 +2565,7 @@ func runLedgerList(args []string) {
sess := ledgerAPISession(*consoleFlag)
printUsing(stdout, sess)
- body, err := sess.ledgerList(context.Background(), apiAccount(acctRaw, acctIDRaw), *app)
+ body, err := sess.ledgerList(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app)
if err != nil {
log.Fatal(err)
}
@@ -2612,7 +2622,7 @@ func runLedgerPublish(args []string) {
sess := ledgerAPISession(*consoleFlag)
printUsing(stdout, sess)
m := ledger.Manifest{Public: *public, Ledgers: names, Title: *title}
- got, err := sess.ledgerPublish(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, m)
+ got, err := sess.ledgerPublish(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, m)
if err != nil {
log.Fatal(err)
}
@@ -2658,7 +2668,7 @@ func runLedgerBlob(args []string) {
if len(data) > ledger.MaxBlob {
log.Fatalf("ledger blob: %s is %d bytes, exceeds the %d byte cap", path, len(data), ledger.MaxBlob)
}
- sha, err := sess.ledgerBlob(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, name, data)
+ sha, err := sess.ledgerBlob(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, name, data)
if err != nil {
log.Fatal(err)
}
diff --git a/config.go b/config.go
index b11c3171c..4ea31d193 100644
--- a/config.go
+++ b/config.go
@@ -34,38 +34,55 @@ import (
// only; no other verb ever assumes it.
const defaultConsole = "https://platform.carlosframework.com"
-// cliConfig is the whole file. Unknown fields are ignored on read and
-// dropped on rewrite: the file is additive, and the CLI is its only parser.
+// cliConfig is the whole file. Unknown fields are ignored on read and — since
+// the account key landed — PRESERVED on rewrite (writeConfigKeys): the file
+// is additive, other tools may learn to read it, and a rewrite that drops
+// what it doesn't understand makes every future key lossy against the binary
+// that predates it.
type cliConfig struct {
Console string `json:"console"`
+ Account string `json:"account"`
+ // UpdateCheck: "off" disables the once-a-day newer-release hint
+ // (update.go). Any other value — including absent — leaves it on.
+ UpdateCheck string `json:"update_check"`
}
-// loadConfigFile reads one .carlos/config. A missing file, or one without a
-// console, is ("", nil): "no default at this level", and resolution
-// continues. A file that cannot be parsed, or names a console
+// loadConfig reads one .carlos/config whole. A missing file is a zero
+// config, not an error. A file that cannot be parsed, or names a console
// normalizeConsole refuses, is an ERROR — silently ignoring a typo'd
// config would ship to the wrong console, the exact mistake this file
-// exists to prevent.
-func loadConfigFile(path string) (string, error) {
+// exists to prevent. The console comes back normalized ("" when unset);
+// the account comes back trimmed.
+func loadConfig(path string) (cliConfig, error) {
raw, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
- return "", nil
+ return cliConfig{}, nil
}
if err != nil {
- return "", err
+ return cliConfig{}, err
}
var cfg cliConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
- return "", fmt.Errorf("%s is not readable as a carlos config: %w", path, err)
+ return cliConfig{}, fmt.Errorf("%s is not readable as a carlos config: %w", path, err)
}
if strings.TrimSpace(cfg.Console) == "" {
- return "", nil
- }
- n, err := normalizeConsole(cfg.Console)
- if err != nil {
- return "", fmt.Errorf("%s: %w", path, err)
+ cfg.Console = ""
+ } else {
+ n, err := normalizeConsole(cfg.Console)
+ if err != nil {
+ return cliConfig{}, fmt.Errorf("%s: %w", path, err)
+ }
+ cfg.Console = n
}
- return n, nil
+ cfg.Account = strings.TrimSpace(cfg.Account)
+ return cfg, nil
+}
+
+// loadConfigFile is loadConfig's console-only view — the shape every
+// console-default caller predating the account key still reads.
+func loadConfigFile(path string) (string, error) {
+ cfg, err := loadConfig(path)
+ return cfg.Console, err
}
// configDefault resolves the configured default console: the nearest
@@ -111,6 +128,49 @@ func configDefault() (console, source string, err error) {
return c, path, nil
}
+// configAccount resolves the configured default ACCOUNT: the nearest
+// .carlos/config walking up from the working directory whose account key is
+// set, else ~/.carlos/config. A level that names only a console does not
+// stop this walk — console and account are separate defaults that happen to
+// share a file. Returns the account, the file it came from, and that SAME
+// file's console ("" when it names none): the pairing rule lives at the
+// caller (apimode.go defaultAccount), which honors a file's account only
+// for the console the file itself names — a config pair points at one
+// stack, and -console to a different stack must not drag the other stack's
+// account along.
+func configAccount() (account, source, fileConsole string, err error) {
+ if dir, werr := os.Getwd(); werr == nil {
+ for {
+ path := filepath.Join(dir, ".carlos", "config")
+ cfg, err := loadConfig(path)
+ if err != nil {
+ return "", "", "", err
+ }
+ if cfg.Account != "" {
+ return cfg.Account, path, cfg.Console, nil
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ break
+ }
+ dir = parent
+ }
+ }
+ path, perr := globalConfigPath()
+ if perr != nil {
+ // No home directory means no global config — a state, not an error.
+ return "", "", "", nil
+ }
+ cfg, err := loadConfig(path)
+ if err != nil {
+ return "", "", "", err
+ }
+ if cfg.Account == "" {
+ return "", "", "", nil
+ }
+ return cfg.Account, path, cfg.Console, nil
+}
+
// globalConfigPath is ~/.carlos/config — the same directory that holds
// credentials, though this file is not a secret and never holds one.
func globalConfigPath() (string, error) {
@@ -121,22 +181,54 @@ func globalConfigPath() (string, error) {
return filepath.Join(home, ".carlos", "config"), nil
}
-// saveConfigFile validates the console and writes {"console": …} at path,
-// creating the .carlos directory (dirPerm: 0700 for ~/.carlos, which
-// MkdirAll leaves alone if it already exists; 0755 for a project directory
-// that is meant to be committed). Temp-file-then-rename like
-// credentialFile.save, so a crash cannot leave a truncated config — but
-// 0644, not 0600: this file is not a secret.
+// saveConfigFile validates the console and writes the "console" key at
+// path, preserving everything else the file holds (writeConfigKeys).
+// dirPerm: 0700 for ~/.carlos, which MkdirAll leaves alone if it already
+// exists; 0755 for a project directory that is meant to be committed.
func saveConfigFile(path, console string, dirPerm os.FileMode) error {
n, err := normalizeConsole(console)
if err != nil {
return err
}
+ return writeConfigKeys(path, map[string]any{"console": n}, dirPerm)
+}
+
+// saveConfigAccount writes a console+account pair — the chooser's "save as
+// this project's default" answer. The two keys land together deliberately:
+// configAccount's pairing rule reads them as one fact ("this checkout means
+// this account on this console"), so saving the account without pinning the
+// console would leave a default that silently stops applying the day the
+// directory's console default changes.
+func saveConfigAccount(path, console, account string, dirPerm os.FileMode) error {
+ n, err := normalizeConsole(console)
+ if err != nil {
+ return err
+ }
+ return writeConfigKeys(path, map[string]any{"console": n, "account": account}, dirPerm)
+}
+
+// writeConfigKeys rewrites path with the given keys set, keeping every
+// field it does not understand — a rewrite that drops unknown keys makes
+// every future config key lossy against the binary that predates it.
+// Temp-file-then-rename like credentialFile.save, so a crash cannot leave
+// a truncated config — but 0644, not 0600: this file is not a secret.
+func writeConfigKeys(path string, set map[string]any, dirPerm os.FileMode) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, dirPerm); err != nil {
return err
}
- body, err := json.MarshalIndent(cliConfig{Console: n}, "", " ")
+ all := map[string]any{}
+ if raw, err := os.ReadFile(path); err == nil {
+ if err := json.Unmarshal(raw, &all); err != nil {
+ return fmt.Errorf("%s is not readable as a carlos config: %w", path, err)
+ }
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ for k, v := range set {
+ all[k] = v
+ }
+ body, err := json.MarshalIndent(all, "", " ")
if err != nil {
return err
}
diff --git a/config_test.go b/config_test.go
index 4e835c085..54bf00f56 100644
--- a/config_test.go
+++ b/config_test.go
@@ -2,6 +2,7 @@ package main
import (
"bytes"
+ "encoding/json"
"os"
"path/filepath"
"strings"
@@ -325,3 +326,98 @@ func TestAuthDefaultVerb(t *testing.T) {
t.Fatal("two positional args must be a usage error")
}
}
+
+func TestConfigAccountWalk(t *testing.T) {
+ useTempCredentials(t)
+ home := os.Getenv("HOME")
+
+ // Nothing anywhere: no account default, no error.
+ if a, src, cons, err := configAccount(); err != nil || a != "" || src != "" || cons != "" {
+ t.Fatalf("bare configAccount = %q %q %q %v, want empty", a, src, cons, err)
+ }
+
+ // Only the global, and the file names no console: a pure account
+ // default, fileConsole empty.
+ globalPath := writeConfig(t, home, `{"account": "acme"}`)
+ a, src, cons, err := configAccount()
+ if err != nil || a != "acme" || src != globalPath || cons != "" {
+ t.Fatalf("global-only = %q %q %q %v", a, src, cons, err)
+ }
+
+ // A nearer file with only a console does not stop the ACCOUNT walk.
+ root := t.TempDir()
+ writeConfig(t, root, `{"console": "https://outer.example"}`)
+ nested := filepath.Join(root, "cmd", "deep")
+ if err := os.MkdirAll(nested, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Chdir(nested)
+ if a, _, _, err = configAccount(); err != nil || a != "acme" {
+ t.Fatalf("console-only level = %q %v, want the walk to continue to acme", a, err)
+ }
+
+ // The nearest account-bearing file wins and reports ITS console —
+ // normalized, because the pairing rule compares it to a session console.
+ nearest := writeConfig(t, nested, `{"console": "https://inner.example/", "account": "tools"}`)
+ a, src, cons, err = configAccount()
+ if err != nil || a != "tools" || src != nearest || cons != "https://inner.example" {
+ t.Fatalf("nearest = %q %q %q %v", a, src, cons, err)
+ }
+
+ // Malformed config mid-walk is an error, not a skip.
+ os.WriteFile(nearest, []byte(`not json`), 0o644)
+ if _, _, _, err = configAccount(); err == nil {
+ t.Fatal("malformed project config must be an error, not a skip")
+ }
+}
+
+func TestConfigRewritesPreserveUnknownFields(t *testing.T) {
+ useTempCredentials(t)
+ dir := t.TempDir()
+ path := filepath.Join(dir, ".carlos", "config")
+
+ // A fresh saveConfigAccount writes both keys.
+ if err := saveConfigAccount(path, "https://tito.example/", "acme", 0o755); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := loadConfig(path)
+ if err != nil || cfg.Console != "https://tito.example" || cfg.Account != "acme" {
+ t.Fatalf("fresh save = %+v, %v", cfg, err)
+ }
+
+ // A file holding a key from the future keeps it across BOTH rewrites —
+ // the old drop-on-rewrite behavior made every new key lossy.
+ if err := os.WriteFile(path, []byte(`{"console":"https://tito.example","future_key":42}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := saveConfigAccount(path, "https://tito.example", "tools", 0o755); err != nil {
+ t.Fatal(err)
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var all map[string]any
+ if err := json.Unmarshal(raw, &all); err != nil {
+ t.Fatal(err)
+ }
+ if all["future_key"] != float64(42) || all["account"] != "tools" {
+ t.Fatalf("saveConfigAccount dropped fields: %v", all)
+ }
+ if err := saveConfigFile(path, "https://other.example/", 0o755); err != nil {
+ t.Fatal(err)
+ }
+ raw, _ = os.ReadFile(path)
+ all = map[string]any{}
+ if err := json.Unmarshal(raw, &all); err != nil {
+ t.Fatal(err)
+ }
+ if all["future_key"] != float64(42) || all["account"] != "tools" || all["console"] != "https://other.example" {
+ t.Fatalf("saveConfigFile dropped fields: %v", all)
+ }
+
+ // The console is still validated on write, account or not.
+ if err := saveConfigAccount(path, "ftp://nope", "x", 0o755); err == nil {
+ t.Fatal("saving an invalid console must fail")
+ }
+}
diff --git a/deploy.go b/deploy.go
index 541e76a9a..111acdb81 100644
--- a/deploy.go
+++ b/deploy.go
@@ -59,8 +59,8 @@ func runDeploy(args []string) {
}
sess := requireDeployAPISession(*consoleFlag) // API-mode only: member verb
- acct := apiAccount(acctRaw, acctIDRaw)
- printUsing(stdout, sess) // printed HERE, once: runShipAPI's own banner is suppressed below
+ printUsing(stdout, sess) // printed HERE, once: runShipAPI's own banner is suppressed below
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
ctx := context.Background()
recs, _, _, err := sess.instanceList(ctx, acct, *app, "", "")
if err != nil {
diff --git a/docs/superpowers/plans/2026-08-12-account-default-resolution.md b/docs/superpowers/plans/2026-08-12-account-default-resolution.md
new file mode 100644
index 000000000..bab46d065
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-12-account-default-resolution.md
@@ -0,0 +1,122 @@
+# Account Default Resolution Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** When `-account` is omitted in API mode, resolve it from config or the caller's memberships (one → use it; several → interactive chooser with a save-to-project prompt), and expose memberships via `GET /api/cli/apps` (already live), `carlos accounts list`, and `carlos auth whoami`. Closes #157.
+
+**Architecture:** One new token-gated console endpoint returning `Store.AccountsFor(addr)`. CLI-side, a single resolution seam on `apiSession` (`defaultAccount`) called through a `resolvedAccount(sess, raw, rawID)` wrapper that replaces `apiAccount(...)` at every API-mode callsite; flag defaults flip `"ops"` → `""` (direct mode already reads `""` as ops, so boxes are untouched).
+
+**Tech Stack:** Go stdlib only; existing test fixtures (`internal/console` fx, CLI httptest patterns, `carlosTestEnv`).
+
+## Global Constraints
+
+- `go build ./...`, `go vet ./...`, `go test ./... -count=1` clean; `gofmt -l` clean for touched files; `GOOS=darwin` compile passes (CLI runs on Macs — no Linux-only assumptions).
+- Sandbox env for go commands: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`.
+- Wire changes additive only; membership 404 opacity byte-identical; direct/bucket mode behavior unchanged.
+- Registry-mode verbs (`carlos route`, `carlos add`, `accounts list` on a box) keep the `"ops"` default and local resolution.
+- Commits per task; spec: `docs/superpowers/specs/2026-08-12-account-default-resolution-design.md`.
+
+---
+
+### Task 1: ~~Console endpoint~~ SUPERSEDED — endpoint already exists
+
+`GET /api/cli/apps` (handleAPIApps, internal/console/authcli.go:219, live
+since main 5856dc5 2026-08-03, deployed both consoles) already returns
+`{"accounts":[{"sqid","name","role","apps"}]}` from `Store.AccountsFor`,
+leak-tested by TestAPIAppsListsMemberships. No console change, no console
+deploy. Task 4's `listAccounts` decodes this shape; the 404 fallback covers
+only pre-2026-08-03 consoles.
+
+### Task 2: Config `account` key + lossless rewrites
+
+**Files:**
+- Modify: `config.go` (`cliConfig` :39, `loadConfigFile` :50, `saveConfigFile` :124)
+- Test: `config_test.go`
+
+**Interfaces:**
+- Produces: `cliConfig.Account`; `configAccount() (name, source, fileConsole string, err error)` — nearest `.carlos/config` walking up (then global) **whose `account` is set**; `fileConsole` is that same file's normalized console ("" if unset). `saveConfigFile(path, console, perm)` and new `saveConfigAccount(path, console, account string, dirPerm os.FileMode)` both preserve unknown JSON fields on rewrite.
+
+- [ ] **Step 1: failing tests** — `account` key round-trips; console-only files return `"" ` from `configAccount` at that level and the walk continues; nearest account-bearing file wins and reports ITS console; rewrite of a file containing `{"console":…,"future_key":42}` keeps `future_key`; `saveConfigAccount` writes both keys with the file's dir at the given perm.
+- [ ] **Step 2: run** `go test . -run Config -count=1` → FAIL.
+- [ ] **Step 3: implement** — `Account string \`json:"account"\`` on `cliConfig`; refactor the read path to return the whole `cliConfig` (keep `loadConfigFile`'s console-normalization errors verbatim); `configAccount` mirrors `configDefault`'s walk but keys off `Account != ""`; rewrites read the existing file into `map[string]any` (missing file → empty map), set `"console"`/`"account"`, `json.MarshalIndent`, write with the existing perms logic.
+- [ ] **Step 4: pass**; **Step 5: commit** `cli: .carlos/config account key; rewrites preserve unknown fields`.
+
+### Task 3: Interactive chooser
+
+**Files:**
+- Create: `account_choose.go`, `account_choose_test.go`
+
+**Interfaces:**
+- Consumes: `charDevice` (color.go :93 — read its exact signature first and adapt).
+- Produces: `type apiAccountEntry struct{ Name, Sqid string }` (JSON tags `name`/`sqid` — Task 4 decodes into it); `chooseAccount(in io.Reader, out io.Writer, console string, accts []apiAccountEntry) (picked apiAccountEntry, save bool, err error)`; `interactiveTerminal() bool` (stdin AND stdout are char devices); `severalAccountsErr(accts []apiAccountEntry) error` producing exactly: `you belong to several accounts — say which with -account: tom@keymail.dev (bdk), acme (bce)`.
+
+- [ ] **Step 1: failing tests** — pick "2\n" returns accts[1]; junk then "1\n" re-prompts once; junk twice → `severalAccountsErr` text; EOF → same; save prompt: "y\n" → save=true, "\n"/"n\n" → false; output contains the numbered list and `account [1-2]:`.
+- [ ] **Step 2: run** → FAIL. **Step 3: implement** with `bufio.NewScanner(in)`, prompt loop (2 attempts), then `save <name> as this project's default account? [y/N]` reading one more line, `strings.EqualFold(answer, "y")`.
+- [ ] **Step 4: pass**; **Step 5: commit** `cli: interactive account chooser`.
+
+### Task 4: Resolution seam on apiSession
+
+**Files:**
+- Modify: `apimode.go` (near `apiAccount` :202)
+- Test: `apimode_test.go`
+
+**Interfaces:**
+- Consumes: `s.getJSON`, `apiErrorFromBody`, Task 2 `configAccount`, Task 3 chooser/entry/refusal, `saveConfigAccount`, `mutedf`, package `stdout`.
+- Produces:
+
+```go
+func (s *apiSession) listAccounts(ctx context.Context) (accts []apiAccountEntry, supported bool, err error)
+// 404 → (nil, false, nil): console predates the endpoint. Non-200/404 → apiErrorFromBody.
+
+func (s *apiSession) defaultAccount(ctx context.Context) (apiAcct, error)
+// order: configAccount (honored iff fileConsole=="" || fileConsole==s.console;
+// prints muted `account: <name> (the default from <source>)`) → listAccounts:
+// !supported → apiAcct{Seg:"ops"}; 0 → error naming s.console; 1 → muted
+// `account: <name> (<sqid>)`, apiAcct{Seg: sqid, Exact: true}; several →
+// interactiveTerminal() ? chooser (on save=true saveConfigAccount(
+// filepath.Join(".carlos","config"), s.console, picked.Name, 0o755) + muted
+// confirmation) : severalAccountsErr. Chosen entry returns Seg=sqid, Exact.
+
+func resolvedAccount(sess *apiSession, raw, rawID *string) apiAcct
+// apiAccount(raw, rawID); if Seg=="" && sess!=nil → sess.defaultAccount
+// (context.Background()), log.Fatal on error. sess==nil callers never reach
+// it (verbs split modes before touching it).
+```
+
+- [ ] **Step 1: failing tests** — httptest console serving `/api/cli/apps`: one account → Seg=sqid+Exact; several (non-TTY test env) → `severalAccountsErr` text; 404 → `{Seg:"ops"}`; explicit `-account`/`-account-id` never call the endpoint (assert via handler hit-counter); config account + matching console → its name, endpoint untouched; config console mismatch → falls through to memberships; expired-token 401 surfaces as an error, not a fallback.
+- [ ] **Step 2: run** → FAIL. **Step 3: implement.** **Step 4: pass.** **Step 5: commit** `cli: resolve omitted -account from config or memberships`.
+
+### Task 5: Flag-default flip + callsite sweep
+
+**Files:**
+- Modify: `cli.go` (:1555 accountFlags, :2447 ledgerAccountFlags, ship :1697, promote/rollback/channels/versions/tags sites :1961–:2326, ledger sites :2484–:2636), `apps.go` (:202, :322, :385, :418, :478, :522), `appconfig_cli.go` (14 sites), `deploy.go` :62, `restart.go` :41, `store.go` (4), `retention.go` :54, `instances_cli.go` (:244, :267, :307 + sites), `fleets.go` (6 defaults + 6 sites), `logs.go` (:70, :95)
+- **Leave alone:** `cli.go:72` and `cli.go:403` (registry-mode `route`/list verbs — genuine ops default).
+
+**Interfaces:** consumes Task 4's `resolvedAccount` only.
+
+- [ ] **Step 1:** flip every listed `fs.String("account", "ops", …)` to `fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")` — matching each file's existing help-string wording style; same for `accountFlags`/`ledgerAccountFlags`.
+- [ ] **Step 2:** replace every listed `apiAccount(acctRaw, acctIDRaw)` with `resolvedAccount(sess, acctRaw, acctIDRaw)`. Dual-mode verbs (ship, promote, etc. via `accountFlags`) only hit this on their `sess != nil` branch; their direct branch keeps `acctOf()` → `resolveAccount` (`""` → ops, unchanged).
+- [ ] **Step 3:** `go build ./... && go vet ./... && go test ./... -count=1` full-suite — existing tests that shipped with `-account` omitted against httptest consoles will exercise the new fallback; fix any that asserted the literal `ops` segment by adding the endpoint 404 (fallback keeps them green) or an explicit `-account ops`.
+- [ ] **Step 4: commit** `cli: -account defaults to your own account in API mode`.
+
+### Task 6: whoami memberships
+
+**Files:** modify `auth.go` (`authWhoami` :702), test in `auth_e2e_test.go` or the httptest pattern used by whoami today.
+
+- [ ] **Step 1: failing test** — console serving both endpoints: output contains `accounts: tom@keymail.dev (bdk), acme (bce)`; console without the endpoint → whoami output unchanged, exit 0.
+- [ ] **Step 2:** after the identity print, `newConsoleClient(c.Console, c.Token).do(ctx, GET, "/api/cli/apps", nil, &struct{ Accounts []apiAccountEntry }{…})`; on ANY error print nothing; else one muted line `accounts: n1 (s1), n2 (s2)`.
+- [ ] **Step 3: pass; commit** `cli: whoami prints account memberships`.
+
+### Task 7: accounts list signed-in mode
+
+**Files:** modify `accounts.go` (`runAccountsList` :204), test in `accounts` CLI test file (follow existing patterns; shell-outs use `carlosTestEnv` + scratch `cmd.Dir`).
+
+- [ ] **Step 1: failing test** — with no registry file at `-registry` and a credential for an httptest console: NAME/SQID table of the caller's accounts; with a registry file present: today's registry table (existing behavior test stays green); old console (404) → error naming the console as predating account listing.
+- [ ] **Step 2:** add `-console` flag; if `*consoleFlag == ""` and `os.Stat(*regPath)` succeeds → existing registry path verbatim; else `loggedInCredential(*consoleFlag)` (fatal on err), GET `/api/cli/apps`, on 404 fatal `"%s does not support account listing — it predates this CLI"`, else `newTable` NAME/SQID.
+- [ ] **Step 3: pass; commit** `cli: accounts list works signed-in (#157)`.
+
+### Task 8: Gate + PR
+
+- [ ] Full gate: build, vet, `go test ./... -count=1`, `gofmt -l` on touched files, `GOOS=darwin go build ./...`.
+- [ ] Opus review of the console endpoint + resolution seam (security-touching per delegation policy); fix findings.
+- [ ] Push branch, PR titled `CLI: default -account to your own account; membership discovery (#157)`, body maps spec sections to tasks, `Closes #157`.
diff --git a/docs/superpowers/plans/2026-08-12-delegated-wildcard-certs.md b/docs/superpowers/plans/2026-08-12-delegated-wildcard-certs.md
new file mode 100644
index 000000000..55f6fe6c3
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-12-delegated-wildcard-certs.md
@@ -0,0 +1,427 @@
+# Delegated-Challenge Wildcard Certs Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** the edge can obtain `*.<customer-domain>` certs by publishing
+ACME DNS-01 challenge TXTs **in the platform's own zone**, at a
+delegation target the customer has CNAMEd to — no customer DNS
+credentials, no knowledge of their provider.
+
+**Architecture:** `wildcardManager` gains a delegated-parent source (a
+closure, like its existing `hosts` closure) and a delegation zone; the
+only issuance-path change is *where* the challenge TXT is published
+(`challengeFQDN()` mapping). The `dnsProvider` seam, obtain flow,
+backoff, cache, and fall-back-to-per-host behavior are untouched.
+The claim-derived parent source (`wildcard: true` on
+`internal/domains.Claim`) is **Plan C** (gated on #151 landing); this
+plan ships the mechanics inert-until-sourced.
+
+**Tech Stack:** Go, `internal/edge` (platform repo), existing
+`wildcard.go`/`dns.go` seams, `golang.org/x/crypto/acme`.
+
+**Spec:** `docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md`
+§4 (this branch).
+
+## Global Constraints
+
+- Platform repo gate before any commit lands: `go build ./...`,
+ `go vet ./...`, `go test ./... -count=1` all clean, every package;
+ `gofmt -l` clean on touched files; `GOOS=darwin go build ./...`
+ compiles (CI runs it).
+- Sandbox: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`;
+ disable the sandbox if the toolchain reports "read-only file system".
+- Every change lands through a PR — no direct commits to `main`.
+- Delegation-target convention (fixed here, per spec §10):
+ `_acme-challenge.<parent>.<delegation-zone>`, with the deployment's
+ delegation zone in env `CARLOS_ACME_DELEGATION_ZONE` (flagship value:
+ `acme.oncarlos.com` — a zone the existing DNSimple token already
+ writes; no new credentials).
+- Every failure degrades to per-host issuance — never a dead edge
+ (wildcard.go's standing contract; nothing here may violate it).
+- Work in a fresh worktree off `main`:
+ `git worktree add .claude/worktrees/delegated-wildcards -b edge-delegated-wildcards main`
+ (run from the repo root `/home/paulca/github.com/carlosframework/platform`).
+ All paths below are relative to that worktree.
+
+---
+
+### Task 1: Delegated parents join the parent set; challenges publish at the delegation target
+
+**Files:**
+- Modify: `internal/edge/wildcard.go` (struct fields, constructor,
+ `parentsFor` call site in `refresh()`, `solveDNS01`, new
+ `challengeFQDN`/`isDelegated` methods)
+- Test: `internal/edge/wildcard_test.go`
+
+**Interfaces:**
+- Consumes: existing `wildcardManager`, `parentsFor`, `solveDNS01`,
+ `fakeDNS` test double (wildcard_test.go:231).
+- Produces: `newWildcardManager(static []string, appsDomain string,
+ hosts func() []string, delegated func() []string, delegationZone
+ string, cache autocert.Cache, dns dnsProvider) *wildcardManager` —
+ two NEW trailing params before cache: `delegated` (nil = none) and
+ `delegationZone`. Also `(*wildcardManager).challengeFQDN(parent
+ string) string`, which Task 2's env plumbing and Plan C's derivation
+ rely on.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/edge/wildcard_test.go`:
+
+```go
+func TestDelegatedParentsJoinTheSet(t *testing.T) {
+ w := newWildcardManager(nil, "oncarlos.com", func() []string {
+ return []string{"app.bab.oncarlos.com"}
+ }, func() []string {
+ return []string{"woodstar.app"}
+ }, "acme.oncarlos.com", autocert.DirCache(t.TempDir()), &fakeDNS{})
+ w.refresh()
+ w.mu.Lock()
+ parents := append([]string(nil), w.parents...)
+ w.mu.Unlock()
+ want := []string{"bab.oncarlos.com", "woodstar.app"}
+ if !reflect.DeepEqual(parents, want) {
+ t.Fatalf("parents = %v, want %v", parents, want)
+ }
+}
+
+func TestChallengeFQDNDelegation(t *testing.T) {
+ w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil },
+ func() []string { return []string{"woodstar.app"} },
+ "acme.oncarlos.com", autocert.DirCache(t.TempDir()), &fakeDNS{})
+ w.refresh()
+ // A delegated parent's TXT publishes into OUR zone, at the target
+ // the customer's CNAME points to.
+ if got, want := w.challengeFQDN("woodstar.app"), "_acme-challenge.woodstar.app.acme.oncarlos.com"; got != want {
+ t.Errorf("delegated: %q, want %q", got, want)
+ }
+ // A platform-zone parent publishes directly, exactly as before.
+ if got, want := w.challengeFQDN("bab.oncarlos.com"), "_acme-challenge.bab.oncarlos.com"; got != want {
+ t.Errorf("own-zone: %q, want %q", got, want)
+ }
+}
+
+func TestChallengeFQDNWithoutZoneNeverDelegates(t *testing.T) {
+ // No delegation zone configured: even a listed delegated parent
+ // publishes directly (misconfiguration degrades to the historical
+ // behavior, which per-host issuance then covers — never a dead edge).
+ w := newWildcardManager(nil, "", func() []string { return nil },
+ func() []string { return []string{"woodstar.app"} },
+ "", autocert.DirCache(t.TempDir()), &fakeDNS{})
+ w.refresh()
+ if got, want := w.challengeFQDN("woodstar.app"), "_acme-challenge.woodstar.app"; got != want {
+ t.Errorf("%q, want %q", got, want)
+ }
+}
+
+func TestNilDelegatedSourceIsFine(t *testing.T) {
+ // Every existing construction site passes nil — the zero-source
+ // manager must behave exactly as before this feature existed.
+ w := newWildcardManager([]string{"go.tito.io"}, "", func() []string { return nil },
+ nil, "acme.oncarlos.com", autocert.DirCache(t.TempDir()), &fakeDNS{})
+ w.refresh()
+ w.mu.Lock()
+ parents := append([]string(nil), w.parents...)
+ w.mu.Unlock()
+ if !reflect.DeepEqual(parents, []string{"go.tito.io"}) {
+ t.Fatalf("parents = %v, want [go.tito.io]", parents)
+ }
+ if got, want := w.challengeFQDN("go.tito.io"), "_acme-challenge.go.tito.io"; got != want {
+ t.Errorf("%q, want %q", got, want)
+ }
+}
+```
+
+Add `"reflect"` to the test file's imports if not already present.
+
+- [ ] **Step 2: Run to verify they fail**
+
+```bash
+cd /home/paulca/github.com/carlosframework/platform/.claude/worktrees/delegated-wildcards
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/edge/ -run 'TestDelegated|TestChallengeFQDN|TestNilDelegated' -count=1
+```
+
+Expected: FAIL — `newWildcardManager` has the old arity;
+`challengeFQDN` undefined. (Compilation failure across
+wildcard_test.go's existing call sites is part of the expected output.)
+
+- [ ] **Step 3: Implement**
+
+In `internal/edge/wildcard.go`:
+
+1. Add fields to `wildcardManager` (after `hosts`):
+
+```go
+ // delegated lists customer domains approved for delegated-challenge
+ // wildcard issuance (spec §4: claim-derived; nil = none). Their
+ // challenge TXTs publish into delegationZone — our own zone — at
+ // _acme-challenge.<parent>.<delegationZone>, which the customer has
+ // CNAMEd _acme-challenge.<parent> to. The platform never touches,
+ // or knows, the customer's DNS provider.
+ delegated func() []string
+ delegationZone string
+
+ // delegatedSet is refresh()'s snapshot of delegated(), read by
+ // challengeFQDN under mu (issuance runs on run()'s goroutine).
+ delegatedSet map[string]bool
+```
+
+2. Extend the constructor (two new params before `cache`):
+
+```go
+func newWildcardManager(staticDomains []string, appsDomain string, hosts func() []string, delegated func() []string, delegationZone string, cache autocert.Cache, dns dnsProvider) *wildcardManager {
+ return &wildcardManager{
+ cache: cache,
+ dns: dns,
+ directory: envOr("CARLOS_ACME_DIRECTORY", letsEncryptDirectory),
+ staticDomains: staticDomains,
+ appsDomain: strings.ToLower(strings.TrimSpace(appsDomain)),
+ hosts: hosts,
+ delegated: delegated,
+ delegationZone: strings.ToLower(strings.TrimSpace(delegationZone)),
+ certs: map[string]*tls.Certificate{},
+ nextAttempt: map[string]time.Time{},
+ delegatedSet: map[string]bool{},
+ wake: make(chan struct{}, 1),
+ }
+}
+```
+
+3. In `refresh()`, snapshot the delegated set and include it in the
+parent union. Replace the first two lines of `refresh()`:
+
+```go
+func (w *wildcardManager) refresh() {
+ var delegated []string
+ if w.delegated != nil {
+ for _, d := range w.delegated() {
+ if d = strings.ToLower(strings.TrimSpace(d)); d != "" {
+ delegated = append(delegated, d)
+ }
+ }
+ }
+ parents := parentsFor(append(append([]string(nil), w.staticDomains...), delegated...), w.appsDomain, w.hosts())
+ now := time.Now()
+ w.mu.Lock()
+ w.parents = parents
+ set := make(map[string]bool, len(delegated))
+ for _, d := range delegated {
+ set[d] = true
+ }
+ w.delegatedSet = set
+```
+
+(the rest of `refresh()` — the `need` loop, unlock, wake — is
+unchanged; `parentsFor` already dedupes and sorts, so a delegated
+domain that is also a static domain appears once).
+
+4. Add the mapping methods (near `wildcardPEMKey`):
+
+```go
+// challengeFQDN returns where parent's DNS-01 TXT is published: the
+// delegation target inside our own zone for delegated parents, or
+// directly at _acme-challenge.<parent> otherwise. With no delegation
+// zone configured the direct form is used for everyone — the
+// misconfigured state degrades to the historical behavior and per-host
+// issuance keeps covering (obtain will fail on the CA's lookup, log,
+// and back off — never a dead edge).
+func (w *wildcardManager) challengeFQDN(parent string) string {
+ if w.delegationZone != "" && w.isDelegated(parent) {
+ return "_acme-challenge." + parent + "." + w.delegationZone
+ }
+ return "_acme-challenge." + parent
+}
+
+func (w *wildcardManager) isDelegated(parent string) bool {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ return w.delegatedSet[parent]
+}
+```
+
+5. In `solveDNS01`, replace the one line
+`challengeFQDN := "_acme-challenge." + parent` with:
+
+```go
+ challengeFQDN := w.challengeFQDN(parent)
+```
+
+(`present`, cleanup, and `waitForTXT(ctx, challengeFQDN, …)` then all
+operate on the delegation target for delegated parents — the TXT we
+wait for is the one we wrote, in our own zone, which also sidesteps
+negative-caching of the customer-side name. The CA independently
+follows the customer's CNAME.)
+
+6. Update `newWildcardFromEnv`'s construction call (Task 2 refines it;
+for this task pass the inert values so the package compiles):
+
+```go
+ return newWildcardManager(static, appsDomain, e.currentHosts, nil, os.Getenv("CARLOS_ACME_DELEGATION_ZONE"), cache, dns)
+```
+
+7. Mechanically update every existing `newWildcardManager(` call in
+`internal/edge/wildcard_test.go` (six sites listed at lines 251, 256,
+277, 288, 324, and inside `TestOnReloadHookFires`; grep for the exact
+set) to pass `nil, ""` for the two new params, e.g.:
+
+```go
+ w1 := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, nil, "", cache, &fakeDNS{})
+```
+
+- [ ] **Step 4: Run the full edge package**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/edge/ -count=1
+```
+
+Expected: PASS — the four new tests and every pre-existing wildcard
+test (their behavior is pinned by `TestNilDelegatedSourceIsFine`'s
+contract).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/edge/wildcard.go internal/edge/wildcard_test.go
+git commit -m "edge: delegated-challenge wildcard parents — publish DNS-01 TXTs at a delegation target in our own zone"
+```
+
+---
+
+### Task 2: Env plumbing + provider-miss logging
+
+**Files:**
+- Modify: `internal/edge/wildcard.go` (`newWildcardFromEnv`)
+- Test: `internal/edge/wildcard_test.go`
+
+**Interfaces:**
+- Consumes: Task 1's constructor.
+- Produces: `CARLOS_ACME_DELEGATION_ZONE` as the deployment config
+ surface (flagship: `acme.oncarlos.com`). Plan C replaces the `nil`
+ delegated source with the claim-derived one; nothing else changes.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `internal/edge/wildcard_test.go` (match the env-isolation
+style of the existing `TestNewWildcardFromEnv` at line 358 — it uses
+`t.Setenv`):
+
+```go
+func TestNewWildcardFromEnvDelegationZone(t *testing.T) {
+ t.Setenv("CARLOS_WILDCARD_DOMAINS", "go.tito.io")
+ t.Setenv("CARLOS_DNS_PROVIDER", "dnsimple")
+ t.Setenv("CARLOS_DNSIMPLE_TOKEN", "test-token")
+ t.Setenv("CARLOS_ACME_DELEGATION_ZONE", " Acme.OnCarlos.Com ")
+ e := &Edge{}
+ w := newWildcardFromEnv(e, autocert.DirCache(t.TempDir()))
+ if w == nil {
+ t.Fatal("manager should be on")
+ }
+ if w.delegationZone != "acme.oncarlos.com" {
+ t.Errorf("delegationZone = %q, want lowercased+trimmed acme.oncarlos.com", w.delegationZone)
+ }
+}
+```
+
+(If `Edge{}` needs more construction in the existing
+`TestNewWildcardFromEnv`, mirror exactly what that test does — it is
+the pattern-setter for this seam.)
+
+- [ ] **Step 2: Run to verify it fails**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/edge/ -run TestNewWildcardFromEnvDelegationZone -count=1
+```
+
+Expected: FAIL — `w.delegationZone` is `" Acme.OnCarlos.Com "` (raw
+env, not normalized) — Task 1's step 6 passed the env through without
+normalization; the constructor lowercases/trims, so if this PASSES
+already, note it and move on (the constructor normalizes). Either
+result is acceptable evidence; the test stays as the lock.
+
+- [ ] **Step 3: Confirm `newWildcardFromEnv` final form**
+
+```go
+func newWildcardFromEnv(e *Edge, cache autocert.Cache) *wildcardManager {
+ static := splitDomains(os.Getenv("CARLOS_WILDCARD_DOMAINS"))
+ appsDomain := strings.ToLower(strings.TrimSpace(os.Getenv("PLATFORM_APPS_DOMAIN")))
+ if len(static) == 0 && appsDomain == "" {
+ return nil
+ }
+ dns, reason := dnsProviderFromEnv()
+ if dns == nil {
+ log.Printf("edge: wildcard certs off (%s) — per-host issuance continues", reason)
+ return nil
+ }
+ // Delegated parents (spec §4): the SOURCE is claim-derived and lands
+ // with the claim-derivation pass (Plan C); until then the zone is
+ // configured but the set is empty — inert by construction.
+ return newWildcardManager(static, appsDomain, e.currentHosts, nil, os.Getenv("CARLOS_ACME_DELEGATION_ZONE"), cache, dns)
+}
+```
+
+- [ ] **Step 4: Run the whole gate**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off GOOS=darwin go build ./... && \
+gofmt -l internal/edge
+```
+
+Expected: all clean; gofmt prints nothing.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/edge/wildcard.go internal/edge/wildcard_test.go
+git commit -m "edge: CARLOS_ACME_DELEGATION_ZONE names the delegation zone; claim-derived source arrives with Plan C"
+```
+
+---
+
+### Task 3: PR
+
+- [ ] **Step 1: Push and open the PR**
+
+```bash
+git push -u origin edge-delegated-wildcards
+gh pr create --title "edge: delegated-challenge wildcard certs (customer domains, zero customer DNS knowledge)" --body "$(cat <<'EOF'
+Wildcard DNS-01 for a customer domain without holding customer DNS
+credentials: the customer CNAMEs _acme-challenge.<domain> to
+_acme-challenge.<domain>.<CARLOS_ACME_DELEGATION_ZONE> (a zone our
+existing DNSimple token already writes), and the edge publishes the
+challenge TXT at that target in OUR zone. Let's Encrypt follows the
+CNAME. The dnsProvider seam, obtain flow, backoff and
+degrade-to-per-host contract are untouched; the only issuance change is
+where the TXT goes (challengeFQDN mapping).
+
+Ships inert: the delegated-parent SOURCE is claim-derived
+(wildcard:true on internal/domains.Claim, agreed with #151) and lands
+with the claim-derivation pass after #151 merges; until then the set is
+empty by construction.
+
+Spec: docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md §4.
+
+🤖 Generated with [Claude Code](https://claude.com/claude-code)
+EOF
+)"
+```
+
+## Self-review notes
+
+- Spec §4 coverage: delegation-target convention ✓ (fixed:
+ `_acme-challenge.<parent>.<zone>`, env `CARLOS_ACME_DELEGATION_ZONE`,
+ flagship `acme.oncarlos.com`), provider-blind ✓ (dnsProvider
+ untouched), degrade-to-per-host ✓ (no-zone case tested), claim-derived
+ source explicitly deferred to Plan C with the seam (`delegated func()
+ []string`) already shaped for it.
+- NOT in this plan (Plan C, gated on #151 landing): claims → delegated
+ set derivation, `catchall` alias derivation, the `up.alias`
+ parent-derivation exclusion, `delegation_status` stamps, attach-door
+ refusals. NOT in this plan (Plan D): everything operational.
+- Wire-shape check: no storage shapes change here; the cert cache keys
+ (`wildcard-<domain>.pem`) already accommodate any parent string.
diff --git a/docs/superpowers/plans/2026-08-12-woodstar-w0-relay-fanout.md b/docs/superpowers/plans/2026-08-12-woodstar-w0-relay-fanout.md
new file mode 100644
index 000000000..f7e111893
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-12-woodstar-w0-relay-fanout.md
@@ -0,0 +1,751 @@
+# Woodstar W0 — Relay Fan-out Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** `cmd/relay` optionally fronts its five satellite services and
+reproduces prod Caddy's path fan-out and wildcard-host behavior, so one
+Host-routed CARLOS route can serve the whole Woodstar app.
+
+**Architecture:** a new `internal/relay/fanout.go` wraps the existing
+`Server.Handler()` with an outermost handler that (a) dispatches Caddy's
+path prefixes to per-service `httputil.ReverseProxy` instances and (b)
+when a public apex domain is configured, reproduces the
+`*.woodstar.app` host behavior (www→apex 301, `/.well-known/atproto-did`
+→ apbridge, other subdomain paths → 302 to the apex profile). All new
+behavior is opt-in via new flags; unset flags = byte-identical behavior.
+
+**Tech Stack:** Go (woodstar repo, pure Go, no CGO), `net/http/httputil`,
+`net/http/httptest` for tests.
+
+**Spec:** platform repo
+`docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md`
+§6 (branch `worktree-woodstar-carloku`).
+
+## Global Constraints
+
+- Work in the **woodstar repo** (`/home/paulca/github.com/woodstarapp/woodstar`)
+ — but NEVER build in the main checkout (its CLAUDE.md; multiple
+ sessions active). First action: `git worktree add`.
+- Unset flags = today's behavior **byte-identical**. Dev/devbox
+ unaffected (Caddy keeps the fan-out there).
+- The fan-out route set mirrors `infra/devbox-init.sh`'s Caddy block
+ (the truthful superset of `infra/prod-Caddyfile`): `/media*`→media,
+ `/api/*`→indexer, `/stream/*`→notify, `/unfurl*` + `/img*`→unfurl
+ (`/img` serves every link-card image; added by final-review fix),
+ `/.well-known/webfinger` + `/.well-known/host-meta` + `/users/*` +
+ `/inbox` + `/ap/*`→apbridge, everything else→relay.
+- Wildcard-host behavior mirrors `infra/prod-Caddyfile`'s
+ `*.woodstar.app` block byte-for-byte: `www` → **permanent** (301)
+ redirect to apex with URI preserved; `/.well-known/atproto-did` →
+ apbridge; anything else → **temporary** (302) redirect to
+ `https://<apex>/<leftmost-label>`.
+- Proxies preserve the inbound `Host` header (Caddy's default; the
+ apbridge resolves users from it).
+- Gate: `make test` green; `gofmt -l` clean on touched files.
+
+---
+
+### Task 1: Fan-out path dispatch
+
+**Files:**
+- Create: `internal/relay/fanout.go`
+- Test: `internal/relay/fanout_test.go`
+
+**Interfaces:**
+- Produces: `relay.FanoutConfig{Media, Indexer, Notify, Unfurl,
+ APBridge, Domain string}` and
+ `relay.Fanout(inner http.Handler, cfg FanoutConfig) (http.Handler, error)`.
+ Task 3 adds host-aware behavior inside the same handler; Task 4 wires
+ `cmd/relay` flags to it.
+
+- [ ] **Step 1: Create the worktree**
+
+```bash
+cd /home/paulca/github.com/woodstarapp/woodstar
+git worktree add .worktrees/w0-fanout -b w0-relay-fanout origin/main
+cd .worktrees/w0-fanout
+```
+
+- [ ] **Step 2: Write the failing dispatch test**
+
+Create `internal/relay/fanout_test.go`:
+
+```go
+package relay
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// fanoutBackends starts one recording backend per satellite and returns
+// the config pointing at them plus the record of who got which request.
+type backendHit struct {
+ name, method, path, host, body string
+}
+
+func fanoutBackends(t *testing.T) (FanoutConfig, *[]backendHit) {
+ t.Helper()
+ hits := &[]backendHit{}
+ mk := func(name string) string {
+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b, _ := io.ReadAll(r.Body)
+ *hits = append(*hits, backendHit{name, r.Method, r.URL.Path, r.Host, string(b)})
+ w.WriteHeader(299) // unmistakable status: proves the response came from the backend
+ io.WriteString(w, name)
+ }))
+ t.Cleanup(s.Close)
+ return s.URL
+ }
+ return FanoutConfig{
+ Media: mk("media"),
+ Indexer: mk("indexer"),
+ Notify: mk("notify"),
+ Unfurl: mk("unfurl"),
+ APBridge: mk("apbridge"),
+ }, hits
+}
+
+func TestFanoutDispatch(t *testing.T) {
+ cfg, hits := fanoutBackends(t)
+ inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ *hits = append(*hits, backendHit{"inner", r.Method, r.URL.Path, r.Host, ""})
+ w.WriteHeader(298)
+ })
+ h, err := Fanout(inner, cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ front := httptest.NewServer(h)
+ t.Cleanup(front.Close)
+
+ cases := []struct {
+ method, path, want string
+ status int
+ }{
+ {"GET", "/media/abc123", "media", 299},
+ {"POST", "/media", "media", 299},
+ {"GET", "/api/search?q=x", "indexer", 299},
+ {"GET", "/stream/notify", "notify", 299},
+ {"GET", "/unfurl?url=https%3A%2F%2Fexample.com", "unfurl", 299},
+ {"GET", "/.well-known/webfinger?resource=acct:paul", "apbridge", 299},
+ {"GET", "/.well-known/host-meta", "apbridge", 299},
+ {"GET", "/users/paul", "apbridge", 299},
+ {"POST", "/inbox", "apbridge", 299},
+ {"GET", "/ap/objects/1", "apbridge", 299},
+ // NOT proxied — the relay's own surface:
+ {"GET", "/subscribe", "inner", 298},
+ {"GET", "/info", "inner", 298},
+ {"GET", "/events", "inner", 298},
+ {"GET", "/.well-known/woodstar.json", "inner", 298},
+ {"GET", "/.well-known/apple-app-site-association", "inner", 298},
+ {"GET", "/api", "inner", 298}, // Caddy /api/* does not match bare /api
+ {"GET", "/users", "inner", 298}, // /users/* does not match bare /users
+ {"GET", "/inboxes", "inner", 298}, // /inbox is an exact match
+ }
+ for _, c := range cases {
+ *hits = (*hits)[:0]
+ req, _ := http.NewRequest(c.method, front.URL+c.path, strings.NewReader("payload"))
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("%s %s: %v", c.method, c.path, err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != c.status {
+ t.Errorf("%s %s: status %d, want %d", c.method, c.path, resp.StatusCode, c.status)
+ }
+ if len(*hits) != 1 || (*hits)[0].name != c.want {
+ t.Errorf("%s %s: hit %+v, want backend %q", c.method, c.path, *hits, c.want)
+ }
+ }
+}
+
+func TestFanoutPreservesHostMethodBody(t *testing.T) {
+ cfg, hits := fanoutBackends(t)
+ h, err := Fanout(http.NotFoundHandler(), cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ front := httptest.NewServer(h)
+ t.Cleanup(front.Close)
+
+ req, _ := http.NewRequest("POST", front.URL+"/media", strings.NewReader("blobbytes"))
+ req.Host = "woodstar.app" // the Host the edge delivers
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if len(*hits) != 1 {
+ t.Fatalf("hits: %+v", *hits)
+ }
+ got := (*hits)[0]
+ if got.method != "POST" || got.body != "blobbytes" || got.host != "woodstar.app" {
+ t.Errorf("backend saw %+v; want POST, body blobbytes, host woodstar.app", got)
+ }
+}
+
+func TestFanoutPartialConfig(t *testing.T) {
+ // Only media set: media paths proxy, all other satellite paths fall
+ // through to inner (never a 502 to a nil proxy).
+ media := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(299)
+ }))
+ t.Cleanup(media.Close)
+ inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(298) })
+ h, err := Fanout(inner, FanoutConfig{Media: media.URL})
+ if err != nil {
+ t.Fatal(err)
+ }
+ front := httptest.NewServer(h)
+ t.Cleanup(front.Close)
+ for path, want := range map[string]int{"/media/x": 299, "/api/x": 298, "/inbox": 298} {
+ resp, err := http.Get(front.URL + path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != want {
+ t.Errorf("%s: status %d, want %d", path, resp.StatusCode, want)
+ }
+ }
+}
+
+func TestFanoutRejectsBadUpstream(t *testing.T) {
+ if _, err := Fanout(http.NotFoundHandler(), FanoutConfig{Media: "://not-a-url"}); err == nil {
+ t.Fatal("want error for unparseable upstream URL")
+ }
+}
+```
+
+- [ ] **Step 3: Run the test to verify it fails**
+
+```bash
+cd /home/paulca/github.com/woodstarapp/woodstar/.worktrees/w0-fanout
+go test ./internal/relay/ -run TestFanout -count=1
+```
+
+Expected: FAIL — `undefined: FanoutConfig`, `undefined: Fanout`.
+
+- [ ] **Step 4: Implement `internal/relay/fanout.go`**
+
+```go
+package relay
+
+// Fan-out: the relay optionally fronts its satellite services,
+// reproducing the Caddy path routing that prod and the devbox do today
+// (infra/devbox-init.sh's block is the authoritative route set). On
+// CARLOS, the edge routes by Host only, so the relay carries the paths.
+// Everything here is opt-in: a zero FanoutConfig returns inner untouched.
+//
+// Spec: platform repo docs/superpowers/specs/
+// 2026-08-12-woodstar-carloku-migration-design.md §6.
+
+import (
+ "fmt"
+ "net"
+ "net/http"
+ "net/http/httputil"
+ "net/url"
+ "strings"
+)
+
+// FanoutConfig names the satellite base URLs (empty = that satellite is
+// not fronted; its paths fall through to the relay) and the public apex
+// domain (empty = no host-aware handling; Task 3).
+type FanoutConfig struct {
+ Media string
+ Indexer string
+ Notify string
+ Unfurl string
+ APBridge string
+ Domain string
+}
+
+// fanout dispatches by (host, path); zero-value fields disable their leg.
+type fanout struct {
+ inner http.Handler
+ media http.Handler
+ indexer http.Handler
+ notify http.Handler
+ unfurl http.Handler
+ apbridge http.Handler
+ domain string // lowercase apex, "" = host handling off
+}
+
+// Fanout wraps inner with the satellite path dispatch. A zero cfg
+// returns inner unchanged (the byte-identical default).
+func Fanout(inner http.Handler, cfg FanoutConfig) (http.Handler, error) {
+ if cfg == (FanoutConfig{}) {
+ return inner, nil
+ }
+ f := &fanout{inner: inner, domain: strings.ToLower(strings.TrimSpace(cfg.Domain))}
+ for _, p := range []struct {
+ name, target string
+ dst *http.Handler
+ }{
+ {"media", cfg.Media, &f.media},
+ {"indexer", cfg.Indexer, &f.indexer},
+ {"notify", cfg.Notify, &f.notify},
+ {"unfurl", cfg.Unfurl, &f.unfurl},
+ {"apbridge", cfg.APBridge, &f.apbridge},
+ } {
+ if p.target == "" {
+ continue
+ }
+ u, err := url.Parse(p.target)
+ if err != nil || u.Scheme == "" || u.Host == "" {
+ return nil, fmt.Errorf("fanout: %s upstream %q: not an absolute URL", p.name, p.target)
+ }
+ proxy := httputil.NewSingleHostReverseProxy(u)
+ // Immediate flush so /stream/* SSE events cross the hop as they
+ // are written; harmless for ordinary responses.
+ proxy.FlushInterval = -1
+ *p.dst = proxy
+ }
+ return f, nil
+}
+
+func (f *fanout) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ // Host-aware wildcard handling is added in Task 3; path dispatch
+ // applies to every host that reaches the relay.
+ f.dispatch(w, r)
+}
+
+// dispatch mirrors the Caddy handle blocks: longest-specificity path
+// checks, satellite legs falling through to the relay when unset.
+func (f *fanout) dispatch(w http.ResponseWriter, r *http.Request) {
+ p := r.URL.Path
+ switch {
+ case f.media != nil && (p == "/media" || strings.HasPrefix(p, "/media/")):
+ f.media.ServeHTTP(w, r)
+ case f.indexer != nil && strings.HasPrefix(p, "/api/"):
+ f.indexer.ServeHTTP(w, r)
+ case f.notify != nil && strings.HasPrefix(p, "/stream/"):
+ f.notify.ServeHTTP(w, r)
+ case f.unfurl != nil && (p == "/unfurl" || strings.HasPrefix(p, "/unfurl/")):
+ f.unfurl.ServeHTTP(w, r)
+ case f.apbridge != nil && (p == "/.well-known/webfinger" ||
+ p == "/.well-known/host-meta" ||
+ strings.HasPrefix(p, "/users/") ||
+ p == "/inbox" ||
+ strings.HasPrefix(p, "/ap/")):
+ f.apbridge.ServeHTTP(w, r)
+ default:
+ f.inner.ServeHTTP(w, r)
+ }
+}
+
+// requestHost returns r.Host lowercased with any port stripped.
+func requestHost(r *http.Request) string {
+ h := strings.ToLower(r.Host)
+ if host, _, err := net.SplitHostPort(h); err == nil {
+ return host
+ }
+ return h
+}
+```
+
+(`requestHost` is used by Task 3; declaring it here keeps Task 3's diff
+handler-only. If `go vet` flags it unused before Task 3 lands in the
+same branch, that is expected within this task only — it is used two
+tasks later; alternatively fold this function into Task 3's diff.)
+Note: `/media*` and `/unfurl*` in Caddy are literal-prefix wildcards;
+the deployed client only ever requests `/media`, `/media/<hash>`,
+`/unfurl?url=…`, and `/img?u=…`, so exact + `/`-prefix matching above
+is the honest equivalent. (Correction from final review: a hypothetical
+`/mediafoo` falling to the relay does NOT 404 — extension-less unknown
+paths take the SPA fallback and return 200 HTML. The narrowing stands
+because the client never emits such paths, not because the fallback is
+harmless.)
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+```bash
+go test ./internal/relay/ -run TestFanout -count=1
+```
+
+Expected: PASS (all four tests).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/relay/fanout.go internal/relay/fanout_test.go
+git commit -m "relay: opt-in satellite fan-out — Caddy's path routing, relay-side"
+```
+
+---
+
+### Task 2: SSE streams cross the hop unbuffered
+
+**Files:**
+- Modify: `internal/relay/fanout_test.go` (add one test)
+
+**Interfaces:**
+- Consumes: `Fanout` + `FanoutConfig` from Task 1 (the
+ `FlushInterval = -1` behavior this test locks in).
+- Produces: nothing new — a regression lock.
+
+- [ ] **Step 1: Write the failing-or-locking SSE test**
+
+Append to `internal/relay/fanout_test.go`:
+
+```go
+func TestFanoutStreamsSSEUnbuffered(t *testing.T) {
+ // The notify hop must deliver each SSE event as it is written, not
+ // on backend close — the whole point of proxy.FlushInterval = -1.
+ firstEventOut := make(chan struct{})
+ release := make(chan struct{})
+ notify := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ io.WriteString(w, "data: one\n\n")
+ w.(http.Flusher).Flush()
+ close(firstEventOut)
+ <-release // hold the stream open: reading "one" must not need EOF
+ }))
+ t.Cleanup(func() { close(release); notify.Close() })
+
+ h, err := Fanout(http.NotFoundHandler(), FanoutConfig{Notify: notify.URL})
+ if err != nil {
+ t.Fatal(err)
+ }
+ front := httptest.NewServer(h)
+ t.Cleanup(front.Close)
+
+ resp, err := http.Get(front.URL + "/stream/notify")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ <-firstEventOut
+ buf := make([]byte, 64)
+ type readResult struct {
+ n int
+ err error
+ }
+ got := make(chan readResult, 1)
+ go func() {
+ n, err := resp.Body.Read(buf)
+ got <- readResult{n, err}
+ }()
+ select {
+ case r := <-got:
+ if r.err != nil || !strings.Contains(string(buf[:r.n]), "data: one") {
+ t.Fatalf("read %q err=%v; want the first event", buf[:r.n], r.err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("first SSE event did not arrive while the stream was open — proxy is buffering")
+ }
+}
+```
+
+Add `"time"` to the test file's imports.
+
+- [ ] **Step 2: Run it**
+
+```bash
+go test ./internal/relay/ -run TestFanoutStreamsSSE -count=1
+```
+
+Expected: PASS immediately (Task 1 set `FlushInterval = -1`). If it
+FAILS on the timeout, the flush setting was dropped — restore
+`proxy.FlushInterval = -1` in `Fanout` and re-run.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add internal/relay/fanout_test.go
+git commit -m "relay: lock SSE pass-through flushing with a held-open stream test"
+```
+
+---
+
+### Task 3: Host-aware wildcard behavior
+
+**Files:**
+- Modify: `internal/relay/fanout.go` (ServeHTTP + one new method)
+- Test: `internal/relay/fanout_test.go` (add tests)
+
+**Interfaces:**
+- Consumes: `fanout` struct, `dispatch`, `requestHost` from Task 1.
+- Produces: the complete `Fanout` behavior Task 4 wires flags to.
+
+- [ ] **Step 1: Write the failing host tests**
+
+Append to `internal/relay/fanout_test.go`:
+
+```go
+func TestFanoutWildcardHosts(t *testing.T) {
+ cfg, hits := fanoutBackends(t)
+ cfg.Domain = "woodstar.app"
+ inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ *hits = append(*hits, backendHit{"inner", r.Method, r.URL.Path, r.Host, ""})
+ w.WriteHeader(298)
+ })
+ h, err := Fanout(inner, cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ front := httptest.NewServer(h)
+ t.Cleanup(front.Close)
+ client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse // inspect redirects, don't follow
+ }}
+
+ cases := []struct {
+ host, path string
+ wantStatus int
+ wantLocation string // "" = no redirect expected
+ wantBackend string // "" = redirect, no backend hit
+ }{
+ // apex: ordinary dispatch, no host handling
+ {"woodstar.app", "/info", 298, "", "inner"},
+ {"woodstar.app", "/media/x", 299, "", "media"},
+ // www: permanent redirect to apex, URI preserved
+ {"www.woodstar.app", "/some/path?q=1", http.StatusMovedPermanently, "https://woodstar.app/some/path?q=1", ""},
+ // one-label subdomain: the Bluesky handle file goes to the bridge
+ {"paul.woodstar.app", "/.well-known/atproto-did", 299, "", "apbridge"},
+ // one-label subdomain, anything else: temporary redirect to the profile
+ {"paul.woodstar.app", "/", http.StatusFound, "https://woodstar.app/paul", ""},
+ {"paul.woodstar.app", "/anything/deep?x=1", http.StatusFound, "https://woodstar.app/paul", ""},
+ // port on the Host header is stripped before matching
+ {"paul.woodstar.app:443", "/x", http.StatusFound, "https://woodstar.app/paul", ""},
+ // foreign hosts (rehearsal sslip.io routes) get the full app
+ {"woodstar-rehearsal.99.81.104.219.sslip.io", "/media/x", 299, "", "media"},
+ {"woodstar-rehearsal.99.81.104.219.sslip.io", "/info", 298, "", "inner"},
+ // deeper-than-one-label: not the wildcard's business (no TLS there anyway)
+ {"a.b.woodstar.app", "/info", 298, "", "inner"},
+ }
+ for _, c := range cases {
+ *hits = (*hits)[:0]
+ req, _ := http.NewRequest("GET", front.URL+c.path, nil)
+ req.Host = c.host
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("%s %s: %v", c.host, c.path, err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != c.wantStatus {
+ t.Errorf("%s %s: status %d, want %d", c.host, c.path, resp.StatusCode, c.wantStatus)
+ }
+ if loc := resp.Header.Get("Location"); loc != c.wantLocation {
+ t.Errorf("%s %s: Location %q, want %q", c.host, c.path, loc, c.wantLocation)
+ }
+ if c.wantBackend == "" {
+ if len(*hits) != 0 {
+ t.Errorf("%s %s: unexpected backend hit %+v", c.host, c.path, *hits)
+ }
+ } else if len(*hits) != 1 || (*hits)[0].name != c.wantBackend {
+ t.Errorf("%s %s: hits %+v, want %q", c.host, c.path, *hits, c.wantBackend)
+ }
+ }
+}
+
+func TestFanoutAtprotoDIDPreservesHost(t *testing.T) {
+ // The bridge resolves WHICH user's DID from the Host header — the
+ // proxy must hand it through untouched.
+ cfg, hits := fanoutBackends(t)
+ cfg.Domain = "woodstar.app"
+ h, err := Fanout(http.NotFoundHandler(), cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ front := httptest.NewServer(h)
+ t.Cleanup(front.Close)
+ req, _ := http.NewRequest("GET", front.URL+"/.well-known/atproto-did", nil)
+ req.Host = "paul.woodstar.app"
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if len(*hits) != 1 || (*hits)[0].host != "paul.woodstar.app" {
+ t.Fatalf("bridge saw %+v; want Host paul.woodstar.app", *hits)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify the new tests fail**
+
+```bash
+go test ./internal/relay/ -run TestFanoutWildcard -count=1
+```
+
+Expected: FAIL — www/subdomain cases dispatch instead of redirecting
+(status 298/299 where 301/302 is wanted).
+
+- [ ] **Step 3: Implement the host handling**
+
+In `internal/relay/fanout.go`, replace `ServeHTTP` with:
+
+```go
+func (f *fanout) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if f.domain != "" {
+ host := requestHost(r)
+ if host != f.domain {
+ if host == "www."+f.domain {
+ // Caddy: redir https://woodstar.app{uri} permanent
+ http.Redirect(w, r, "https://"+f.domain+r.URL.RequestURI(), http.StatusMovedPermanently)
+ return
+ }
+ if label, ok := strings.CutSuffix(host, "."+f.domain); ok && label != "" && !strings.Contains(label, ".") {
+ // One label under the apex: paul.woodstar.app.
+ if r.URL.Path == "/.well-known/atproto-did" && f.apbridge != nil {
+ // The one well-known file that makes the subdomain a
+ // Bluesky handle — answered by the bridge, which reads
+ // the user from the Host header.
+ f.apbridge.ServeHTTP(w, r)
+ return
+ }
+ // Caddy: redir https://woodstar.app/{labels.2} temporary
+ http.Redirect(w, r, "https://"+f.domain+"/"+label, http.StatusFound)
+ return
+ }
+ // Foreign or deeper hosts (rehearsal routes, a.b.woodstar.app):
+ // serve the app; the wildcard owns exactly one label.
+ }
+ }
+ f.dispatch(w, r)
+}
+```
+
+- [ ] **Step 4: Run the full relay package**
+
+```bash
+go test ./internal/relay/ -count=1
+```
+
+Expected: PASS (new tests and every pre-existing test).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/relay/fanout.go internal/relay/fanout_test.go
+git commit -m "relay: Caddy-parity wildcard host handling — www, atproto-did, profile redirect"
+```
+
+---
+
+### Task 4: `cmd/relay` flag wiring
+
+**Files:**
+- Modify: `cmd/relay/main.go` (flags + handler wrap; today
+ `log.Fatal(http.ListenAndServe(*addr, srv.Handler()))` is main.go:63)
+
+**Interfaces:**
+- Consumes: `relay.Fanout`, `relay.FanoutConfig` (Tasks 1+3).
+- Produces: the deployable flag surface the migration's units use:
+ `-media-upstream`, `-indexer-upstream`, `-notify-upstream`,
+ `-unfurl-upstream`, `-apbridge-upstream`, `-domain`.
+
+- [ ] **Step 1: Add the flags and the wrap**
+
+In `cmd/relay/main.go`, after the existing flag declarations (below
+`importMax`, before `flag.Parse()`):
+
+```go
+ mediaUp := flag.String("media-upstream", "", "front the media service at this base URL (fan-out: /media*)")
+ indexerUp := flag.String("indexer-upstream", "", "front the indexer at this base URL (fan-out: /api/*)")
+ notifyUp := flag.String("notify-upstream", "", "front the notify service at this base URL (fan-out: /stream/*)")
+ unfurlUp := flag.String("unfurl-upstream", "", "front the unfurl service at this base URL (fan-out: /unfurl*)")
+ apbridgeUp := flag.String("apbridge-upstream", "", "front the AP bridge at this base URL (fan-out: webfinger, host-meta, /users/*, /inbox, /ap/*)")
+ domain := flag.String("domain", "", "public apex domain; with -apbridge-upstream set, serves Caddy-parity subdomain behavior (www redirect, atproto-did via bridge, profile redirects)")
+```
+
+And replace the final serve line (main.go:63):
+
+```go
+ handler, err := relay.Fanout(srv.Handler(), relay.FanoutConfig{
+ Media: *mediaUp,
+ Indexer: *indexerUp,
+ Notify: *notifyUp,
+ Unfurl: *unfurlUp,
+ APBridge: *apbridgeUp,
+ Domain: *domain,
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+ if *domain != "" && *apbridgeUp == "" {
+ log.Fatal("-domain requires -apbridge-upstream: subdomains must answer /.well-known/atproto-did (the Bluesky handle contract)")
+ }
+ log.Printf("relay %q listening on %s (db %s) — client at http://localhost%s/", *name, *addr, *db, *addr)
+ log.Fatal(http.ListenAndServe(*addr, handler))
+```
+
+(The pre-existing `log.Printf` line stays; only `srv.Handler()` inside
+`ListenAndServe` changes to `handler`.)
+
+- [ ] **Step 2: Build and run the whole gate**
+
+```bash
+go build ./... && go vet ./... && make test
+gofmt -l cmd/relay internal/relay
+```
+
+Expected: build/vet/test clean; gofmt prints nothing.
+
+- [ ] **Step 3: Manual smoke (optional but cheap)**
+
+```bash
+go run ./cmd/relay -addr 127.0.0.1:7071 -db /tmp/w0-smoke.db \
+ -apbridge-upstream http://127.0.0.1:9999 -domain example.test &
+curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' \
+ -H 'Host: paul.example.test' http://127.0.0.1:7071/x
+kill %1
+```
+
+Expected: `302 https://example.test/paul`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add cmd/relay/main.go
+git commit -m "relay: -*-upstream and -domain flags wire the fan-out"
+```
+
+---
+
+### Task 5: PR
+
+- [ ] **Step 1: Push and open the PR**
+
+```bash
+git push -u origin w0-relay-fanout
+gh pr create --title "relay: opt-in satellite fan-out + Caddy-parity wildcard hosts (CARLOS W0)" --body "$(cat <<'EOF'
+On CARLOS the edge routes by Host only, so the relay learns to front its
+satellites: new optional flags -media/-indexer/-notify/-unfurl/-apbridge-upstream
+reproduce the Caddy path fan-out (devbox-init.sh's route set), and -domain
+reproduces the *.woodstar.app block byte-for-byte (www→apex 301,
+/.well-known/atproto-did→bridge with Host preserved, everything else→302
+to the apex profile). All flags unset = byte-identical behavior; dev and
+the devbox are untouched (Caddy keeps the fan-out there).
+
+Spec: platform repo docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md §6.
+
+🤖 Generated with [Claude Code](https://claude.com/claude-code)
+EOF
+)"
+```
+
+- [ ] **Step 2: After merge (recorded here, executed at migration time):**
+rebuild all six binaries at the merged sha
+(`GOOS=linux GOARCH=arm64 CGO_ENABLED=0 -trimpath`) and
+`carlos ship -app woodstar-<svc> -kind binary -version <sha> -target linux-arm64 <bin>`
+for relay, media, indexer, notify, unfurl, apbridge. The old `dfe406a`
+shipment predates unfurl/apbridge and is superseded.
+
+## Self-review notes
+
+- Spec §6 coverage: path set ✓ (all nine legs + default), wildcard host
+ behavior ✓ (www 301 URI-preserved, atproto-did→bridge Host-preserved,
+ 302 profile redirect, apex untouched, foreign hosts serve the app),
+ SSE flush ✓ (held-open stream test), WS untouched ✓ (/subscribe
+ dispatches inner; upgrade path unchanged), unset-flags-byte-identical
+ ✓ (zero cfg returns inner; `TestFanoutPartialConfig` covers partial).
+- The `-domain`-requires-`-apbridge-upstream` refusal is deliberate:
+ a subdomain that redirects profiles but 404s atproto-did would
+ silently break Bluesky handles — fail loud at boot instead.
diff --git a/docs/superpowers/specs/2026-08-12-account-default-resolution-design.md b/docs/superpowers/specs/2026-08-12-account-default-resolution-design.md
new file mode 100644
index 000000000..b4e6bf2c7
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-12-account-default-resolution-design.md
@@ -0,0 +1,182 @@
+# Account default resolution and membership discovery
+
+**Date:** 2026-08-12 · **Issue:** #157 · **Status:** approved (Paul, in-session)
+
+## Problem
+
+Every API-mode verb defaults `-account` to `ops` — an account that does not
+exist on real consoles — so a new user's first ship answers the deliberately
+opaque membership 404. Worse, nothing exposes a signed-in caller's own
+memberships: `carlos accounts list` opens the registry DB on local disk, and
+`carlos auth whoami` reports identity only. A user who omits `-account` is
+guessing at a value the console knew all along (Tom, 2026-08-11: first ship
+on console.carloku.com, three failures deep before learning his account is
+named by his sign-in address).
+
+Per the no-infra rule, the CLI must answer this itself: discover the
+caller's accounts, and when the answer is unambiguous, just use it.
+
+## Decisions (Paul, 2026-08-12)
+
+- **One membership → it is the default.** No flag needed.
+- **Several memberships → interactive chooser** on a terminal; **refuse and
+ list** when stdin/stdout is not a TTY (a script cannot answer a prompt;
+ refusing loudly beats hanging, and the refusal doubles as discovery).
+- **Chooser offers to save** the pick as the project's default account
+ (`./.carlos/config`), one y/N prompt.
+- `carlos auth whoami` prints memberships; `carlos accounts list` gains a
+ signed-in mode.
+- Direct/bucket mode is untouched: `ops` remains its genuine default.
+
+## Wire (no change needed)
+
+The endpoint already exists — discovered during implementation
+(2026-08-12): `GET /api/cli/apps` (handleAPIApps, live since main 5856dc5,
+2026-08-03, deployed on both production consoles) answers with the
+caller's own live memberships:
+
+ → 200 {"accounts": [{"sqid": "bdk", "name": "tom@keymail.dev",
+ "role": "owner", "apps": […]}, …]}
+
+- `Store.AccountsFor`-backed: deleted accounts excluded, creation order
+ (the console switcher's order) — the chooser's first entry is the
+ personal account minted at sign-up. Leak-tested
+ (TestAPIAppsListsMemberships).
+- The CLI decodes only `name`/`sqid` (plus `role` for `accounts list`).
+ **No new endpoint and no console deploy required**; the 404 fallback
+ below covers only consoles older than 2026-08-03.
+
+## CLI resolution (API mode)
+
+The `-account` flag default changes from `"ops"` to `""` on every verb that
+takes it (the `accountFlags` helper and every hand-rolled API-verb pair —
+`apps create`/`delete`/`restore`/`place` and any sibling found in the
+implementation sweep).
+Direct mode is unaffected: `resolveAccount` already answers `""` with
+`release.OpsAccount`.
+
+In API mode an empty account resolves through one shared seam on the
+session, in order:
+
+1. **Explicit flags** (`-account-id`, then `-account`) — travel verbatim,
+ exactly as today.
+2. **Config default:** the `account` key of the nearest `.carlos/config`
+ (same upward walk as the console default, then global). Honored **only
+ when that same file's `console` matches the session's console** — the
+ pair is one fact; `-console` to a different stack must not drag the
+ other stack's account along. A consoleless `{"account": …}` is
+ deliberately NOT a wildcard (tightened in review, 2026-08-12): the
+ committed `console` key is nearly inert as repo input — no credential,
+ no request — but an account applying to every console would let a
+ checked-in file silently redirect ships between stacks the caller
+ really is a member of. The chooser always writes the pair, so only a
+ hand-written file can even be consoleless.
+3. **Memberships** via `GET /api/cli/apps`:
+ - exactly one → use its **sqid, exact-matched** (`?exact=sqid`), and
+ print a muted `account: <name> (<sqid>)` line under the "using …"
+ banner so what was guessed is always visible;
+ - several + TTY (stdin AND stdout are char devices) → numbered chooser,
+ then the save prompt;
+ - several + non-TTY → error:
+ `you belong to several accounts — say which with -account: tom@keymail.dev (bdk), acme (bce)`;
+ - zero (unreachable in practice — sign-in mints a personal account) →
+ error naming the console.
+4. **Old console** (endpoint 404s) → send `ops`, byte-for-byte today's
+ behavior — but SAID: a muted
+ `account: ops (<console> did not answer with your memberships)` line
+ prints (review, 2026-08-12: a silent ops followed by the membership 404
+ is the exact riddle #157 was filed about, and any 404-shaped thing in
+ front of a console — proxy, mis-stamped route — lands here too).
+
+The resolution happens once per invocation, before the first request that
+needs an account path; verbs that never touch an account path never call it.
+
+## Chooser UX
+
+ you belong to several accounts on https://console.carloku.com:
+ 1. tom@keymail.dev (bdk)
+ 2. acme (bce)
+ account [1-2]:
+
+ save acme as this directory's default account (.carlos/config)? [y/N]
+
+- Pick by number; anything else (including empty) re-prompts once, then
+ errors with the non-TTY message. EOF (^D) errors the same way.
+- `y` writes `{"console": <session console>, "account": <name>}` to
+ `./.carlos/config` **in the working directory** — same placement rule as
+ `auth default -project` ("run it where you want the file"). Any other
+ answer saves nothing.
+- **From `$HOME` the save is not offered at all** (review, 2026-08-12):
+ there `./.carlos/config` IS `~/.carlos/config`, and a y/N buried inside
+ a ship must not silently repoint the machine-wide default console.
+- **A failed save warns and continues** (review, 2026-08-12): the human
+ already answered and the account is known — a read-only checkout must
+ not turn bookkeeping into a dead ship.
+- Resolution is memoized on the session: however many times a verb asks
+ (`secrets set` asks for the seal key and again for the write), the
+ chooser runs at most once and both answers are the same account.
+- The saved value is the account **name** (readable in a committed file);
+ it resolves server-side against memberships like any `-account` value.
+- Single-account users never see either prompt.
+
+## Config schema
+
+`cliConfig` gains `Account string \`json:"account"\``. Two behavior changes
+to the file handling:
+
+- **Rewrites preserve unknown fields** from now on: load into a raw map,
+ set the keys being written, marshal back. Today's "unknown fields are
+ dropped on rewrite" makes every future key lossy against old binaries;
+ this stops the bleeding for keys added after this one.
+- **Accepted wrinkle:** binaries older than this change still drop the
+ `account` key when they rewrite the file (`auth default <url>`). It is a
+ default, not data — recoverable by re-choosing.
+
+A config `account` that no longer matches any membership fails server-side
+with the membership 404 — the error is not rewritten, but the muted
+`account: …` line above it names what the config supplied and from which
+file, so the fix is evident.
+
+## Discovery verbs
+
+- **`carlos accounts list`**: signed-in mode calls the endpoint and prints
+ a NAME/SQID/ROLE table (role rides free on the existing wire shape). The
+ registry form stays for boxes; the split keys off **registry-file
+ existence, not `resolveMode`** — the verb must keep working on a box
+ with a registry and neither bucket env nor credentials. `-console` skips
+ the registry even where one exists.
+- **`carlos auth whoami`**: after identity, fetches the endpoint and prints
+ `accounts: tom@keymail.dev (bdk), acme (bce)`. Degrades **silently** on
+ 404/error — whoami's job is identity; the membership line is a bonus, and
+ an old console must not make whoami look broken.
+
+## Security
+
+- The endpoint returns only the caller's own memberships, resolved from the
+ token's address — no account enumeration surface.
+- The chooser writes only `./.carlos/config` (0755 dir, non-secret file,
+ committed on purpose) and only after an explicit `y`.
+- The membership 404's byte-identical opacity for wrong/foreign accounts is
+ unchanged.
+
+## Testing
+
+- Console: `authcli_test` — own memberships only, deleted excluded, sorted,
+ 401 bare/wrong token.
+- CLI resolution: table test against httptest consoles — one/several/zero
+ memberships, endpoint-404 fallback to `ops`, config-account honored,
+ config console-mismatch ignored, explicit flags bypass everything.
+- Chooser: unit test with injected reader/writer — pick, re-prompt, EOF,
+ save-yes writes the file, save-no doesn't. TTY gating itself is covered
+ by the non-TTY refusal path in the resolution tests.
+- Config: `account` key round-trips; rewrite preserves unknown fields;
+ console-only files still parse ("" account = no default at this level).
+- Existing `carlosTestEnv` rules apply to anything that shells the binary.
+
+## Out of scope
+
+- #81 (apps create echoes sqid where a human wants the name), #83 (promote
+ version picker), #79 (ship-before-claim recovery).
+- A `carlos accounts default` verb — the chooser's save prompt plus editing
+ the file covers it.
+- Any change to box-side/direct-mode account handling.
diff --git a/docs/superpowers/specs/2026-08-12-proactive-cli-design.md b/docs/superpowers/specs/2026-08-12-proactive-cli-design.md
new file mode 100644
index 000000000..0087b81f1
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-12-proactive-cli-design.md
@@ -0,0 +1,128 @@
+# Proactive CLI: moved consoles, stale binaries, missing logins
+
+**Date:** 2026-08-12 · **Issues:** #160, #161, #162 · **Status:** approved
+(Paul, in-session: "let's get all that done"; the issues carry the designs)
+
+The direction (Paul, 2026-08-12): the CLI should never surface a bare
+status code or a dead end a person has to ask an oracle about. When it can
+see both the problem and the remedy, it explains the problem and — on a
+terminal — offers to perform the remedy. Scripts keep today's exact errors:
+they are load-bearing (`noCredentialsMsg` is string-matched) and a script
+cannot answer a prompt. All prompts ride the #159 machinery
+(`interactiveTerminal`, `chooserIn`, y/N wording).
+
+## 1. Console redirects (#160)
+
+**Today** the client follows Go's default redirect policy: GETs follow
+cross-host (Go strips Authorization → a mystery 401 from the new host),
+POSTs with unreplayable bodies surface the raw status ("unexpected
+response (status 308)" — Tom's case). Both are wrong.
+
+- **Never follow**: `CheckRedirect: http.ErrUseLastResponse` on the one
+ `consoleClient` http.Client. Every method now deterministically sees the
+ 3xx + `Location`.
+- **Typed**: 301/302/307/308 with a Location whose origin
+ `normalizeConsole` accepts becomes `consoleMovedError{From, To}` at the
+ response sites (`consoleClient.do`, `doMultipart`, `apiSession.getJSON`,
+ `postJSON`). Its `Error()` text carries the whole story for scripts:
+ `this console has moved: <from> -> <to> — run: carlos auth login -console <to>`.
+ A 3xx without a usable Location falls back to today's "unexpected
+ response" wording.
+- **Interactive**: where the typed error is born, a TTY gets:
+
+ this console has moved: https://dashboard.x -> https://console.x
+ log in to https://console.x now? [y/N]
+
+ y runs the standard device flow (`authLogin -console <to>` — browser
+ approval unchanged, credential minted per-origin, nothing reused). If a
+ `.carlos/config` on the walk names the old console, one more offer
+ rewrites it (`writeConfigKeys`, preserving everything else). The verb
+ then exits nonzero with `logged in to <to> — re-run your command` — the
+ original request died mid-flight against the old host; continuation is
+ #162's territory, not this one's.
+- Location is trusted for **display and prompting only**, the user
+ confirms before any credential exists, and `normalizeConsole`'s rules
+ (https, or http strictly on-box) gate the target.
+
+## 2. Offer to log in (#162)
+
+One seam: every API verb reaches credentials through `resolveMode` →
+`loggedInCredential` → `pick`. The two pick refusals that already name a
+console — "not logged in to X" and "the credential for X expired" — become
+a typed `notLoggedInError{Console, Expired}` with byte-identical message
+strings. On a TTY, `resolveMode`:
+
+ not logged in to https://console.carloku.com
+ log in now? [y/N]
+
+y runs `authLogin` inline against that console, reloads credentials,
+retries once, and **returns a working session — the original command
+continues**; the user typed `carlos ship` and a ship is what happens.
+The bare "no deployment target configured" case has no console to offer:
+a TTY is prompted `console URL: ` (validated by `normalizeConsole`, empty
+answer = today's error), then the same login-and-continue. n/EOF, non-TTY,
+and direct mode keep today's errors verbatim. The login flow itself is
+untouched — no shortcuts around the browser approval or step-up.
+
+## 3. Self-update (#161)
+
+**Detection** — passive, our infra, never blocking:
+- `latest.txt` (one line, `v0.7.0`) joins the apt static app; the check is
+ one GET to `https://apt.carlosframework.com/latest.txt` with a ~1.5s
+ timeout, silent on any failure.
+- Ran after a successful member verb, at most once per 24h (cache
+ `~/.carlos/update-check.json` `{checked_at, latest}`), and only when ALL
+ of: stdout is a TTY; `version.Build` is a release tag (a `dev` build
+ never checks); no `CARLOS_DEPLOYMENT_BUCKET`/`DIR`; not root; not opted
+ out (`CARLOS_NO_UPDATE_CHECK`, or `update_check: "off"` in config);
+ and the verb is not a box/daemon verb (edge, agent, adopt, bootstrap,
+ add, remove, route, ops, status, canary, vet, release-keygen).
+- The hint is one muted line:
+ `carlos v0.8.0 is available (you have v0.7.0) — run: carlos update`
+
+**`carlos update`** — detects how this binary is installed:
+- Homebrew path (`/Cellar/`, `/linuxbrew/`) → prints
+ `brew upgrade carlosframework/tap/carlos` and stops (brew owns brew).
+- dpkg owns the path (`dpkg -S`, when dpkg exists) → prints the apt
+ command and stops (apt owns apt).
+- Otherwise self-replace, y/N-gated on a TTY: download
+ `carlos-<GOOS>-<GOARCH>`, `SHA256SUMS.txt`, and `SHA256SUMS.txt.sig`
+ from the latest GitHub release; verify the sig, then the binary's
+ SHA256; write a temp file beside the current executable and rename over
+ it (the config files' crash-safety rule); a permission error names the
+ sudo-shaped fix instead of failing mid-way. Non-TTY never self-replaces.
+- Refusals: root, bucket env set (boxes roll via update-platform-binary.sh
+ and adoption — a self-updating box binary would fight the machinery),
+ `dev` builds (nothing meaningful to update to).
+
+**Integrity**: SHA256SUMS.txt gains a detached signature. A dedicated
+ed25519 CLI-update keypair (NOT the release key: different blast radius,
+different rotation story): private half at SSM `/carlos/cli-update-key`,
+public half a constant in the binary. `SHA256SUMS.txt.sig` =
+base64(ed25519.Sign(priv, file bytes)) — stdlib verify, no OpenPGP. The
+release recipe gains the signing step from v0.8.0 onward — the first
+binary that verifies is v0.8.0, and it downloads the LATEST release's
+sums, so retro-signing v0.7.0's would protect nothing.
+
+## Testing
+
+- Redirects: httptest consoles answering 3xx with/without Location, GET
+ and POST; typed error text; interactive path (forced TTY + injected
+ input) logs in against a fake new console and offers the config rewrite;
+ whoami's silent degrade still silent.
+- Login offer: resolveMode against empty/expired credentials + fake
+ console: y → session returned and original call proceeds; n/EOF/non-TTY
+ → today's exact errors (string-pinned); URL prompt path.
+- Update: check gating table (dev build, root skipped where testable,
+ bucket env, opt-outs, cache freshness, box verbs); hint text; `update`
+ verb against a fake release server — sig verify failure REFUSES, sha
+ mismatch REFUSES, happy path replaces a scratch binary; brew/dpkg paths
+ answer with the right command and touch nothing.
+- Everything through `carlosTestEnv` rules where the binary is shelled.
+
+## Out of scope
+
+- Following redirects automatically (deliberately never).
+- Auto-update without a prompt; package-manager installs self-replacing.
+- Signing the .debs themselves (the apt repo's InRelease already covers
+ apt-path integrity).
diff --git a/docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md b/docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md
new file mode 100644
index 000000000..80af54a52
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md
@@ -0,0 +1,424 @@
+# Woodstar → Carloku migration
+
+**Date:** 2026-08-12 · **Status:** draft (design approved in conversation
+2026-08-12; amended same day after re-recon — see §0) · **Requested by:**
+Paul
+
+**Supersedes:** the plan half of
+`docs/superpowers/plans/2026-08-02-plan-woodstar-flagship.md` (its recon
+remains the authoritative description of where Woodstar lives today; its
+task list is replaced by the plan that follows this spec).
+
+**Builds on:** route records (#137), route hostnames/aliases (#151),
+domain attach (#82), wildcard certs (2026-08-05 — the DNS-01 machinery
+this spec extends), and the kass/keymail platform-tenant migrations
+(the playbook).
+
+## 0. Amendment — the 2026-08-02 recon is stale (re-recon 2026-08-12)
+
+Read from woodstar main `b61409a` (`infra/prod-Caddyfile`,
+`infra/devbox-init.sh`, `cmd/`, `web/public/lib/api.js`); prod-box
+actuals still owed to the §7 pre-flight:
+
+- **Six services, not four.** Added since the old plan: `unfurl` (:7075,
+ link-card fetcher; SQLite `unfurl.db` is a disposable cache,
+ deliberately outside litestream) and `apbridge` (:7076, the
+ ActivityPub/Bluesky bridge: webfinger, host-meta, `/users/*`,
+ `/inbox`, `/ap/*`, and `/.well-known/atproto-did` on user subdomains).
+- **apbridge carries identity.** Its SQLite `apbridge.db` is in the
+ litestream set (three replicated DBs, not two), and its `-seed` file
+ (`apbridge.seed`) holds the bridge key material — the federation
+ identity. The seed is a flat file NOT covered by litestream or the
+ media timer; it must be copied at cutover, and §7's pre-flight
+ verifies how (or whether) prod backs it up today. apbridge also takes
+ a required `-domain` flag (`woodstar.app` on the flagship) — the old
+ plan's "no canonical-host pinning anywhere" claim is no longer true —
+ and dials out to `bsky.social` and Bridgy Fed (ordinary egress; fine).
+- **The per-user subdomain / Bluesky behavior is LIVE, not
+ aspirational.** Prod Caddy's `*.woodstar.app` block today:
+ `www` → apex (permanent redirect); `/.well-known/atproto-did` →
+ apbridge (what makes `paul.woodstar.app` a Bluesky handle); everything
+ else → temporary redirect to `https://woodstar.app/<leftmost-label>`
+ (the profile). §5's woodstar-side half is therefore **existing prod
+ behavior W0 must preserve at cutover**, not a follow-up issue.
+- **The fan-out set is wider** (§6 lists it). The reference
+ prod-Caddyfile lacks the `/unfurl*` route the devbox has — box
+ actuals to be verified in pre-flight; the migration includes it
+ either way.
+- The reference Caddyfile's header names `34.241.196.33` while the
+ 2026-08-02 recon verified EIP `52.30.232.124`; pre-flight confirms
+ the live address before any SSH step.
+
+Where this amendment contradicts the sections below, the amendment
+wins; §§2, 5, 6, 7 have been updated in place to match.
+
+## 1. Goal
+
+Woodstar's production — `woodstar.app`, `status.woodstar.app`, and
+working `*.woodstar.app` TLS + routing — serves from the Carloku
+flagship box (`i-092c0c1eea75723cb` / 99.81.104.219), riding carlos
+release channels. Carloku IS the CARLOS platform flagship deployment
+(Paul, 2026-08-12); "migrate to carloku" means onto that box.
+
+Hostnames survive byte-identical: passkeys (RP ID = page domain) and the
+iOS AASA binding (`J5NDF5PCD8.app.woodstar.ios`) are domain-bound. The
+devbox (`dev.woodstar.app`, `i-0c388ec47cf98067a`) stays put — it is the
+dev/canary workflow and is not migrating.
+
+Two deliberate product outcomes ride along:
+
+- The platform gains **delegated-challenge wildcard certs** (§4): the
+ standing answer to "wildcard TLS on a customer domain whose DNS
+ provider the platform must not know". Woodstar is treated as an
+ untrusted third-party customer for this feature even though it is a
+ platform tenant.
+- The platform gains **wildcard aliases** (§5): `*.<domain>` resolving
+ to a primary route, preserving Woodstar's "any subdomain serves the
+ app" contract and enabling the Bluesky handle story
+ (`@paul.woodstar.app` → the user's woodstar profile; atproto handle
+ verification needs `https://paul.woodstar.app/.well-known/atproto-did`
+ to answer over valid TLS).
+
+## 2. Tenancy shape
+
+**Platform tenant**, the keymail/kass pattern:
+
+- Seven carlos apps: `woodstar-relay`, `woodstar-media`,
+ `woodstar-indexer`, `woodstar-notify`, `woodstar-unfurl`,
+ `woodstar-apbridge` (kind binary, linux-arm64) and `woodstar-status`
+ (kind static). Six binaries stay six apps — matches the repo's
+ one-binary-per-service design value.
+- Six bespoke hardened units
+ `woodstar-{relay,media,indexer,notify,unfurl,apbridge}.service`:
+ static system user `woodstar`, harden-host.sh sandbox drop-ins,
+ loopback ports 8451 relay / 8452 media / 8453 indexer / 8454 notify /
+ 8455 unfurl / 8456 apbridge, data under `/data/woodstar` (0700;
+ `/data` 0711). apbridge runs with
+ `-domain woodstar.app -seed /data/woodstar/apbridge.seed` plus
+ relay/media upstream URLs; the seed file is migrated at cutover (§7).
+- Env file `/etc/carlos/apps/woodstar.env` (0600 root) carrying
+ `WOODSTAR_APPLE_APP_ID=J5NDF5PCD8.app.woodstar.ios` — present
+ **before** the DNS flip, or iOS passkey assertions break. No other
+ runtime env; no secrets, no SSM staging, no IMDS allowance (the Go
+ services contain zero AWS SDK; litestream and the media timer run as
+ root).
+- Backups move to the platform data bucket:
+ `s3://carlos-data-ie-271376211898/woodstar/{relay,indexer,apbridge,media}`
+ (host role already has RW — zero IAM/tofu change). The old
+ `woodstar-backups-271376211898` bucket keeps pre-migration history,
+ frozen. Litestream config is extended root-side for the THREE
+ replicated DBs (relay, indexer, apbridge — unfurl's cache is
+ deliberately excluded, per the repo's own rule), NOT enabled until
+ cutover; media backup is a root hourly additive `aws s3 sync` timer.
+- Headroom: the whole stack runs in a t4g.nano today (data in the
+ single-digit MB); the flagship verdict from the 2026-08-02 plan
+ stands, re-checked at rehearsal with all six services.
+
+## 3. Sequencing — after the custom-domain track
+
+Gate: **#137 merged and deployed, then #151, then #82 — live on the
+flagship** — before rehearsal-onward steps. `woodstar.app` and
+`status.woodstar.app` then attach through the product surface
+end-to-end:
+
+- The relay's primary route exists as a **declared route record**
+ (#137 vocabulary); `woodstar.app` attaches as an **alias** to it via
+ the #82/#151 door.
+- `status.woodstar.app` attaches for `woodstar-status` (alias to its
+ primary route, or first-route static attach — whichever the landed
+ door supports for the shape at hand).
+- No `carlos add` over SSM for the public hostnames.
+
+Work that does NOT wait (no code-path overlap with the track): W0 in
+the woodstar repo (§6), and the two platform features (§4, §5) up to
+review — their claim-shape integration point is coordinated with the
+#151/#82 sessions (§10).
+
+## 4. Delegated-challenge wildcard certs
+
+The platform must hold no customer DNS credentials and never know the
+customer's DNS provider. Wildcard issuance is DNS-01, which normally
+requires writing TXT records into the customer zone — squared by
+**ACME challenge delegation via CNAME** (the Cloudflare-for-SaaS
+pattern):
+
+1. The customer creates, once, in their own zone with any provider:
+ `_acme-challenge.<domain>` CNAME →
+ `_acme-challenge.<domain>.<delegation-domain>`, where the delegation
+ domain is a platform-controlled zone (under `oncarlos.com`; exact
+ label fixed in the plan).
+2. At issuance the edge publishes the challenge TXT **in its own zone**
+ at the delegation target, via the DNSimple provider it already has.
+3. Let's Encrypt follows the CNAME from the customer zone into ours,
+ sees the TXT, issues `*.<domain>`.
+
+Properties: no customer credentials; revocable by the customer deleting
+one record; the CNAME's existence is standing proof of domain control —
+strictly stronger evidence than #82's DNS-pointing ruling. This
+retires part of the "bring-your-own apps domain / DNS delegation" price
+tag #82 §9 left unpaid.
+
+Mechanics (all provider-blind, the `dnsProvider` seam is untouched):
+
+- `wildcard.go` computes `challengeFQDN = _acme-challenge.<parent>`
+ today. Delegated parents get a parent → delegation-target mapping;
+ `solveDNS01` publishes at the target. **`waitForTXT` polls the
+ delegation target, not the original name** (amended per final
+ review): we confirm the write we control, and the customer zone's
+ negative-cache TTL is not ours to bound. Consequence, stated:
+ `waitForTXT` is NOT a delegation-setup check — a missing customer
+ CNAME sails through the wait and fails at Let's Encrypt, one failed
+ validation per parent per hour (inside LE limits); the attach-surface
+ CNAME gate below is the real setup check.
+- **Platform parents always win** (final-review ruling): a delegated
+ entry colliding with a static domain or sitting under the apps
+ domain is dropped at `refresh()`, never preferred — one bad claim
+ must not degrade a live platform wildcard's renewal.
+- **Declaration — two additive claim fields** (the #82 session's
+ catch: one bool must not fuse two orthogonal capabilities):
+ - `wildcard: true` — this domain is a **delegated wildcard parent
+ for cert issuance** (§4 only). Useful alone: one wildcard cert can
+ cover several named aliases without per-host CT-logged certs.
+ - `catchall: true` — **derive the `*.<host>` alias entry** to the
+ primary (§5). Requires `wildcard` (a catch-all no cert can serve
+ is refused at the attach surface, not stored).
+ - On a first-route claim (empty `route`), `catchall` aliases the
+ claim's **own materialized primary route** once #137 reconcile
+ mints it — defined here so the empty-route case is never
+ ambiguous. Woodstar sets both fields.
+ - Shape status: **SETTLED with the #151 session** — both fields
+ declared on `internal/domains.Claim` with the orthogonality and
+ refusal rule in doc comments (`worktree-route-hostnames` 086f150).
+ Absent fields = today's behavior byte-identical. Standing rule
+ from that agreement: #151's converge preserves only DECLARED
+ struct fields, so **any new claim field lands on
+ `internal/domains.Claim` in the same change that first writes
+ it**. Routeless claims are ignored by #151's reconcile, so this
+ spec's derivation owns the empty-route+catchall case outright.
+ The edge derives delegated wildcard parents from claims exactly
+ as it derives `<acct>.<apps-domain>` parents from the route
+ table — no bare env list.
+ - The delegation-CNAME stamp joins the domains GET row as an
+ additive `delegation_status` field (name agreed with #151; their
+ row JSON is additive and the CLI tolerates unknown fields), riding
+ the same read path as the `pointing`/`elsewhere` stamps.
+- **Gate:** the attach surface verifies the delegation CNAME exists
+ before enabling (the #82 `pointing`/`elsewhere` stamp spirit) — a
+ missing CNAME is an honest stamp, not a silent hourly issuance
+ failure.
+- Every failure degrades to per-host issuance, like today's wildcard
+ failures — never a dead edge.
+
+## 5. Wildcard aliases
+
+A wildcard cert alone leaves arbitrary subdomains handshaking into the
+edge's no-route response. Woodstar's live contract (prod Caddy wildcard
+block, §0) is per-user subdomains: `paul.woodstar.app` answers
+`/.well-known/atproto-did` (the Bluesky handle) and redirects everything
+else to the apex profile. So:
+
+- `*.<domain>` becomes a `route_aliases`-shaped entry resolving to a
+ named primary route: one-label matching only (a wildcard matches
+ exactly one label, mirroring the cert), explicit named routes/aliases
+ win over the wildcard.
+- Resolution is to the primary's upstream **entry** (the identical map
+ value, not a copied address) — #151/#152's agreed rule — so wildcard
+ traffic inherits multi-upstream health-checked routing the moment
+ #152 lands, with zero wildcard-side changes. Derivation slots beside
+ #151's `ReconcileAliases` (`internal/domains/aliases.go`), which
+ ignores fields it doesn't know.
+- **Parent-derivation exclusion (#151's caution, pinned):** the edge's
+ `currentHosts()` excludes alias-flagged entries from wildcard-cert
+ parent derivation; the derived `*.<host>` entries must carry the same
+ alias flag (`up.alias`) so DNS-01 parent derivation never
+ double-counts a delegated domain.
+- The upstream sees the **original `Host` header** — per-user host
+ interpretation is app logic, and the platform must not foreclose it.
+- Per #151's pinned rule, content/backing derivation uses the primary's
+ host; the request host is TLS/cache-key/metering only. Metering
+ consequence (flagged by the unit-economics session): each concrete
+ subdomain becomes its own usage row attributed to the primary route's
+ account/app — correct, provided alias resolution decides route
+ identity before the metering wrap, which is #151's ordering.
+
+The woodstar-side half — the relay reproducing the Caddy wildcard
+block's host-aware behavior (`www` → apex permanent redirect,
+`/.well-known/atproto-did` → apbridge, anything else → temporary
+redirect to `https://woodstar.app/<leftmost-label>`) — is **live prod
+behavior and part of W0** (§6), not a follow-up. The platform
+guarantees: any `*.woodstar.app` request arrives at the relay with Host
+intact over valid TLS.
+
+## 6. W0 — relay path fan-out (the one woodstar code change)
+
+The edge routes by Host only; the deployed client and the federated
+world expect Caddy's path fan-out on one origin. Fix is relay-side
+(W0a from the 2026-08-02 plan, widened by §0's re-recon):
+
+- Optional flags `-media-upstream`, `-indexer-upstream`,
+ `-notify-upstream`, `-unfurl-upstream`, `-apbridge-upstream` on
+ `cmd/relay`. When set, the relay reverse-proxies:
+ `/media` + `/media/*` → media (including `POST /media` uploads);
+ `/api/*` → indexer; `/stream/*` → notify; `/unfurl*` AND `/img*` →
+ unfurl (`/img` serves every link-card image — omitting it fails as a
+ 200 SPA page inside `<img>` tags, caught in final review);
+ `/.well-known/webfinger`, `/.well-known/host-meta`, `/users/*`,
+ `/inbox`, `/ap/*` → apbridge — the devbox Caddyfile's route set,
+ which is the truthful superset of prod's. `httputil.ReverseProxy`
+ passes WS upgrades and flushes `text/event-stream`.
+- **Host-aware wildcard behavior** (only when fan-out is enabled and
+ the request Host is a one-label subdomain of the relay's domain, not
+ the apex): `www` → permanent redirect to the apex with URI preserved;
+ `/.well-known/atproto-did` → apbridge; everything else → temporary
+ redirect to `https://<apex>/<leftmost-label>` — byte-matching the
+ prod Caddy wildcard block. Apex requests are untouched.
+- Unset = today's behavior byte-identical; dev/devbox unaffected (Caddy
+ keeps the fan-out there).
+- TDD in the woodstar repo; lands via woodstar PR; cutover binaries are
+ the merged W0 sha (the dfe406a shipment already in the bucket is
+ pipeline-validation only, superseded — it predates unfurl and
+ apbridge entirely).
+
+## 7. Rehearsal, cutover, rollback
+
+**Playbook constraints (inherited, standing):** no DNS changes and no
+touching running woodstar services until the gated steps; the flip is
+HELD for Paul's explicit go; rehearsals only ever read COPIES — never
+point a litestream at the live `prod/` prefixes (lineage fork); never
+run old-box and flagship stacks against the same replication prefixes
+at once; no `carlos bootstrap` on the live flagship.
+
+**Old-box access:** the prod box (`ec2-user@52.30.232.124`, SSH-only,
+no SSM) gets an **allow rule for the executing session's SSH** so the
+session drives verification and cutover steps directly.
+
+**Pre-flight (any time, no prod impact):**
+
+- Over SSH (confirming the live address first — §0's IP discrepancy):
+ `systemctl cat` on all six `woodstar-*` units (exact flags/env,
+ especially apbridge's `-domain`/`-seed`/`-bridgy`/`-bsky-api`), real
+ per-service RSS, `ls /opt/woodstar/data` (expect relay.db,
+ indexer.db, apbridge.db, apbridge.seed, unfurl.db, media — anything
+ else is a finding), the box's actual Caddyfile vs the repo reference
+ (`/unfurl*` presence), and `/etc/litestream.yml` actuals (which DBs
+ replicate, and whether `apbridge.seed` has ANY backup today — if
+ not, copy it to the backup bucket immediately as its own pre-flight
+ fix, it is the federation identity).
+- Create the ACME delegation CNAME in Route53 (inert until used);
+ obtain and handshake-verify `*.woodstar.app` on the flagship
+ **before** the flip — issuance does not depend on the A records.
+- Attach `woodstar.app` + `status.woodstar.app` through the door;
+ declare the wildcard alias. The relay's primary route record is
+ materialized and adopted, and all alias entries are derived box-side,
+ pre-flip. Per-host certs for the two named hosts mint on first
+ post-flip hit.
+
+**Rehearsal (copies only):** server-side CopyObject of the backups into
+`carlos-data-ie-…/woodstar-rehearsal/`; litestream restore the three
+DBs + media sync + a seed-file copy on the flagship;
+`PRAGMA integrity_check` on all three; transient units on rehearsal
+ports; an sslip.io rehearsal route. apbridge rehearses with `-bridgy`
+and `-bsky-api` pointed at throwaway values (its own flags exist for
+exactly this) so the rehearsal never dials the real federation. The
+point is the **path-fan-out check through the edge**: feed renders
+restored posts, `/media/*` images load, `/api/search` returns,
+`/stream/*` SSE streams, `/subscribe` WS connects, `/unfurl` answers,
+webfinger + `/users/*` respond from the bridge — plus woodstar's own
+WebKit `console-check.mjs`. Record freeze-to-serving time; tear down
+units, data, routes.
+
+**Cutover (HELD for Paul's explicit go; quiet hour; TTLs already 300):**
+
+Pre-flight invariant (final-review catch): **`status.woodstar.app`'s
+exact-host route MUST exist before the wildcard alias serves** — with
+the alias live and the status route missing, status requests 302 to
+`woodstar.app/status` (an SPA client route) and the status page
+silently becomes the app. Exact-host beats wildcard; verify the
+precedence in rehearsal.
+
+1. Old box (SSH): stop the six `woodstar-*` units → run one final
+ media-backup pass → wait ~30 s for litestream's final WAL frames →
+ stop litestream → copy `apbridge.seed` off the box → verify newest
+ backup timestamps are post-stop.
+2. Flagship: litestream restore the three DBs from `woodstar-backups` +
+ media sync + seed file into `/data/woodstar`; chown woodstar;
+ `PRAGMA integrity_check`; start the six units; enable flagship
+ litestream config + media timer (fresh `carlos-data-ie` prefixes —
+ order is stop-old → restore-new → start-new).
+3. Smoke BEFORE DNS: loopback `/info` 200, AASA correct, webfinger
+ answers from the bridge,
+ `curl --resolve woodstar.app:443:99.81.104.219` through the edge.
+4. Flip Route53 (zone `Z07702842ZC4MUKZR07JB`, same account):
+ `woodstar.app` A and `*.woodstar.app` A → 99.81.104.219. Watch the
+ per-host certs issue.
+5. Paul's browser pass: passkey sign-in, post, image upload + display,
+ search, live notification, DM, link-card unfurl, iOS app AASA
+ validation, status page, a `paul.woodstar.app` profile redirect +
+ its `/.well-known/atproto-did`, and federation (a webfinger lookup
+ from a real Fediverse server or Bridgy Fed still resolving).
+
+**Rollback (any point before Paul blesses it):** stop flagship units +
+its litestream; restart the old box's units + litestream; flip the two
+A records back. Writes accepted by the flagship during the window live
+only in its lineage — keep the window short. Old box: units stopped,
+disk intact, frozen 30 days.
+
+**Named failure shapes:**
+
+- **Degraded-not-broken:** a misconfigured fan-out or alias leaves the
+ site "working" while media/search/notify silently fail. The four-path
+ checks run at rehearsal AND at cutover — never skipped.
+- **Founder trap:** never start units against an empty `/data/woodstar`
+ reachable on the real hostname — the first registrant becomes
+ founder. Restore is explicit; there is no restore-on-boot.
+- **SSE/WS through two proxied hops** (edge → relay → notify): verified
+ in rehearsal, not assumed.
+
+## 8. Testing
+
+- Platform: the full gate (`go build/vet/test ./...`, gofmt, darwin
+ cross-compile) plus delegated-wildcard unit tests (parent→target
+ mapping; TXT published at the target, not the origin; cleanup;
+ degradation to per-host on failure), wildcard-alias tests (one-label
+ matching, named-route precedence, Host preserved to upstream), and
+ parse goldens for the agreed claim-shape extension (additive rule —
+ pre-change claims must still parse).
+- Woodstar: W0 TDD (proxy wiring, SSE flush, WS `/subscribe`
+ untouched); `make test` green; e2e harness where practical.
+- Live verification is §7's rehearsal + cutover checks — verify against
+ the thing changed, with the binary built.
+
+## 9. Post-cutover
+
+- Promote the W0 sha to stable after the 72 h supply-chain bake.
+- Repoint status publishing: `carlos ship -kind static` + promote
+ replaces `scripts/status-publish.sh`.
+- Note woodstar `infra/` tofu drift (records + stopped units outside
+ its state); retire the old prod instance after the 30-day freeze;
+ zone, bucket, devbox, IAM stay.
+- Update woodstar's CLAUDE.md "The boxes" section post-retirement, and
+ its `infra/prod-Caddyfile` reference-copy header (the file describes
+ a box that no longer serves).
+
+## 10. Coordination points
+
+- **Claim-shape: SETTLED 2026-08-12.** `wildcard: true` additive on
+ schema 1, agreed with the #151 session (their branch fa5dc0b carries
+ the field; §4 has the detail). The #151 session pings this one when
+ their console door + CLI land, so deploy-flow tasks can cite exact
+ paths.
+- **Delegation-domain label** (§4): fixed in the plan (a name under
+ `oncarlos.com`).
+- **status.woodstar.app attach shape** (§3): alias vs first-route
+ static — decided against the landed #82 door.
+
+## 11. Out of scope, named
+
+- Edge path-prefix routing (W0b) — relay fan-out covers it.
+- New per-user-subdomain features beyond parity with the live Caddy
+ behavior (which W0 reproduces) — e.g. serving the full app on
+ subdomains rather than redirecting — woodstar repo, later.
+- Devbox migration — stays on Caddy wildcard DNS-01, untouched.
+- DNS record automation — the delegation CNAME and the cutover flip are
+ operator/customer acts, per #82's ruling.
+- Multi-provider DNS on one edge — delegation makes it unnecessary.
diff --git a/fleets.go b/fleets.go
index 8dffa5b06..efaa88cda 100644
--- a/fleets.go
+++ b/fleets.go
@@ -76,7 +76,8 @@ const fleetsUsage = `usage: carlos fleets <create|add-box|rotate-token|tag-box|d
list every fleet the account owns, with each box's label,
status and tags.
- -account <sqid|name> account the fleet belongs to (default: ops) —
+ -account <sqid|name> account the fleet belongs to (default: your only
+ account, or the configured/chosen one) —
resolved by the console against your own
memberships.
-account-id <sqid> account sqid, matched as a sqid ONLY — supersedes
@@ -155,7 +156,7 @@ func validBoxLabel(label string) error {
func runFleetsCreate(args []string) {
fs := flag.NewFlagSet("fleets create", flag.ExitOnError)
bucket := fs.String("bucket", "", "the customer's own data bucket (must already exist)")
- acctRaw := fs.String("account", "ops", "account (sqid or name) to own the fleet")
+ acctRaw := fs.String("account", "", "account (sqid or name) to own the fleet (default: your only account, or the configured/chosen one)")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
consoleFlag := fs.String("console", "", "console to create it through (default: the only logged-in one)")
fs.Parse(args)
@@ -170,8 +171,8 @@ func runFleetsCreate(args []string) {
bucketV := strings.TrimSpace(*bucket)
sess := fleetsSignedInSession(*consoleFlag)
- acct := apiAccount(acctRaw, acctIDRaw)
fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
f, err := sess.createFleet(context.Background(), acct, name, bucketV)
if err != nil {
log.Fatalf("fleets create: %v", err)
@@ -182,7 +183,7 @@ func runFleetsCreate(args []string) {
func runFleetsAddBox(args []string) {
fs := flag.NewFlagSet("fleets add-box", flag.ExitOnError)
- acctRaw := fs.String("account", "ops", "account (sqid or name) the fleet belongs to")
+ acctRaw := fs.String("account", "", "account (sqid or name) the fleet belongs to (default: your only account, or the configured/chosen one)")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
consoleFlag := fs.String("console", "", "console to add the box through (default: the only logged-in one)")
fs.Parse(args)
@@ -199,8 +200,8 @@ func runFleetsAddBox(args []string) {
}
sess := fleetsSignedInSession(*consoleFlag)
- acct := apiAccount(acctRaw, acctIDRaw)
fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
token, err := sess.addFleetBox(context.Background(), acct, fleet, label)
if err != nil {
log.Fatalf("fleets add-box: %v", err)
@@ -210,7 +211,7 @@ func runFleetsAddBox(args []string) {
func runFleetsRotateToken(args []string) {
fs := flag.NewFlagSet("fleets rotate-token", flag.ExitOnError)
- acctRaw := fs.String("account", "ops", "account (sqid or name) the fleet belongs to")
+ acctRaw := fs.String("account", "", "account (sqid or name) the fleet belongs to (default: your only account, or the configured/chosen one)")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
consoleFlag := fs.String("console", "", "console to rotate through (default: the only logged-in one)")
fs.Parse(args)
@@ -227,8 +228,8 @@ func runFleetsRotateToken(args []string) {
}
sess := fleetsSignedInSession(*consoleFlag)
- acct := apiAccount(acctRaw, acctIDRaw)
fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
token, err := sess.rotateFleetBoxToken(context.Background(), acct, fleet, label)
if err != nil {
log.Fatalf("fleets rotate-token: %v", err)
@@ -273,7 +274,7 @@ func printFleetToken(fleet, label, token string, rotated bool) {
// "named, empty", not the value in *tags.
func runFleetsTagBox(args []string) {
fs := flag.NewFlagSet("fleets tag-box", flag.ExitOnError)
- acctRaw := fs.String("account", "ops", "account (sqid or name) the fleet belongs to")
+ acctRaw := fs.String("account", "", "account (sqid or name) the fleet belongs to (default: your only account, or the configured/chosen one)")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
consoleFlag := fs.String("console", "", "console to tag through (default: the only logged-in one)")
tags := fs.String("tags", "", `comma-separated tags, full replace ("" clears every tag) — required`)
@@ -305,8 +306,8 @@ func runFleetsTagBox(args []string) {
}
sess := fleetsSignedInSession(*consoleFlag)
- acct := apiAccount(acctRaw, acctIDRaw)
fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
if err := sess.tagFleetBox(context.Background(), acct, fleet, label, tagsV); err != nil {
log.Fatalf("fleets tag-box: %v", err)
}
@@ -319,7 +320,7 @@ func runFleetsTagBox(args []string) {
func runFleetsDetachBox(args []string) {
fs := flag.NewFlagSet("fleets detach-box", flag.ExitOnError)
- acctRaw := fs.String("account", "ops", "account (sqid or name) the fleet belongs to")
+ acctRaw := fs.String("account", "", "account (sqid or name) the fleet belongs to (default: your only account, or the configured/chosen one)")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
consoleFlag := fs.String("console", "", "console to detach through (default: the only logged-in one)")
fs.Parse(args)
@@ -336,8 +337,8 @@ func runFleetsDetachBox(args []string) {
}
sess := fleetsSignedInSession(*consoleFlag)
- acct := apiAccount(acctRaw, acctIDRaw)
fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
if err := sess.detachFleetBox(context.Background(), acct, fleet, label); err != nil {
log.Fatalf("fleets detach-box: %v", err)
}
@@ -346,14 +347,14 @@ func runFleetsDetachBox(args []string) {
func runFleetsList(args []string) {
fs := flag.NewFlagSet("fleets ls", flag.ExitOnError)
- acctRaw := fs.String("account", "ops", "account (sqid or name) to list fleets for")
+ acctRaw := fs.String("account", "", "account (sqid or name) to list fleets for (default: your only account, or the configured/chosen one)")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)")
fs.Parse(args)
sess := fleetsSignedInSession(*consoleFlag)
- acct := apiAccount(acctRaw, acctIDRaw)
fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
fleets, err := sess.listFleets(context.Background(), acct)
if err != nil {
log.Fatalf("fleets ls: %v", err)
diff --git a/instances_cli.go b/instances_cli.go
index 04b37bca0..cf5c6977a 100644
--- a/instances_cli.go
+++ b/instances_cli.go
@@ -118,8 +118,8 @@ func runInstancesEnable(args []string) {
}
seedReq = &argv
}
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
m, err := sess.instancesEnable(context.Background(), acct, *app, channelReq, seedReq)
if err != nil {
log.Fatalf("instances enable: %v", err)
@@ -241,7 +241,7 @@ func runInstancesCreate(args []string) {
region := fs.String("region", "", "region code for the record (subject to the manifest's allow-list)")
channel := fs.String("channel", "", "release channel the instance follows (default: the manifest's, then stable)")
consoleFlag := fs.String("console", "", "console to create through (default: the only logged-in one)")
- acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
fs.Parse(args)
if *app == "" || *host == "" {
@@ -249,7 +249,7 @@ func runInstancesCreate(args []string) {
}
sess := requireAPISession("create", *consoleFlag)
printUsing(stdout, sess)
- rec, err := sess.instanceCreate(context.Background(), apiAccount(acctRaw, acctIDRaw), *app,
+ rec, err := sess.instanceCreate(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app,
strings.TrimSpace(*host), strings.TrimSpace(*region), strings.TrimSpace(*channel))
if err != nil {
log.Fatalf("instances create: %v", err)
@@ -264,14 +264,14 @@ func runInstancesList(args []string) {
match := fs.String("match", "", "only hosts containing this substring")
health := fs.String("health", "", "only this state: running, serving, setting-up, asleep, no-readings, not-responding, failed")
consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)")
- acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
fs.Parse(args)
if *app == "" {
log.Fatal(instancesRecordsUsage)
}
sess := requireAPISession("list", *consoleFlag)
- recs, truncated, total, err := sess.instanceList(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, *match, *health)
+ recs, truncated, total, err := sess.instanceList(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, *match, *health)
if err != nil {
log.Fatalf("instances list: %v", err)
}
@@ -304,7 +304,7 @@ func runInstancesDelete(args []string) {
app := fs.String("app", "", "app the instance belongs to")
host := fs.String("host", "", "instance host whose record to retire")
consoleFlag := fs.String("console", "", "console to delete through (default: the only logged-in one)")
- acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
fs.Parse(args)
if *app == "" || *host == "" {
@@ -312,7 +312,7 @@ func runInstancesDelete(args []string) {
}
sess := requireAPISession("delete", *consoleFlag)
printUsing(stdout, sess)
- if err := sess.instanceDelete(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, strings.TrimSpace(*host)); err != nil {
+ if err := sess.instanceDelete(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, strings.TrimSpace(*host)); err != nil {
log.Fatalf("instances delete: %v", err)
}
fmt.Fprintf(stdout, "%s %s — its route retires on the next reconcile pass; parked data stays\n",
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index f2ff4805a..7bd670660 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -1367,8 +1367,8 @@ func RunWithActivatorReady(args []string, act Activator, cache *Cache, ready fun
e.OnReload(w.refresh)
w.refresh()
go w.run()
- log.Printf("edge: wildcard certs on (static %v, apps domain %q)",
- splitDomains(os.Getenv("CARLOS_WILDCARD_DOMAINS")), os.Getenv("PLATFORM_APPS_DOMAIN"))
+ log.Printf("edge: wildcard certs on (static %v, apps domain %q, delegation zone %q)",
+ splitDomains(os.Getenv("CARLOS_WILDCARD_DOMAINS")), os.Getenv("PLATFORM_APPS_DOMAIN"), w.delegationZone)
}
// The non-ACME :80 handler. Known hosts get the classic redirect to https
diff --git a/internal/edge/wildcard.go b/internal/edge/wildcard.go
index 251c8e0fc..32ce6e5e1 100644
--- a/internal/edge/wildcard.go
+++ b/internal/edge/wildcard.go
@@ -16,8 +16,11 @@ package edge
// instance hosts are one label deep).
//
// Canary hosts (<name>.<app>.<acct>.…, one label too deep) and custom
-// domains stay per-host HTTP-01. The wildcard only changes how a handshake
-// GETS a certificate: routing, HostPolicy and the :80 handler are
+// domains stay per-host HTTP-01 — except a custom domain approved as a
+// delegated wildcard parent (spec §4), which gets its own *.<parent>
+// wildcard via a challenge TXT published in our own zone. The wildcard
+// only changes how a handshake GETS a certificate: routing, HostPolicy and
+// the :80 handler are
// untouched, and every failure (DNS provider outage, ACME outage,
// unreadable cache) degrades to exactly the per-host behaviour — never a
// dead edge.
@@ -45,6 +48,8 @@ import (
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
+
+ "github.com/carlosframework/platform/internal/provision"
)
const letsEncryptDirectory = "https://acme-v02.api.letsencrypt.org/directory"
@@ -65,6 +70,19 @@ type wildcardManager struct {
appsDomain string
hosts func() []string // live route hosts (Edge.currentHosts)
+ // delegated lists customer domains approved for delegated-challenge
+ // wildcard issuance (spec §4: claim-derived; nil = none). Their
+ // challenge TXTs publish into delegationZone — our own zone — at
+ // _acme-challenge.<parent>.<delegationZone>, which the customer has
+ // CNAMEd _acme-challenge.<parent> to. The platform never touches,
+ // or knows, the customer's DNS provider.
+ delegated func() []string
+ delegationZone string
+
+ // delegatedSet is refresh()'s snapshot of delegated(), read by
+ // challengeFQDN under mu (issuance runs on run()'s goroutine).
+ delegatedSet map[string]bool
+
mu sync.Mutex
parents []string // last computed parent set (sorted)
certs map[string]*tls.Certificate
@@ -81,26 +99,31 @@ type wildcardManager struct {
wake chan struct{}
}
-func newWildcardManager(staticDomains []string, appsDomain string, hosts func() []string, cache autocert.Cache, dns dnsProvider) *wildcardManager {
+func newWildcardManager(staticDomains []string, appsDomain string, hosts func() []string, delegated func() []string, delegationZone string, cache autocert.Cache, dns dnsProvider) *wildcardManager {
return &wildcardManager{
- cache: cache,
- dns: dns,
- directory: envOr("CARLOS_ACME_DIRECTORY", letsEncryptDirectory),
- staticDomains: staticDomains,
- appsDomain: strings.ToLower(strings.TrimSpace(appsDomain)),
- hosts: hosts,
- certs: map[string]*tls.Certificate{},
- nextAttempt: map[string]time.Time{},
- wake: make(chan struct{}, 1),
+ cache: cache,
+ dns: dns,
+ directory: envOr("CARLOS_ACME_DIRECTORY", letsEncryptDirectory),
+ staticDomains: staticDomains,
+ appsDomain: strings.ToLower(strings.TrimSpace(appsDomain)),
+ hosts: hosts,
+ delegated: delegated,
+ delegationZone: strings.ToLower(strings.TrimSpace(delegationZone)),
+ certs: map[string]*tls.Certificate{},
+ nextAttempt: map[string]time.Time{},
+ delegatedSet: map[string]bool{},
+ wake: make(chan struct{}, 1),
}
}
-// parentsFor computes the wildcard parent set: the static domains plus
+// parentsFor computes the wildcard parent set: staticAndDelegated (the
+// static CARLOS_WILDCARD_DOMAINS plus any delegated parents refresh() has
+// already vetted — both arrive pre-merged through this one parameter) union
// <acct>.<appsDomain> for every route host exactly two labels under
// appsDomain (<app>.<acct>.<appsDomain>). Canary hosts (three labels), the
// apex, and foreign domains contribute nothing. Sorted and deduped so the
// set is deterministic for logs and tests.
-func parentsFor(static []string, appsDomain string, hosts []string) []string {
+func parentsFor(staticAndDelegated []string, appsDomain string, hosts []string) []string {
seen := map[string]bool{}
var out []string
add := func(p string) {
@@ -109,7 +132,7 @@ func parentsFor(static []string, appsDomain string, hosts []string) []string {
out = append(out, p)
}
}
- for _, d := range static {
+ for _, d := range staticAndDelegated {
add(strings.ToLower(d))
}
if appsDomain != "" {
@@ -129,17 +152,48 @@ func parentsFor(static []string, appsDomain string, hosts []string) []string {
}
// refresh recomputes the parent set from the live route table (called from
-// the reload hook on every route swap, and once at startup). If any parent
-// lacks a usable cert it wakes run() — that is what makes a NEW account's
-// wildcard start issuing the moment its first route lands, instead of on
-// the next 12h tick. A wake cannot bypass a failing parent's backoff: run()
-// still skips any parent whose nextAttempt hasn't passed, so a wake only
-// advances parents that aren't already backing off.
+// the reload hook on every route swap, and once at startup), folding in
+// delegated parents (spec §4) alongside the static and per-account ones.
+// Platform parents always win: a delegated entry that collides with a
+// static domain, or falls under the apps domain, is dropped here — before
+// it reaches parentsFor's union or delegatedSet — because letting it
+// through would redirect a PLATFORM parent's challenge to a delegation
+// target where no CNAME exists, silently degrading its wildcard renewal to
+// per-host. If any parent lacks a usable cert it wakes run() — that is what
+// makes a NEW account's wildcard start issuing the moment its first route
+// lands, instead of on the next 12h tick. A wake cannot bypass a failing
+// parent's backoff: run() still skips any parent whose nextAttempt hasn't
+// passed, so a wake only advances parents that aren't already backing off.
func (w *wildcardManager) refresh() {
- parents := parentsFor(w.staticDomains, w.appsDomain, w.hosts())
+ var delegated []string
+ if w.delegated != nil {
+ static := make(map[string]bool, len(w.staticDomains))
+ for _, d := range w.staticDomains {
+ static[strings.ToLower(strings.TrimSpace(d))] = true
+ }
+ appsSuffix := ""
+ if w.appsDomain != "" {
+ appsSuffix = "." + w.appsDomain
+ }
+ for _, d := range w.delegated() {
+ if d = strings.ToLower(strings.TrimSpace(d)); d == "" {
+ continue
+ }
+ if static[d] || (appsSuffix != "" && strings.HasSuffix(d, appsSuffix)) {
+ continue
+ }
+ delegated = append(delegated, d)
+ }
+ }
+ parents := parentsFor(append(append([]string(nil), w.staticDomains...), delegated...), w.appsDomain, w.hosts())
now := time.Now()
w.mu.Lock()
w.parents = parents
+ set := make(map[string]bool, len(delegated))
+ for _, d := range delegated {
+ set[d] = true
+ }
+ w.delegatedSet = set
need := false
for _, p := range parents {
c := w.certs[p]
@@ -244,6 +298,27 @@ func coversParent(cert *tls.Certificate, parent string) bool {
return ok && pub.Equal(cert.Leaf.PublicKey)
}
+// challengeFQDN returns where parent's DNS-01 TXT is published: the
+// delegation target inside our own zone for delegated parents, or
+// directly at _acme-challenge.<parent> otherwise. With no delegation
+// zone configured the direct form is used for everyone — the
+// misconfigured state degrades to the historical behavior and per-host
+// issuance keeps covering (obtain will fail on the CA's lookup, log,
+// and back off — never a dead edge).
+func (w *wildcardManager) challengeFQDN(parent string) string {
+ if w.delegationZone != "" && w.isDelegated(parent) {
+ return "_acme-challenge." + parent + "." + w.delegationZone
+ }
+ return "_acme-challenge." + parent
+}
+
+// isDelegated reports whether parent is in the delegated set.
+func (w *wildcardManager) isDelegated(parent string) bool {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ return w.delegatedSet[parent]
+}
+
// wildcardPEMKey is the cache key for a parent's wildcard cert+key PEM —
// Eleven's file name, so a dir cache written by that lineage reads with no
// migration, and the keys sit additively beside autocert's per-host entries.
@@ -448,7 +523,7 @@ func (w *wildcardManager) solveDNS01(ctx context.Context, client *acme.Client, p
return err
}
- challengeFQDN := "_acme-challenge." + parent
+ challengeFQDN := w.challengeFQDN(parent)
cleanup, err := w.dns.present(ctx, challengeFQDN, txt)
if err != nil {
return fmt.Errorf("publish challenge TXT: %v", err)
@@ -532,7 +607,19 @@ func newWildcardFromEnv(e *Edge, cache autocert.Cache) *wildcardManager {
log.Printf("edge: wildcard certs off (%s) — per-host issuance continues", reason)
return nil
}
- return newWildcardManager(static, appsDomain, e.currentHosts, cache, dns)
+ // Delegated parents (spec §4): the SOURCE is claim-derived and lands
+ // with the claim-derivation pass (Plan C); until then the zone is
+ // configured but the set is empty — inert by construction.
+ rawZone := os.Getenv("CARLOS_ACME_DELEGATION_ZONE")
+ zone := strings.ToLower(strings.TrimSpace(rawZone))
+ if zone != "" && !provision.ValidHostShape(zone) {
+ // Degrade, don't die: the edge must still boot on a bad zone value.
+ // Delegation goes off; per-host and direct (own-zone) DNS-01 keep
+ // covering everyone else.
+ log.Printf("edge: CARLOS_ACME_DELEGATION_ZONE %q invalid — delegation off, per-host and direct DNS-01 continue", rawZone)
+ zone = ""
+ }
+ return newWildcardManager(static, appsDomain, e.currentHosts, nil, zone, cache, dns)
}
// wrapGetCertificate serves the wildcard for covered names and hands
diff --git a/internal/edge/wildcard_test.go b/internal/edge/wildcard_test.go
index 4925d6918..8ad08d544 100644
--- a/internal/edge/wildcard_test.go
+++ b/internal/edge/wildcard_test.go
@@ -73,7 +73,7 @@ func TestParentsFor(t *testing.T) {
func TestCertForMatchesExactlyOneLabel(t *testing.T) {
w := newWildcardManager([]string{"go.tito.io"}, "oncarlos.com",
func() []string { return []string{"kass.abs.oncarlos.com"} },
- autocert.DirCache(t.TempDir()), nil)
+ nil, "", autocert.DirCache(t.TempDir()), nil)
w.refresh()
cert, _ := selfSignedWildcard(t, "abs.oncarlos.com", time.Now().Add(60*24*time.Hour))
w.mu.Lock()
@@ -124,7 +124,7 @@ func TestWildcardPEMRoundTripAndRenewDecision(t *testing.T) {
t.Fatal("parseWildcardPEM(junk): want error")
}
- w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, autocert.DirCache(t.TempDir()), nil)
+ w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, nil, "", autocert.DirCache(t.TempDir()), nil)
if !w.needsObtain("abs.oncarlos.com", time.Now()) {
t.Fatal("no cert loaded: needsObtain should be true")
}
@@ -152,7 +152,7 @@ func TestLoadCachedAndRefreshWake(t *testing.T) {
}
w := newWildcardManager(nil, "oncarlos.com",
- func() []string { return []string{"kass.abs.oncarlos.com"} }, cache, nil)
+ func() []string { return []string{"kass.abs.oncarlos.com"} }, nil, "", cache, nil)
if !w.loadCached(context.Background(), "abs.oncarlos.com") {
t.Fatal("loadCached: want true for cached parent")
}
@@ -169,7 +169,7 @@ func TestLoadCachedAndRefreshWake(t *testing.T) {
default:
}
w2 := newWildcardManager(nil, "oncarlos.com",
- func() []string { return []string{"kass.abs.oncarlos.com"} }, autocert.DirCache(t.TempDir()), nil)
+ func() []string { return []string{"kass.abs.oncarlos.com"} }, nil, "", autocert.DirCache(t.TempDir()), nil)
w2.refresh()
select {
case <-w2.wake:
@@ -190,7 +190,7 @@ func TestLoadCachedRejectsWrongCoverage(t *testing.T) {
if err := cache.Put(context.Background(), wildcardPEMKey("abs.oncarlos.com"), rawWrongParent); err != nil {
t.Fatal(err)
}
- w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, cache, nil)
+ w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, nil, "", cache, nil)
if w.loadCached(context.Background(), "abs.oncarlos.com") {
t.Fatal("loadCached: want false for cert covering the wrong parent")
}
@@ -215,7 +215,7 @@ func TestLoadCachedRejectsWrongCoverage(t *testing.T) {
if err := cache2.Put(context.Background(), wildcardPEMKey("abs.oncarlos.com"), rawMismatch); err != nil {
t.Fatal(err)
}
- w2 := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, cache2, nil)
+ w2 := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, nil, "", cache2, nil)
if w2.loadCached(context.Background(), "abs.oncarlos.com") {
t.Fatal("loadCached: want false for key/leaf mismatch")
}
@@ -248,12 +248,12 @@ func (f *fakeDNS) present(_ context.Context, fqdn, value string) (func(context.C
func TestWildcardAccountKeyStableAcrossManagers(t *testing.T) {
dir := t.TempDir()
cache := autocert.DirCache(dir)
- w1 := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, cache, &fakeDNS{})
+ w1 := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, nil, "", cache, &fakeDNS{})
k1, err := w1.accountKey(context.Background())
if err != nil {
t.Fatal(err)
}
- w2 := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, cache, &fakeDNS{})
+ w2 := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, nil, "", cache, &fakeDNS{})
k2, err := w2.accountKey(context.Background())
if err != nil {
t.Fatal(err)
@@ -274,7 +274,7 @@ func (f faultyCache) Get(context.Context, string) ([]byte, error) {
func TestAccountKeyBackendFaultDoesNotMint(t *testing.T) {
w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil },
- faultyCache{autocert.DirCache(t.TempDir())}, &fakeDNS{})
+ nil, "", faultyCache{autocert.DirCache(t.TempDir())}, &fakeDNS{})
if _, err := w.accountKey(context.Background()); err == nil {
t.Fatal("backend fault: want error, got a (freshly minted) key")
}
@@ -286,7 +286,7 @@ func TestAccountKeyBackendFaultDoesNotMint(t *testing.T) {
// (delete) clears the entry entirely.
func TestPerParentBackoff(t *testing.T) {
w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil },
- autocert.DirCache(t.TempDir()), &fakeDNS{})
+ nil, "", autocert.DirCache(t.TempDir()), &fakeDNS{})
if w.backingOff("abs.oncarlos.com", time.Now()) {
t.Fatal("no nextAttempt recorded yet: backingOff should be false")
@@ -321,7 +321,7 @@ func TestPerParentBackoff(t *testing.T) {
func TestWrappedGetCertificateFallsThrough(t *testing.T) {
w := newWildcardManager(nil, "oncarlos.com",
func() []string { return []string{"kass.abs.oncarlos.com"} },
- autocert.DirCache(t.TempDir()), &fakeDNS{})
+ nil, "", autocert.DirCache(t.TempDir()), &fakeDNS{})
w.refresh()
cert, _ := selfSignedWildcard(t, "abs.oncarlos.com", time.Now().Add(60*24*time.Hour))
w.mu.Lock()
@@ -418,3 +418,126 @@ func TestCurrentHostsPlatformOnly(t *testing.T) {
t.Fatalf("currentHosts = %v, want %v", got, want)
}
}
+
+func TestDelegatedParentsJoinTheSet(t *testing.T) {
+ w := newWildcardManager(nil, "oncarlos.com", func() []string {
+ return []string{"app.bab.oncarlos.com"}
+ }, func() []string {
+ return []string{"woodstar.app"}
+ }, "acme.oncarlos.com", autocert.DirCache(t.TempDir()), &fakeDNS{})
+ w.refresh()
+ w.mu.Lock()
+ parents := append([]string(nil), w.parents...)
+ w.mu.Unlock()
+ want := []string{"bab.oncarlos.com", "woodstar.app"}
+ if !reflect.DeepEqual(parents, want) {
+ t.Fatalf("parents = %v, want %v", parents, want)
+ }
+}
+
+func TestChallengeFQDNDelegation(t *testing.T) {
+ w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil },
+ func() []string { return []string{"woodstar.app"} },
+ "acme.oncarlos.com", autocert.DirCache(t.TempDir()), &fakeDNS{})
+ w.refresh()
+ // A delegated parent's TXT publishes into OUR zone, at the target
+ // the customer's CNAME points to.
+ if got, want := w.challengeFQDN("woodstar.app"), "_acme-challenge.woodstar.app.acme.oncarlos.com"; got != want {
+ t.Errorf("delegated: %q, want %q", got, want)
+ }
+ // A platform-zone parent publishes directly, exactly as before.
+ if got, want := w.challengeFQDN("bab.oncarlos.com"), "_acme-challenge.bab.oncarlos.com"; got != want {
+ t.Errorf("own-zone: %q, want %q", got, want)
+ }
+}
+
+func TestChallengeFQDNWithoutZoneNeverDelegates(t *testing.T) {
+ // No delegation zone configured: even a listed delegated parent
+ // publishes directly (misconfiguration degrades to the historical
+ // behavior, which per-host issuance then covers — never a dead edge).
+ w := newWildcardManager(nil, "", func() []string { return nil },
+ func() []string { return []string{"woodstar.app"} },
+ "", autocert.DirCache(t.TempDir()), &fakeDNS{})
+ w.refresh()
+ if got, want := w.challengeFQDN("woodstar.app"), "_acme-challenge.woodstar.app"; got != want {
+ t.Errorf("%q, want %q", got, want)
+ }
+}
+
+func TestNilDelegatedSourceIsFine(t *testing.T) {
+ // Every existing construction site passes nil — the zero-source
+ // manager must behave exactly as before this feature existed.
+ w := newWildcardManager([]string{"go.tito.io"}, "", func() []string { return nil },
+ nil, "acme.oncarlos.com", autocert.DirCache(t.TempDir()), &fakeDNS{})
+ w.refresh()
+ w.mu.Lock()
+ parents := append([]string(nil), w.parents...)
+ w.mu.Unlock()
+ if !reflect.DeepEqual(parents, []string{"go.tito.io"}) {
+ t.Fatalf("parents = %v, want [go.tito.io]", parents)
+ }
+ if got, want := w.challengeFQDN("go.tito.io"), "_acme-challenge.go.tito.io"; got != want {
+ t.Errorf("%q, want %q", got, want)
+ }
+}
+
+// TestPlatformParentsWinOverDelegated exercises the precedence rule refresh()
+// enforces: a delegated entry that collides with a static domain or falls
+// under the apps domain is dropped before it can hijack a PLATFORM parent's
+// challenge — that parent's TXT must still publish directly, never into the
+// delegation zone where no customer CNAME exists.
+func TestPlatformParentsWinOverDelegated(t *testing.T) {
+ w := newWildcardManager([]string{"go.tito.io"}, "oncarlos.com",
+ func() []string { return []string{"app.bab.oncarlos.com"} },
+ func() []string { return []string{"go.tito.io", "bab.oncarlos.com", "woodstar.app"} },
+ "acme.oncarlos.com", autocert.DirCache(t.TempDir()), &fakeDNS{})
+ w.refresh()
+
+ // Colliding entries lose: both the static domain and the dynamic
+ // per-account parent publish their challenge directly, exactly as if
+ // they had never appeared in the delegated source.
+ if got, want := w.challengeFQDN("go.tito.io"), "_acme-challenge.go.tito.io"; got != want {
+ t.Errorf("static collision: challengeFQDN = %q, want %q", got, want)
+ }
+ if got, want := w.challengeFQDN("bab.oncarlos.com"), "_acme-challenge.bab.oncarlos.com"; got != want {
+ t.Errorf("apps-domain collision: challengeFQDN = %q, want %q", got, want)
+ }
+ // A genuinely foreign delegated parent still gets the delegated form.
+ if got, want := w.challengeFQDN("woodstar.app"), "_acme-challenge.woodstar.app.acme.oncarlos.com"; got != want {
+ t.Errorf("non-colliding delegated: challengeFQDN = %q, want %q", got, want)
+ }
+}
+
+func TestNewWildcardFromEnvDelegationZone(t *testing.T) {
+ t.Setenv("CARLOS_WILDCARD_DOMAINS", "go.tito.io")
+ t.Setenv("CARLOS_DNS_PROVIDER", "dnsimple")
+ t.Setenv("CARLOS_DNSIMPLE_TOKEN", "test-token")
+ t.Setenv("CARLOS_ACME_DELEGATION_ZONE", " Acme.OnCarlos.Com ")
+ e := &Edge{}
+ w := newWildcardFromEnv(e, autocert.DirCache(t.TempDir()))
+ if w == nil {
+ t.Fatal("manager should be on")
+ }
+ if w.delegationZone != "acme.oncarlos.com" {
+ t.Errorf("delegationZone = %q, want lowercased+trimmed acme.oncarlos.com", w.delegationZone)
+ }
+}
+
+// TestNewWildcardFromEnvInvalidDelegationZoneDegrades covers "validate at
+// boot, degrade don't die": a malshaped CARLOS_ACME_DELEGATION_ZONE must
+// never stop the edge from booting wildcards — it just turns delegation off
+// while per-host and direct (own-zone) DNS-01 keep covering everyone else.
+func TestNewWildcardFromEnvInvalidDelegationZoneDegrades(t *testing.T) {
+ t.Setenv("CARLOS_WILDCARD_DOMAINS", "go.tito.io")
+ t.Setenv("CARLOS_DNS_PROVIDER", "dnsimple")
+ t.Setenv("CARLOS_DNSIMPLE_TOKEN", "test-token")
+ t.Setenv("CARLOS_ACME_DELEGATION_ZONE", "*.bad zone")
+ e := &Edge{}
+ w := newWildcardFromEnv(e, autocert.DirCache(t.TempDir()))
+ if w == nil {
+ t.Fatal("invalid delegation zone must not stop the edge from booting")
+ }
+ if w.delegationZone != "" {
+ t.Errorf("delegationZone = %q, want empty (delegation off)", w.delegationZone)
+ }
+}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index a048eb9a5..b08dc9a83 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -111,6 +111,9 @@
// wildcard certs: unset/dnsimple needs the token (SSM → unit env),
// route53 rides the box IAM role; nothing set = wildcards off,
// per-host HTTP-01 only
+// - CARLOS_ACME_DELEGATION_ZONE — our own zone that delegated wildcard
+// parents' challenge TXTs publish into (spec §4); unset or an invalid
+// shape = delegation off, per-host and direct DNS-01 continue
// - PLATFORM_EDGE_IPS — comma-separated edge IPs custom-domain DNS checks
// compare against. Default: this box's public IPv4 from IMDS
// (best-effort; if neither is knowable, DNS stamping stands down
diff --git a/logs.go b/logs.go
index 558f7fdb0..dd267d1db 100644
--- a/logs.go
+++ b/logs.go
@@ -67,7 +67,7 @@ func runLogs(args []string) {
n := fs.Int("n", 0, "at most this many lines (default 1000, max 5000)")
follow := fs.Bool("f", false, "follow: poll for new lines until interrupted")
consoleFlag := fs.String("console", "", "console to read through (default: the only logged-in one)")
- acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
fs.Usage = func() { fmt.Fprint(os.Stderr, logsUsage) }
fs.Parse(args)
@@ -92,7 +92,7 @@ func runLogs(args []string) {
}
sess := requireAPISession("logs", *consoleFlag)
- acct := apiAccount(acctRaw, acctIDRaw)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
ctx := context.Background()
res, err := sess.logsGet(ctx, acct, *app, q)
if err != nil {
diff --git a/main.go b/main.go
index 4b015b5c7..75fea7ef0 100644
--- a/main.go
+++ b/main.go
@@ -108,12 +108,18 @@ func main() {
host.RunAdopt(args)
case "vet":
vet.Run(args)
+ case "update":
+ runUpdate(args)
case "canary":
log.Fatalf("not implemented: %s", cmd)
default:
usage(os.Stderr)
os.Exit(2)
}
+ // After a verb that ran to success: the once-a-day newer-release hint
+ // (#161). Verbs that fail never reach here — log.Fatal exits — so a
+ // broken command is never followed by an upsell.
+ maybeUpdateHint(cmd)
}
// usage prints the top-level command list, grouped by the role you are in
@@ -155,6 +161,9 @@ Your apps — ship and run software on a CARLOS deployment
events (wakes, restarts, failures); -f follows live
ledger open ledgers: append | head | list | publish | blob | verify
vet check a release against the platform contract
+ update update this carlos binary to the latest release (or name
+ the brew/apt command when a package manager owns it);
+ -y skips the prompt, terminal still required
Your deployment — stand up and administer boxes, routes and accounts
bootstrap prepare a host: units, dirs, user
diff --git a/restart.go b/restart.go
index 25c9e5a31..c9d628b9c 100644
--- a/restart.go
+++ b/restart.go
@@ -38,8 +38,8 @@ func runRestart(args []string) {
}
sess := requireRestartAPISession(*consoleFlag)
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
res, err := sess.restart(context.Background(), acct, name)
if err != nil {
log.Fatalf("restart: %v", err)
diff --git a/retention.go b/retention.go
index f031cded9..f4de48820 100644
--- a/retention.go
+++ b/retention.go
@@ -51,8 +51,8 @@ func runReleasesRetention(args []string) {
}
sess := requireRetentionAPISession(*consoleFlag)
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
ctx := context.Background()
switch {
case *off:
diff --git a/store.go b/store.go
index 3192348bb..05075cefe 100644
--- a/store.go
+++ b/store.go
@@ -60,7 +60,8 @@ const storeUsage = `usage: carlos store <create|status|grant|rotate> [flags]
until -finish deletes it. A member verb: spends
nothing new, so there is no operator gate here.
- -account <sqid|name> account the app belongs to (default: ops) —
+ -account <sqid|name> account the app belongs to (default: your only
+ account, or the configured/chosen one) —
resolved by the console against your own
memberships; signed-in mode only.
-account-id <sqid> account sqid, matched as a sqid ONLY.
@@ -142,8 +143,8 @@ func runStoreCreate(args []string) {
}
sess := requireStoreAPISession(*consoleFlag)
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
res, err := sess.storeCreate(context.Background(), acct, name, strings.TrimSpace(*envPrefix))
if err != nil {
log.Fatalf("store create: %v", err)
@@ -166,8 +167,8 @@ func runStoreStatus(args []string) {
}
sess := requireStoreAPISession(*consoleFlag)
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
res, err := sess.storeGet(context.Background(), acct, name)
if err != nil {
log.Fatalf("store status: %v", err)
@@ -191,8 +192,8 @@ func runStoreGrant(args []string) {
}
sess := requireStoreAPISession(*consoleFlag)
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
res, err := sess.storeGrant(context.Background(), acct, name, strings.TrimSpace(*bucket))
if err != nil {
log.Fatalf("store grant: %v", err)
@@ -216,8 +217,8 @@ func runStoreRotate(args []string) {
}
sess := requireStoreAPISession(*consoleFlag)
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
res, err := sess.storeRotate(context.Background(), acct, name, *finish)
if err != nil {
log.Fatalf("store rotate: %v", err)
diff --git a/update.go b/update.go
new file mode 100644
index 000000000..357b8e16f
--- /dev/null
+++ b/update.go
@@ -0,0 +1,358 @@
+// Self-update (#161): the binary knows its own version, the apt static
+// site publishes the latest, and carlosframework/releases holds the
+// artifacts — so a stale workstation install can notice and fix itself.
+// Everything here is workstation-only by construction: boxes (bucket env,
+// root) are rolled by update-platform-binary.sh and adoption, and a
+// self-updating box binary would fight that machinery.
+package main
+
+import (
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/version"
+)
+
+// Vars, not consts: the tests point them at httptest servers and a test
+// keypair. Production never changes them.
+var (
+ updateLatestURL = "https://apt.carlosframework.com/latest.txt"
+ // Tag-pinned, never "latest/download": the floating alias can serve an
+ // older authentic release than the one latest.txt promised, and every
+ // artifact of a self-replace must come from the ONE tag being claimed
+ // (review 2026-08-12: an authentic-but-old sums+binary pair must not
+ // be acceptable, or a caching layer can pin workstations to a
+ // known-vulnerable release without a signature error).
+ updateDownloadBase = "https://github.com/carlosframework/releases/releases/download"
+
+ // cliUpdatePubKey verifies SHA256SUMS.txt.sig on self-replace: the
+ // public half of the dedicated CLI-update keypair (private half at SSM
+ // /carlos/cli-update-key — NOT the release key; different blast
+ // radius, different rotation story).
+ cliUpdatePubKey = "R+EO3qQ47VsuKl9Qq/pkDXBucPYvzp5lU2ut4zpnJPc="
+)
+
+// releaseTag matches the version stamp of a published build ("v0.7.0").
+// Anything else — "dev" above all — never checks and never updates: there
+// is no meaningful "newer than dev".
+var releaseTag = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`)
+
+func releaseBuild(build string) bool { return releaseTag.MatchString(build) }
+
+// newerVersion reports whether latest (a release tag) is strictly newer
+// than current (also a release tag). Unparseable input is never "newer" —
+// a garbled feed must not nag anyone.
+func newerVersion(latest, current string) bool {
+ l, lok := release.ParseSemVer(strings.TrimPrefix(latest, "v"))
+ c, cok := release.ParseSemVer(strings.TrimPrefix(current, "v"))
+ return lok && cok && l.Compare(c) > 0
+}
+
+// noHintVerbs are the box/daemon/plumbing verbs the update hint stays out
+// of — either they run where updates are the platform's job, or their
+// output is parsed (version: the tap formula's test asserts on it).
+var noHintVerbs = map[string]bool{
+ "edge": true, "agent": true, "adopt": true, "bootstrap": true,
+ "add": true, "remove": true, "route": true, "ops": true,
+ "status": true, "canary": true, "vet": true, "release-keygen": true,
+ "version": true, "help": true, "-h": true, "--help": true,
+ "update": true,
+}
+
+type updateCache struct {
+ CheckedAt time.Time `json:"checked_at"`
+ Latest string `json:"latest"`
+}
+
+func updateCachePath() (string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(home, ".carlos", "update-check.json"), nil
+}
+
+// fetchLatest asks the apt site which release is current. One small GET
+// with its own short deadline — the caller is done with its real work, and
+// a slow feed must cost nobody anything.
+func fetchLatest(timeout time.Duration) (string, error) {
+ client := &http.Client{Timeout: timeout}
+ res, err := client.Get(updateLatestURL)
+ if err != nil {
+ return "", err
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("latest.txt: status %d", res.StatusCode)
+ }
+ raw, err := io.ReadAll(io.LimitReader(res.Body, 128))
+ if err != nil {
+ return "", err
+ }
+ tag := strings.TrimSpace(string(raw))
+ if !releaseBuild(tag) {
+ return "", fmt.Errorf("latest.txt: %q is not a release tag", tag)
+ }
+ return tag, nil
+}
+
+// maybeUpdateHint prints the one-line "a newer carlos exists" nudge after
+// a successful member verb — at most once per 24h (the network check and
+// the hint share the cache window), and only where a hint can possibly be
+// wanted: a human at a terminal, running a release build, off-box, not
+// opted out. Every failure is silence; this must never make a working
+// command look broken.
+func maybeUpdateHint(cmd string) {
+ if noHintVerbs[cmd] || !interactiveTerminal() || !releaseBuild(version.Build) {
+ return
+ }
+ if os.Getenv("CARLOS_DEPLOYMENT_BUCKET") != "" || os.Getenv("CARLOS_DEPLOYMENT_DIR") != "" {
+ return
+ }
+ if os.Geteuid() == 0 || os.Getenv("CARLOS_NO_UPDATE_CHECK") != "" {
+ return
+ }
+ if off, err := configUpdateCheckOff(); err != nil || off {
+ return
+ }
+ path, err := updateCachePath()
+ if err != nil {
+ return
+ }
+ var cache updateCache
+ if raw, err := os.ReadFile(path); err == nil {
+ // A future checked_at (clock skew, corrupt write) must re-check,
+ // not silence the hint until the clock catches up.
+ if json.Unmarshal(raw, &cache) == nil {
+ if since := time.Since(cache.CheckedAt); since >= 0 && since < 24*time.Hour {
+ return
+ }
+ }
+ }
+ // The attempt is stamped BEFORE the fetch: a blackholed egress must
+ // cost one command its 1.5s, not every command forever. Latest is
+ // informational (for a human reading the file) — the hint only ever
+ // speaks at check time, so a stale value never re-nags.
+ writeCache := func(latest string) {
+ if raw, err := json.Marshal(updateCache{CheckedAt: time.Now().UTC(), Latest: latest}); err == nil {
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err == nil {
+ os.WriteFile(path, raw, 0o600)
+ }
+ }
+ }
+ writeCache("")
+ latest, err := fetchLatest(1500 * time.Millisecond)
+ if err != nil {
+ return
+ }
+ writeCache(latest)
+ if newerVersion(latest, version.Build) {
+ mutedf(stdout, "carlos %s is available (you have %s) — run: carlos update", latest, version.Build)
+ }
+}
+
+// configUpdateCheckOff walks the config files like the other defaults do:
+// nearest .carlos/config with an update_check key, else the global. Only
+// the literal "off" disables.
+func configUpdateCheckOff() (bool, error) {
+ if dir, werr := os.Getwd(); werr == nil {
+ for {
+ cfg, err := loadConfig(filepath.Join(dir, ".carlos", "config"))
+ if err != nil {
+ return false, err
+ }
+ if cfg.UpdateCheck != "" {
+ return cfg.UpdateCheck == "off", nil
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ break
+ }
+ dir = parent
+ }
+ }
+ path, perr := globalConfigPath()
+ if perr != nil {
+ return false, nil
+ }
+ cfg, err := loadConfig(path)
+ if err != nil {
+ return false, err
+ }
+ return cfg.UpdateCheck == "off", nil
+}
+
+// installMethod classifies how the running binary got where it is, so
+// `carlos update` hands package-managed installs to their manager instead
+// of fighting it.
+func installMethod(exe string) string {
+ // Both brews keep formula binaries under a Cellar (/opt/homebrew/Cellar,
+ // ~/.linuxbrew/Cellar) — matching looser substrings ("/homebrew/") would
+ // also match unrelated paths that merely contain the word.
+ if strings.Contains(exe, "/Cellar/") {
+ return "brew"
+ }
+ if dpkg, err := exec.LookPath("dpkg"); err == nil {
+ out, err := exec.Command(dpkg, "-S", exe).Output()
+ if err == nil && strings.HasPrefix(string(out), "carlos:") {
+ return "apt"
+ }
+ }
+ return "binary"
+}
+
+func runUpdate(args []string) {
+ fs := flag.NewFlagSet("update", flag.ExitOnError)
+ yes := fs.Bool("y", false, "replace without asking (plain-binary installs only)")
+ fs.Parse(args)
+ if os.Getenv("CARLOS_DEPLOYMENT_BUCKET") != "" || os.Getenv("CARLOS_DEPLOYMENT_DIR") != "" {
+ log.Fatal("carlos update is for workstation installs — a box's binary is rolled by update-platform-binary.sh and adoption")
+ }
+ if os.Geteuid() == 0 {
+ log.Fatal("carlos update refuses to run as root — box binaries are rolled by the platform, not self-updated")
+ }
+ if !releaseBuild(version.Build) {
+ log.Fatalf("this is a %q build, not a published release — update by rebuilding from source", version.Build)
+ }
+ latest, err := fetchLatest(10 * time.Second)
+ if err != nil {
+ log.Fatalf("could not read the release feed: %v", err)
+ }
+ if !newerVersion(latest, version.Build) {
+ fmt.Fprintf(stdout, "carlos %s is the latest release — nothing to do\n", version.Build)
+ return
+ }
+ exe, err := os.Executable()
+ if err == nil {
+ exe, err = filepath.EvalSymlinks(exe)
+ }
+ if err != nil {
+ log.Fatalf("cannot locate the running binary: %v", err)
+ }
+ switch installMethod(exe) {
+ case "brew":
+ fmt.Fprintf(stdout, "carlos %s is available (you have %s)\n", latest, version.Build)
+ fmt.Fprintln(stdout, "this install is Homebrew's — update with: brew upgrade carlosframework/tap/carlos")
+ return
+ case "apt":
+ fmt.Fprintf(stdout, "carlos %s is available (you have %s)\n", latest, version.Build)
+ fmt.Fprintln(stdout, "this install is apt's — update with: sudo apt update && sudo apt install carlos")
+ return
+ }
+ fmt.Fprintf(stdout, "carlos %s is available (you have %s); this binary: %s\n", latest, version.Build, exe)
+ // Non-TTY never self-replaces, -y or not (spec): provisioning belongs
+ // to package managers and image builds, not to a CLI quietly swapping
+ // itself in a pipeline. -y only skips the question for a human.
+ if !interactiveTerminal() {
+ log.Fatal("carlos update replaces a binary only at a terminal — scripts should install the release directly")
+ }
+ if !*yes && !promptYes(stdout, fmt.Sprintf("download %s and replace it? [y/N] ", latest)) {
+ return
+ }
+ if err := selfReplace(exe, latest); err != nil {
+ if errors.Is(err, os.ErrPermission) {
+ log.Fatalf("no permission to replace %s — re-run as: sudo carlos update", exe)
+ }
+ log.Fatalf("update: %v", err)
+ }
+ fmt.Fprintf(stdout, "%s carlos %s -> %s (%s)\n", colorFor(stdout).ok("updated:"), version.Build, latest, exe)
+}
+
+// selfReplace downloads the matching artifact for this OS/arch — from the
+// TAG-pINNED release path, so the bytes installed are the version claimed —
+// proves it against the SIGNED checksum list, runs the replacement's own
+// `version` as a final lie detector, and renames it over exe: temp file
+// beside the target then rename, the same crash-safety rule as the config
+// writes, so no failure can leave half a binary answering `carlos`.
+func selfReplace(exe, latest string) error {
+ asset := fmt.Sprintf("carlos-%s-%s", runtime.GOOS, runtime.GOARCH)
+ sums, err := fetchUpdateFile(latest, "SHA256SUMS.txt", 1<<20)
+ if err != nil {
+ return err
+ }
+ sig, err := fetchUpdateFile(latest, "SHA256SUMS.txt.sig", 4096)
+ if err != nil {
+ return fmt.Errorf("%v (a release without a signed checksum list predates carlos update)", err)
+ }
+ pub, err := base64.StdEncoding.DecodeString(cliUpdatePubKey)
+ if err != nil || len(pub) != ed25519.PublicKeySize {
+ return errors.New("embedded update key is invalid — this binary cannot self-update")
+ }
+ rawSig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(sig)))
+ if err != nil || !ed25519.Verify(ed25519.PublicKey(pub), sums, rawSig) {
+ return errors.New("SHA256SUMS.txt signature does not verify — refusing the download")
+ }
+ wantSum := ""
+ for _, line := range strings.Split(string(sums), "\n") {
+ fields := strings.Fields(line)
+ if len(fields) == 2 && fields[1] == asset {
+ wantSum = fields[0]
+ }
+ }
+ if wantSum == "" {
+ return fmt.Errorf("no %s in the release's checksum list", asset)
+ }
+ binData, err := fetchUpdateFile(latest, asset, 256<<20)
+ if err != nil {
+ return err
+ }
+ if got := hex.EncodeToString(func() []byte { s := sha256.Sum256(binData); return s[:] }()); got != wantSum {
+ return fmt.Errorf("%s does not match its signed checksum — refusing to install it", asset)
+ }
+ tmp, err := os.CreateTemp(filepath.Dir(exe), ".carlos-update-*")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(tmp.Name())
+ if err := tmp.Chmod(0o755); err != nil {
+ tmp.Close()
+ return err
+ }
+ if _, err := tmp.Write(binData); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ // The lie detector: before anything is replaced, the candidate itself
+ // must report the version this whole exchange promised. Catches a
+ // re-pointed tag, a mislabeled artifact, and "updated!" prints that
+ // would otherwise claim a version that never arrived.
+ if out, err := exec.Command(tmp.Name(), "version").Output(); err != nil ||
+ !strings.Contains(string(out), "carlos "+latest) {
+ return fmt.Errorf("the downloaded binary reports %q, not %s — refusing to install it",
+ strings.TrimSpace(string(out)), latest)
+ }
+ return os.Rename(tmp.Name(), exe)
+}
+
+func fetchUpdateFile(tag, name string, limit int64) ([]byte, error) {
+ client := &http.Client{Timeout: 5 * time.Minute}
+ res, err := client.Get(updateDownloadBase + "/" + tag + "/" + name)
+ if err != nil {
+ return nil, err
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("%s: status %d", name, res.StatusCode)
+ }
+ return io.ReadAll(io.LimitReader(res.Body, limit))
+}
diff --git a/update_test.go b/update_test.go
new file mode 100644
index 000000000..0a42356aa
--- /dev/null
+++ b/update_test.go
@@ -0,0 +1,261 @@
+package main
+
+import (
+ "crypto/ed25519"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/version"
+)
+
+func TestReleaseBuild(t *testing.T) {
+ for tag, want := range map[string]bool{
+ "v0.7.0": true, "v10.2.33": true,
+ "dev": false, "0.7.0": false, "v0.7": false, "v0.7.0-1": false, "": false,
+ } {
+ if got := releaseBuild(tag); got != want {
+ t.Errorf("releaseBuild(%q) = %v, want %v", tag, got, want)
+ }
+ }
+}
+
+func TestNewerVersion(t *testing.T) {
+ for _, tc := range []struct {
+ latest, current string
+ want bool
+ }{
+ {"v0.8.0", "v0.7.0", true},
+ {"v1.0.0", "v0.9.9", true},
+ {"v0.7.0", "v0.7.0", false},
+ {"v0.6.0", "v0.7.0", false},
+ {"garbage", "v0.7.0", false}, // a garbled feed never nags
+ {"v0.8.0", "dev", false},
+ } {
+ if got := newerVersion(tc.latest, tc.current); got != tc.want {
+ t.Errorf("newerVersion(%q, %q) = %v, want %v", tc.latest, tc.current, got, tc.want)
+ }
+ }
+}
+
+// useVersion pins the build stamp for one test — it is a package var
+// precisely so release-only behavior is testable from a dev build.
+func useVersion(t *testing.T, v string) {
+ t.Helper()
+ old := version.Build
+ version.Build = v
+ t.Cleanup(func() { version.Build = old })
+}
+
+// latestServer serves latest.txt with the given tag and counts fetches.
+func latestServer(t *testing.T, tag string) *int {
+ t.Helper()
+ hits := 0
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits++
+ fmt.Fprintln(w, tag)
+ }))
+ t.Cleanup(ts.Close)
+ oldURL := updateLatestURL
+ updateLatestURL = ts.URL
+ t.Cleanup(func() { updateLatestURL = oldURL })
+ return &hits
+}
+
+func TestUpdateHintShowsOncePerDay(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ out := captureStdout(t)
+ forceTerminal(t, true)
+ useVersion(t, "v0.7.0")
+ hits := latestServer(t, "v0.8.0")
+
+ maybeUpdateHint("ship")
+ if !strings.Contains(out.String(), "carlos v0.8.0 is available (you have v0.7.0) — run: carlos update") {
+ t.Fatalf("no hint:\n%s", out.String())
+ }
+ // Within the cache window: no second fetch, no second hint.
+ out.Reset()
+ maybeUpdateHint("ship")
+ if *hits != 1 || out.String() != "" {
+ t.Fatalf("second run: hits=%d out=%q, want cached silence", *hits, out.String())
+ }
+ // An expired cache checks again.
+ path, _ := updateCachePath()
+ stale := fmt.Sprintf(`{"checked_at": %q, "latest": "v0.8.0"}`, time.Now().Add(-25*time.Hour).Format(time.RFC3339))
+ if err := os.WriteFile(path, []byte(stale), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ maybeUpdateHint("ship")
+ if *hits != 2 {
+ t.Fatalf("hits after stale cache = %d, want 2", *hits)
+ }
+}
+
+func TestUpdateHintGates(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ out := captureStdout(t)
+ forceTerminal(t, true)
+ useVersion(t, "v0.7.0")
+ hits := latestServer(t, "v0.8.0")
+
+ // Each gate must keep the network untouched, not merely the output.
+ t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "some-box-bucket")
+ maybeUpdateHint("ship")
+ t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "")
+
+ t.Setenv("CARLOS_NO_UPDATE_CHECK", "1")
+ maybeUpdateHint("ship")
+ t.Setenv("CARLOS_NO_UPDATE_CHECK", "")
+
+ writeConfig(t, ".", `{"update_check": "off"}`)
+ maybeUpdateHint("ship")
+ os.RemoveAll(".carlos")
+
+ maybeUpdateHint("edge") // box verb
+ maybeUpdateHint("version") // parsed output
+ maybeUpdateHint("update") // the verb itself
+
+ forceTerminal(t, false)
+ maybeUpdateHint("ship")
+ forceTerminal(t, true)
+
+ useVersion(t, "dev")
+ maybeUpdateHint("ship")
+
+ if *hits != 0 || out.String() != "" {
+ t.Fatalf("gated runs: hits=%d out=%q, want zero of both", *hits, out.String())
+ }
+}
+
+func TestUpdateHintUpToDateIsSilent(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ out := captureStdout(t)
+ forceTerminal(t, true)
+ useVersion(t, "v0.8.0")
+ hits := latestServer(t, "v0.8.0")
+
+ maybeUpdateHint("ship")
+ if *hits != 1 || out.String() != "" {
+ t.Fatalf("up to date: hits=%d out=%q, want one silent check", *hits, out.String())
+ }
+ // And the silent check still stamped the cache.
+ path, _ := updateCachePath()
+ if _, err := os.Stat(path); err != nil {
+ t.Fatalf("cache not written on the up-to-date path: %v", err)
+ }
+}
+
+func TestInstallMethod(t *testing.T) {
+ for path, want := range map[string]string{
+ "/opt/homebrew/Cellar/carlos/0.7.0/bin/carlos": "brew",
+ "/home/linuxbrew/.linuxbrew/Cellar/carlos/x/carlos": "brew",
+ "/home/tom/bin/carlos": "binary",
+ } {
+ if got := installMethod(path); got != want {
+ t.Errorf("installMethod(%q) = %q, want %q", path, got, want)
+ }
+ }
+ // The dpkg branch depends on the host's dpkg database, so it is not
+ // asserted here — /usr/bin/carlos on a non-deb box is still "binary".
+}
+
+// releaseServer serves a fake carlosframework/releases download endpoint:
+// a binary, its SHA256SUMS.txt, and a signature by the given key.
+func releaseServer(t *testing.T, binary []byte, sums string, priv ed25519.PrivateKey, breakSig bool) {
+ t.Helper()
+ sig := ed25519.Sign(priv, []byte(sums))
+ if breakSig {
+ sig[0] ^= 0xff
+ }
+ asset := "carlos-" + goosArch()
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Downloads are tag-pinned: /vX.Y.Z/<asset>, never a floating
+ // "latest" alias — anything else here is a client bug.
+ if !strings.HasPrefix(r.URL.Path, "/v0.8.0/") {
+ t.Errorf("un-pinned download path %q", r.URL.Path)
+ http.NotFound(w, r)
+ return
+ }
+ switch strings.TrimPrefix(r.URL.Path, "/v0.8.0/") {
+ case "SHA256SUMS.txt":
+ w.Write([]byte(sums))
+ case "SHA256SUMS.txt.sig":
+ w.Write([]byte(base64.StdEncoding.EncodeToString(sig)))
+ case asset:
+ w.Write(binary)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ t.Cleanup(ts.Close)
+ oldBase := updateDownloadBase
+ updateDownloadBase = ts.URL
+ t.Cleanup(func() { updateDownloadBase = oldBase })
+}
+
+func goosArch() string { return runtime.GOOS + "-" + runtime.GOARCH }
+
+func TestSelfReplace(t *testing.T) {
+ useTempCredentials(t)
+ pub, priv, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ oldKey := cliUpdatePubKey
+ cliUpdatePubKey = base64.StdEncoding.EncodeToString(pub)
+ t.Cleanup(func() { cliUpdatePubKey = oldKey })
+
+ newBinary := []byte("#!/bin/sh\necho carlos v0.8.0\n")
+ sum := sha256.Sum256(newBinary)
+ asset := fmt.Sprintf("carlos-%s", goosArch())
+ sums := fmt.Sprintf("%s %s\n", hex.EncodeToString(sum[:]), asset)
+
+ exe := filepath.Join(t.TempDir(), "carlos")
+ if err := os.WriteFile(exe, []byte("old"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ // A broken signature refuses before anything is downloaded or touched.
+ releaseServer(t, newBinary, sums, priv, true)
+ if err := selfReplace(exe, "v0.8.0"); err == nil ||
+ !strings.Contains(err.Error(), "signature does not verify") {
+ t.Fatalf("broken sig = %v, want the signature refusal", err)
+ }
+ if got, _ := os.ReadFile(exe); string(got) != "old" {
+ t.Fatal("the binary was touched despite a bad signature")
+ }
+
+ // A checksum mismatch refuses too.
+ releaseServer(t, []byte("not what was signed"), sums, priv, false)
+ if err := selfReplace(exe, "v0.8.0"); err == nil ||
+ !strings.Contains(err.Error(), "does not match its signed checksum") {
+ t.Fatalf("bad sum = %v, want the checksum refusal", err)
+ }
+
+ // The happy path replaces atomically and keeps 0755.
+ releaseServer(t, newBinary, sums, priv, false)
+ if err := selfReplace(exe, "v0.8.0"); err != nil {
+ t.Fatal(err)
+ }
+ got, err := os.ReadFile(exe)
+ if err != nil || string(got) != string(newBinary) {
+ t.Fatalf("replaced binary = %q, %v", got, err)
+ }
+ fi, err := os.Stat(exe)
+ if err != nil || fi.Mode().Perm() != 0o755 {
+ t.Fatalf("replaced mode = %v, %v; want 0755", fi.Mode(), err)
+ }
+}