Merge remote-tracking branch 'origin/main' into worktree-domain-attach
34 files changed,
+4546
−149
account_choose.go+123 −0account_choose_test.go+89 −0accounts.go+71 −3accounts_list_test.go+81 −0accounts_resolve_test.go+12 −0apimode.go+310 −6apimode_test.go+536 −0appconfig_cli.go+16 −10apps.go+8 −7auth.go+57 −6auth_test.go+58 −7cli.go+29 −19config.go+115 −23config_test.go+96 −0deploy.go+2 −2docs/superpowers/plans/2026-08-12-account-default-resolution.md+122 −0docs/superpowers/plans/2026-08-12-delegated-wildcard-certs.md+427 −0docs/superpowers/plans/2026-08-12-woodstar-w0-relay-fanout.md+751 −0docs/superpowers/specs/2026-08-12-account-default-resolution-design.md+182 −0docs/superpowers/specs/2026-08-12-proactive-cli-design.md+128 −0docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md+424 −0fleets.go+14 −13instances_cli.go+7 −7internal/edge/edge.go+2 −2internal/edge/wildcard.go+111 −24internal/edge/wildcard_test.go+134 −11internal/host/agent.go+3 −0logs.go+2 −2main.go+9 −0restart.go+1 −1retention.go+1 −1store.go+6 −5update.go+358 −0update_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 = ¬LoggedInError{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, ¬LoggedInError{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, ¬LoggedInError{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) |
| + } |
| +} |