carlos / platform Public

console API + CLI: bring an app live on its platform domain with a login alone (#66)

The one bucket-only prerequisite between "promoted over the API" and
"reachable" was the instances manifest: carlos instances enable was
operator/store-mode only, so a member's path to an app's first route ran
through a box shell (today: Systems Manager). Closes the gap in the
smallest shape consistent with the spec's invariant that the agent's
reconcile pass is the only route writer:

- PUT /api/cli/apps/{account}/{app}/instances-manifest — member-facing
  enable. Domain pinned server-side to <sqid>.<PLATFORM_APPS_DOMAIN>;
  custom domains stay operator acts, a standing foreign-domain manifest
  is a 409 never an overwrite; user tokens only; pool/regions ride
  through updates untouched. Console reads PLATFORM_APPS_DOMAIN, the
  same variable the edge and agent already use.
- carlos instances enable gains API mode (credential-first, -domain/
  -pool/-regions refused rather than ignored in signed-in mode).
- carlos instances create/list/delete — the spec §9 follow-up slice:
  CLI fronts over the existing record verbs, signed-in only.
- Spec addendum §12a records the door and its security line.

Chain to live: enable → create <app>.<sqid>.<domain> → provision
reconcile mints the route at the apex host (domains pass skips its
mint idempotently via byHost); wildcard TLS covers that exact depth.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 0087f80d510076efc29801c10d2729500c09e166 parent 91d0311
11 files changed, +915 −35
  • apimode.go +133 −0
  • cli.go +20 −7
  • cmd/console/main.go +6 −2
  • docs/superpowers/specs/2026-08-04-instance-provisioning-design.md +37 −0
  • instances_cli.go +200 −25
  • instances_e2e_test.go +142 −0
  • internal/console/apiinstances.go +116 −0
  • internal/console/apiinstancesenable_test.go +241 −0
  • internal/console/authcli.go +4 −0
  • internal/console/server.go +9 −0
  • main.go +7 −1
diff --git a/apimode.go b/apimode.go
index 09378fb82..ec697f2f3 100644
--- a/apimode.go
+++ b/apimode.go
@@ -786,6 +786,139 @@ func (s *apiSession) configSync(ctx context.Context, a apiAcct, app string) erro
return nil
}
+// deleteJSON runs one DELETE and hands back the status and raw body without
+// interpreting a non-200 as an error itself — getJSON's plumbing with the
+// method swapped, for the instance-record retire door (issue #66), the one
+// DELETE this client speaks.
+func (s *apiSession) deleteJSON(ctx context.Context, path string) (int, []byte, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodDelete, s.c.base+path, nil)
+ if err != nil {
+ return 0, nil, err
+ }
+ if s.c.token != "" {
+ req.Header.Set("Authorization", "Bearer "+s.c.token)
+ }
+ res, err := s.c.httpc.Do(req)
+ if err != nil {
+ return 0, nil, err
+ }
+ defer res.Body.Close()
+ body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
+ if err != nil {
+ return res.StatusCode, nil, err
+ }
+ return res.StatusCode, body, nil
+}
+
+// apiInstanceManifest is the instances-manifest door's response — the
+// manifest as the console now holds it, echoed so the CLI prints the domain
+// the SERVER pinned (the account's platform subdomain), never one this side
+// guessed.
+type apiInstanceManifest struct {
+ App string `json:"app"`
+ Domain string `json:"domain"`
+ Pool string `json:"pool"`
+ Channel string `json:"channel"`
+ Regions []string `json:"regions"`
+ Seed []string `json:"seed"`
+}
+
+// instancesEnable asks the console to write (or update) the app's instances
+// manifest under the account's own platform subdomain — the member-facing
+// half of `carlos instances enable` (issue #66). channel and seed are
+// pointers for the same reason apiConfigApply's ExecDelivery is: absent
+// means "preserve what the manifest carries", and seed's explicit empty
+// slice means "clear" — three requests the wire must keep apart.
+func (s *apiSession) instancesEnable(ctx context.Context, a apiAcct, app string, channel *string, seed *[]string) (apiInstanceManifest, error) {
+ in := struct {
+ Channel *string `json:"channel,omitempty"`
+ Seed *[]string `json:"seed,omitempty"`
+ }{channel, seed}
+ status, body, err := s.putJSON(ctx, apiPath(a, app, "/instances-manifest"), in)
+ if err != nil {
+ return apiInstanceManifest{}, err
+ }
+ if status != http.StatusOK {
+ return apiInstanceManifest{}, apiErrorFromBody(status, body)
+ }
+ var out apiInstanceManifest
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiInstanceManifest{}, fmt.Errorf("instances enable: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
+// apiInstanceRecord mirrors provision.Record's wire shape — redeclared here
+// rather than imported because this client renders records, never writes
+// them, and the fields it renders are the contract worth pinning.
+type apiInstanceRecord struct {
+ Host string `json:"host"`
+ Slug string `json:"slug"`
+ Pool string `json:"pool"`
+ Region string `json:"region"`
+ Channel string `json:"channel"`
+ CreatedAt string `json:"created_at"`
+ CreatedBy string `json:"created_by"`
+}
+
+// instanceCreate asks the console to create one instance record — the
+// create-only write whose materialization (provision.Reconcile, on the box)
+// is what brings the host live. The console stamps pool from the manifest
+// and validates host against the manifest's domain; this side sends only
+// what a caller may choose.
+func (s *apiSession) instanceCreate(ctx context.Context, a apiAcct, app, host, region, channel string) (apiInstanceRecord, error) {
+ in := struct {
+ Host string `json:"host"`
+ Region string `json:"region,omitempty"`
+ Channel string `json:"channel,omitempty"`
+ }{host, region, channel}
+ status, body, err := s.postJSON(ctx, apiPath(a, app, "/instances"), in)
+ if err != nil {
+ return apiInstanceRecord{}, err
+ }
+ if status != http.StatusCreated {
+ return apiInstanceRecord{}, apiErrorFromBody(status, body)
+ }
+ var out apiInstanceRecord
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiInstanceRecord{}, fmt.Errorf("instances create: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
+// instanceList reads the app's instance records through the console,
+// honest about the server's 500-record cap.
+func (s *apiSession) instanceList(ctx context.Context, a apiAcct, app string) ([]apiInstanceRecord, bool, error) {
+ status, body, err := s.getJSON(ctx, apiPath(a, app, "/instances"))
+ if err != nil {
+ return nil, false, err
+ }
+ if status != http.StatusOK {
+ return nil, false, apiErrorFromBody(status, body)
+ }
+ var res struct {
+ Instances []apiInstanceRecord `json:"instances"`
+ Truncated bool `json:"truncated"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return nil, false, fmt.Errorf("instances list: unreadable response: %w", err)
+ }
+ return res.Instances, res.Truncated, nil
+}
+
+// instanceDelete retires one instance record. Idempotent server-side (the
+// caller's goal is "not there"), so a 200 is the only success shape.
+func (s *apiSession) instanceDelete(ctx context.Context, a apiAcct, app, host string) error {
+ status, body, err := s.deleteJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)))
+ if err != nil {
+ return err
+ }
+ if status != http.StatusOK {
+ return apiErrorFromBody(status, body)
+ }
+ return nil
+}
+
// shipTimeout bounds ship's own context — generous on purpose. The console
// gives itself up to 10 minutes to read the request body and 2 minutes to
// store it (internal/console/apicli.go), well past the 30s absolute Timeout
diff --git a/cli.go b/cli.go
index fc10c3a79..226a8e54d 100644
--- a/cli.go
+++ b/cli.go
@@ -181,13 +181,26 @@ func runRemove(args []string) {
}
func runInstances(args []string) {
- // "enable" is the one subcommand this verb grows (Task 8); every other
- // invocation is the bare listing form below, unchanged. Checked before
- // the flagset below is even built, since "enable" is a positional word,
- // not a flag, and must never reach fs.Parse.
- if len(args) > 0 && args[0] == "enable" {
- runInstancesEnable(args[1:])
- return
+ // The subcommands are positional words, not flags, and must never reach
+ // fs.Parse: enable is the app's provisioning opt-in (instances_cli.go),
+ // create/list/delete are the console-API record fronts (issue #66).
+ // Every other invocation is the bare listing form below — THIS box's
+ // registry routes, operator-side — unchanged.
+ if len(args) > 0 {
+ switch args[0] {
+ case "enable":
+ runInstancesEnable(args[1:])
+ return
+ case "create":
+ runInstancesCreate(args[1:])
+ return
+ case "list":
+ runInstancesList(args[1:])
+ return
+ case "delete":
+ runInstancesDelete(args[1:])
+ return
+ }
}
fs := flag.NewFlagSet("instances", flag.ExitOnError)
reg := fs.String("registry", defaultRegistry(), "registry database")
diff --git a/cmd/console/main.go b/cmd/console/main.go
index c2686b9a2..d509f3cad 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -31,9 +31,12 @@
// https://keymail.dev), CARLOS_REGISTRY (default /data/registry.db),
// CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR, CARLOS_RELEASE_PUBKEY,
// and optionally CARLOS_RELEASE_KEY (enables promote/rollback from the
-// console; unset = read-only) and CARLOS_SECRETS_PUBKEY (SERVED to CLI
+// console; unset = read-only), CARLOS_SECRETS_PUBKEY (SERVED to CLI
// clients so `carlos secrets set` can seal with a login alone; never used
-// here). grant/revoke/owners need only CONSOLE_DB.
+// here) and PLATFORM_APPS_DOMAIN (the same variable the edge and agent
+// read; enables the CLI's member-facing `carlos instances enable`, whose
+// manifest domain is pinned to <sqid>.<this domain>). grant/revoke/owners
+// need only CONSOLE_DB.
package main
import (
@@ -227,6 +230,7 @@ func serve() {
Key: key,
EdgeIPs: edgeIPs(),
SecretsPub: secretsPub(),
+ AppsDomain: strings.ToLower(strings.TrimSpace(os.Getenv("PLATFORM_APPS_DOMAIN"))),
}, st)
if err != nil {
log.Fatal(err)
diff --git a/docs/superpowers/specs/2026-08-04-instance-provisioning-design.md b/docs/superpowers/specs/2026-08-04-instance-provisioning-design.md
index cc612e744..94a1fd8cb 100644
--- a/docs/superpowers/specs/2026-08-04-instance-provisioning-design.md
+++ b/docs/superpowers/specs/2026-08-04-instance-provisioning-design.md
@@ -357,6 +357,43 @@ an account-scoped token listing (so a remaining member can enumerate and
revoke), or a gate-time membership re-check for the minter on every use
(which trades a cheap gate for a lookup per call). Not built here.
+## 12a. Addendum (2026-08-06, issue #66): the member-facing enable door
+
+§3.1's "written by the operator, direct/store mode" left one bucket-only
+prerequisite between "shipped, promoted, claimed, configured over the
+console API" and "reachable": nothing a login credential holds could create
+an app's first route. Built to close that:
+
+- **`PUT /api/cli/apps/{account}/{app}/instances-manifest`** — the
+ member-facing half of `carlos instances enable`. Gated by `apiAccountApp`
+ directly (user tokens only — enabling is policy, a human act; an app
+ token meets cliUser's 401, as at the token mint), budgeted on the
+ address-keyed write limiter. The manifest's **domain is pinned
+ server-side to `<sqid>.<PLATFORM_APPS_DOMAIN>`** — the account's own
+ platform subdomain; nothing the request carries can name another. Custom
+ domains stay operator/store-mode, and a standing manifest under any other
+ domain is a 409, never an overwrite. Channel and seed follow the
+ absent-vs-explicit split (absent preserves, explicit replaces, seed `[]`
+ clears); pool and regions are operator policy the door never writes.
+ The console learns the apps domain from `PLATFORM_APPS_DOMAIN` — the
+ same variable the edge and agent read; unset = the door refuses (503)
+ naming the store-mode operator act.
+- **`carlos instances enable` API mode** — credential-first resolution
+ (the §1 pattern from 2026-08-03-cli-console-api-design.md);
+ `-domain`/`-pool`/`-regions` are refused in signed-in mode rather than
+ ignored.
+- **`carlos instances create / list / delete`** — the §9 follow-up slice,
+ built: thin CLI fronts over the §7 record verbs, signed-in only
+ (operators placing routes by hand keep `carlos add`).
+
+The go-live path this completes, for a platform-domain app: enable (manifest
+under `<sqid>.<domain>`) → create the record for `<app>.<sqid>.<domain>`
+(one label under the pin) → provision.Reconcile mints the real route at the
+apex host itself — domains.Reconcile's `byHost` guard then skips apex
+minting idempotently, and `wildcardParent` covers the host at exactly the
+per-account wildcard depth. No console route write anywhere: §11's "the
+agent's reconcile pass is the only route writer" stands untouched.
+
## 13. Testing
- `internal/provision`: record/manifest validation matrices, key parsing
diff --git a/instances_cli.go b/instances_cli.go
index c3f84378b..1da564fe8 100644
--- a/instances_cli.go
+++ b/instances_cli.go
@@ -1,11 +1,19 @@
-// The `carlos instances enable` verb: an operator's opt-in for the
-// per-app instance-provisioning feature (spec §3.1). Writes
-// apps/<sqid>/<app>/instances.json via provision.PutManifest, the
-// "carlos env set" pattern applied to the provisioning manifest — same
-// CAS-upsert discipline, not create-only, so re-running with a changed
-// value updates the manifest in place. Everything else instance-shaped
-// (create/list/delete of records themselves) is API-only per spec §9; no
-// CLI front for those is built here.
+// The `carlos instances` verb family beyond the bare box-side listing:
+//
+// - `enable` — the app's opt-in to instance provisioning (spec §3.1).
+// Two modes, resolved credential-first like ship/promote (apimode.go):
+// store mode writes apps/<sqid>/<app>/instances.json via
+// provision.PutManifest with every field under the operator's hand;
+// API mode (issue #66) asks the console, which pins the manifest's
+// domain to the account's own platform subdomain — so a member can
+// bring an app live on <app>.<sqid>.<apps-domain> with a login alone,
+// and custom domains stay an operator act.
+// - `create` / `list` / `delete` — the CLI front over the console's
+// instance-record verbs (spec §9's "natural follow-up slice", built
+// here because the records were otherwise API-only: the one
+// day-to-day surface with no carlos command). API mode ONLY: a
+// record is a console-authorized write, and the box-side operator
+// equivalent has always been `carlos add`.
package main
import (
@@ -14,24 +22,34 @@ import (
"fmt"
"log"
"strings"
+ "text/tabwriter"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
)
-const instancesEnableUsage = `usage: carlos instances enable -app <a> -domain <d> [flags]
+const instancesEnableUsage = `usage: carlos instances enable -app <a> [flags]
+
+Signed-in mode (carlos auth login; no CARLOS_DEPLOYMENT_* env): the console
+writes the manifest, and pins its domain to your account's own platform
+subdomain (<sqid>.<apps-domain>). -domain, -pool and -regions are operator,
+store-mode flags and are refused here.
+
+Store mode (CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR set): every field
+is the operator's call, and -domain is required.
flags:
-app <a> app whose instances this enables (required)
-domain <d> every instance host must be exactly one DNS label
- + "." + this domain (required)
- -pool <p> pool stamped into every record at create (default:
- "", the shared pool)
+ + "." + this domain (store mode: required;
+ signed-in mode: refused — the console pins it)
+ -pool <p> pool stamped into every record at create (store
+ mode only; default: "", the shared pool)
-channel <c> release channel stamped into every record at
- create (default "stable"); the request may
- override channel, never pool
- -regions <r1,r2,...> allow-list for a record's region; empty (default)
- = any shape-valid region
+ create (store-mode default "stable"; signed-in
+ mode: omitted = preserve what the manifest holds)
+ -regions <r1,r2,...> allow-list for a record's region (store mode
+ only); empty (default) = any shape-valid region
-seed "args..." argv of the app's first-boot init command,
whitespace-split via strings.Fields — shell-word
semantics (quoting, escaping) are deliberately NOT
@@ -40,6 +58,8 @@ flags:
(default "ops")
-account-id <id> account sqid, matched as a sqid ONLY — supersedes
-account
+ -console <origin> console to enable through (signed-in mode only;
+ default: the only logged-in one)
Writing this manifest is the app's opt-in to instance provisioning: absence
means the feature does not exist for the app, and the API refuses creates
@@ -49,19 +69,73 @@ until this is set (spec §3.1).
func runInstancesEnable(args []string) {
fs := flag.NewFlagSet("instances enable", flag.ExitOnError)
app := fs.String("app", "", "app whose instances this enables")
- domain := fs.String("domain", "", "instance host domain suffix")
- pool := fs.String("pool", "", "pool stamped into every record at create")
- channel := fs.String("channel", "stable", "release channel stamped into every record at create")
- regions := fs.String("regions", "", "comma-separated region allow-list; empty = any shape-valid region")
+ domain := fs.String("domain", "", "instance host domain suffix (store mode only)")
+ pool := fs.String("pool", "", "pool stamped into every record at create (store mode only)")
+ // Default "" so signed-in mode can tell "unset" from "asked for": an
+ // omitted -channel must preserve whatever the manifest already carries,
+ // not silently reset it to stable. Store mode applies the documented
+ // "stable" default below, so its behaviour is unchanged.
+ channel := fs.String("channel", "", "release channel stamped into every record at create")
+ regions := fs.String("regions", "", "comma-separated region allow-list (store mode only); empty = any shape-valid region")
seed := fs.String("seed", "", "argv of the app's first-boot init command, whitespace-split (strings.Fields)")
- _, _, acctOf := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console to enable through (signed-in mode only; default: the only logged-in one)")
+ acctRaw, acctIDRaw, acctOf := accountFlags(fs)
fs.Parse(args)
- if *app == "" || *domain == "" {
+ if *app == "" {
log.Fatal(instancesEnableUsage)
}
if !release.ValidApp(*app) {
log.Fatalf("carlos instances enable: %q is not a valid app name", *app)
}
+ set := map[string]bool{}
+ fs.Visit(func(f *flag.Flag) { set[f.Name] = true })
+
+ sess, err := resolveMode(*consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess != nil {
+ // Signed-in mode. The three operator fields are refused rather than
+ // ignored — a flag that silently did nothing would read as policy
+ // applied.
+ for _, f := range []string{"domain", "pool", "regions"} {
+ if set[f] {
+ log.Fatalf("carlos instances enable: -%s is an operator, store-mode flag — the console pins the manifest to your account's platform subdomain. Set CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR to enable in store mode instead", f)
+ }
+ }
+ var channelReq *string
+ if set["channel"] {
+ channelReq = channel
+ }
+ var seedReq *[]string
+ if set["seed"] {
+ // parseSeed("") is an empty (non-nil after this) argv: an explicit
+ // -seed "" clears the manifest's seed, matching -channel's
+ // explicit-vs-absent split.
+ argv := parseSeed(*seed)
+ if argv == nil {
+ argv = []string{}
+ }
+ seedReq = &argv
+ }
+ acct := apiAccount(acctRaw, acctIDRaw)
+ fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ m, err := sess.instancesEnable(context.Background(), acct, *app, channelReq, seedReq)
+ if err != nil {
+ log.Fatalf("instances enable: %v", err)
+ }
+ fmt.Fprintf(stdout, "enabled instances for %s under %s\n", m.App, m.Domain)
+ fmt.Fprintf(stdout, " first instance: carlos instances create -app %s -host <label>.%s\n", m.App, m.Domain)
+ return
+ }
+
+ // Store mode — the operator path, unchanged.
+ if *domain == "" {
+ log.Fatal(instancesEnableUsage)
+ }
+ if *channel == "" {
+ *channel = "stable"
+ }
// A domain is dot-joined labels with no suffix of its own to check
// against (unlike a host, which is validated against ITS domain by
// provision.ValidHost) — ValidHostShape is exactly that shape check,
@@ -110,10 +184,111 @@ func parseSeed(raw string) []string {
return strings.Fields(raw)
}
-// instancesEnable is the core `carlos instances enable` writes through —
-// separated from flag parsing/validation above so a test can call it
-// directly against a release.Store, the same shape provision_test.go's
+// instancesEnable is the core store-mode `carlos instances enable` writes
+// through — separated from flag parsing/validation above so a test can call
+// it directly against a release.Store, the same shape provision_test.go's
// TestManifestRoundTrip exercises PutManifest with.
func instancesEnable(ctx context.Context, s release.Store, acct, app string, m provision.Manifest) error {
return provision.PutManifest(ctx, s, acct, app, m)
}
+
+const instancesRecordsUsage = `usage: carlos instances create -app <a> -host <h> [-region <r>] [-channel <c>]
+ carlos instances list -app <a>
+ carlos instances delete -app <a> -host <h>
+
+Signed-in only (carlos auth login): instance records are console-authorized
+writes — the console checks the host against the app's manifest and the
+box's reconcile pass materializes the route. Common flags:
+
+ -account <a> account (sqid or name; default "ops")
+ -account-id <id> account sqid, matched as a sqid ONLY
+ -console <origin> console to act through (default: the only logged-in one)
+
+Operators placing routes by hand on a box use carlos add, not this.
+`
+
+// requireAPISession resolves signed-in mode for the record verbs, refusing
+// direct mode by name: there IS no store-mode record write (the console is
+// the door, spec §9), and the operator equivalent is a different verb.
+func requireAPISession(verb, consoleFlag string) *apiSession {
+ sess, err := resolveMode(consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess == nil {
+ log.Fatalf("carlos instances %s works through a console, and this shell is in direct bucket mode\n"+
+ "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Instance records are\n"+
+ "console-authorized writes; unset the store env and run: carlos auth login -console <url>.\n"+
+ "To place a route by hand on THIS box, use carlos add instead", verb)
+ }
+ return sess
+}
+
+func runInstancesCreate(args []string) {
+ fs := flag.NewFlagSet("instances create", flag.ExitOnError)
+ app := fs.String("app", "", "app the instance belongs to")
+ host := fs.String("host", "", "instance host — one DNS label + \".\" + the app's instance domain")
+ 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")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ fs.Parse(args)
+ if *app == "" || *host == "" {
+ log.Fatal(instancesRecordsUsage)
+ }
+ sess := requireAPISession("create", *consoleFlag)
+ fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ rec, err := sess.instanceCreate(context.Background(), apiAccount(acctRaw, acctIDRaw), *app,
+ strings.TrimSpace(*host), strings.TrimSpace(*region), strings.TrimSpace(*channel))
+ if err != nil {
+ log.Fatalf("instances create: %v", err)
+ }
+ fmt.Fprintf(stdout, "created instance %s (channel %s)\n", rec.Host, rec.Channel)
+ fmt.Fprintf(stdout, " the host comes live on the next reconcile pass on its box\n")
+}
+
+func runInstancesList(args []string) {
+ fs := flag.NewFlagSet("instances list", flag.ExitOnError)
+ app := fs.String("app", "", "app whose instance records to list")
+ 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")
+ 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, err := sess.instanceList(context.Background(), apiAccount(acctRaw, acctIDRaw), *app)
+ if err != nil {
+ log.Fatalf("instances list: %v", err)
+ }
+ w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0)
+ fmt.Fprintln(w, "HOST\tREGION\tCHANNEL\tPOOL\tCREATED\tBY")
+ for _, r := range recs {
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", r.Host, r.Region, r.Channel, r.Pool, r.CreatedAt, r.CreatedBy)
+ }
+ w.Flush()
+ if truncated {
+ fmt.Fprintln(stdout, "(truncated at 500 records)")
+ }
+}
+
+func runInstancesDelete(args []string) {
+ fs := flag.NewFlagSet("instances delete", flag.ExitOnError)
+ 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")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ fs.Parse(args)
+ if *app == "" || *host == "" {
+ log.Fatal(instancesRecordsUsage)
+ }
+ sess := requireAPISession("delete", *consoleFlag)
+ fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ if err := sess.instanceDelete(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, strings.TrimSpace(*host)); err != nil {
+ log.Fatalf("instances delete: %v", err)
+ }
+ fmt.Fprintf(stdout, "deleted instance %s — its route retires on the next reconcile pass; parked data stays\n", *host)
+}
diff --git a/instances_e2e_test.go b/instances_e2e_test.go
new file mode 100644
index 000000000..b23d3d13d
--- /dev/null
+++ b/instances_e2e_test.go
@@ -0,0 +1,142 @@
+// End-to-end acceptance test for issue #66: `carlos instances
+// enable/create/list/delete` working through the console API with `carlos
+// auth login` alone — the last step between "promoted" and "reachable"
+// without a box shell or bucket credentials.
+//
+// Follows TestE2ECLIThroughConsole's shape (auth_e2e_test.go): a REAL
+// console.Server over a DirStore, a device-flow login landing a real bearer
+// token in a temp credential file, then — with NO store env set — the real
+// run* functions driven in-process. Ground truth is the DirStore's own
+// manifest and record objects, plus the `stdout` seam's confirmation lines
+// (these verbs print through cli.go's stdout var, so captureStdout reaches
+// them).
+package main
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/console"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestE2EInstancesThroughConsole(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ tmp := t.TempDir()
+ relStore := release.NewDirStore(filepath.Join(tmp, "store"))
+ seed, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ priv, err := release.ParseSigningKey(seed, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub, err := release.ParsePublicKey(pubB64, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cst, err := console.Open(filepath.Join(tmp, "console.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { cst.Close() })
+
+ var srv *console.Server
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ srv.ServeHTTP(w, r)
+ }))
+ t.Cleanup(ts.Close)
+ srv, err = console.NewServer(console.Config{
+ Origin: ts.URL,
+ Keymail: "http://keymail.invalid", // never dialled — see e2eDeviceLogin
+ Registry: filepath.Join(tmp, "registry.db"),
+ Releases: relStore,
+ ReleasePub: pub,
+ Key: priv,
+ AppScheme: "http",
+ AppsDomain: "oncarlos.test",
+ }, cst)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ const addr = "paul@keymail.dev"
+ if err := cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := cst.CreateAppIn(console.OpsAccountID, "titogo", addr, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+
+ token, expiresAt := e2eDeviceLogin(t, ts.URL, cst, addr, "e2e@laptop")
+ credFile, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ credFile.put(credential{Console: ts.URL, Address: addr, Token: token, ExpiresAt: expiresAt})
+ if err := credFile.save(); err != nil {
+ t.Fatal(err)
+ }
+
+ // --- enable: the console pins the domain to the account's platform
+ // subdomain; no -domain crosses the wire ---------------------------------
+ const domain = "bab.oncarlos.test"
+ buf := captureStdout(t)
+ runInstancesEnable([]string{"-app", "titogo", "-account", "ops", "-channel", "beta"})
+ if want := "enabled instances for titogo under " + domain; !strings.Contains(buf.String(), want) {
+ t.Fatalf("enable stdout = %q, want %q", buf.String(), want)
+ }
+ m, err := provision.GetManifest(context.Background(), relStore, release.OpsAccount, "titogo")
+ if err != nil {
+ t.Fatalf("manifest after enable: %v", err)
+ }
+ if m.Domain != domain || m.Channel != "beta" || m.Pool != "" {
+ t.Fatalf("manifest = %+v, want %s on beta with the shared pool", m, domain)
+ }
+
+ // --- create: the app's own platform host, one label under the pin ------
+ host := "titogo." + domain
+ buf.Reset()
+ runInstancesCreate([]string{"-app", "titogo", "-account", "ops", "-host", host})
+ if want := "created instance " + host + " (channel beta)"; !strings.Contains(buf.String(), want) {
+ t.Fatalf("create stdout = %q, want %q", buf.String(), want)
+ }
+ rec, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, "titogo", host)
+ if err != nil {
+ t.Fatalf("record after create: %v", err)
+ }
+ if rec.Channel != "beta" || rec.Slug != "titogo" || rec.CreatedBy != addr {
+ t.Fatalf("record = %+v, want beta/titogo created by %s", rec, addr)
+ }
+
+ // --- list ----------------------------------------------------------------
+ buf.Reset()
+ runInstancesList([]string{"-app", "titogo", "-account", "ops"})
+ if out := buf.String(); !strings.Contains(out, host) || !strings.Contains(out, "beta") {
+ t.Fatalf("list stdout = %q, want the record's host and channel", out)
+ }
+
+ // --- delete: idempotent record retirement -------------------------------
+ buf.Reset()
+ runInstancesDelete([]string{"-app", "titogo", "-account", "ops", "-host", host})
+ if want := "deleted instance " + host; !strings.Contains(buf.String(), want) {
+ t.Fatalf("delete stdout = %q, want %q", buf.String(), want)
+ }
+ if _, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, "titogo", host); err == nil {
+ t.Fatalf("record survived delete")
+ }
+
+ // The whole flow ran through the console: nothing here may have touched
+ // a local registry.
+ checkNoRegistry()
+}
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index 34ed311ca..aa2fc647a 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -386,6 +386,122 @@ func (s *Server) handleAPIInstanceToken(w http.ResponseWriter, r *http.Request)
})
}
+// instanceSeed bounds (issue #66). Seed becomes the argv the activator execs
+// the app's OWN binary with on first wake — the caller already controls that
+// binary via ship, so the bound here is manifest hygiene, not privilege: a
+// manifest is read forever, and an unbounded argv written once would be
+// permanent.
+const (
+ instanceSeedMaxArgs = 16
+ instanceSeedMaxArgLen = 256
+)
+
+// handleAPIInstanceEnable is the member-facing half of `carlos instances
+// enable` (issue #66): the one bucket-only prerequisite that kept the
+// otherwise-API path from "promoted" to "reachable" needing a box shell.
+//
+// The security line it holds: the manifest's DOMAIN is pinned server-side to
+// <account-sqid>.<AppsDomain> — the account's own platform subdomain — and
+// nothing the request carries can name another. Custom domains (tito's
+// go.tito.io) stay an operator act in store mode, and an existing manifest
+// under any other domain is refused with a 409, never overwritten: this door
+// can hand an account keys only to hostnames that were already hers.
+//
+// Gated by apiAccountApp DIRECTLY, not the instance gate: enabling is
+// policy, a human act — an app token meets cliUser's 401 here, exactly as it
+// does at the token mint. Budgeted on the address-keyed write limiter for
+// the same reason.
+func (s *Server) handleAPIInstanceEnable(w http.ResponseWriter, r *http.Request) {
+ addr, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ if !s.cli.write.allow(addr, time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
+ return
+ }
+ if s.cfg.Releases == nil {
+ jsonError(w, http.StatusServiceUnavailable,
+ "no deployment bucket on this host — the console is running without one")
+ return
+ }
+ if s.cfg.AppsDomain == "" {
+ // Membership is proven, so this may name the operator act that fixes
+ // it. 503, not 409: the missing piece is deployment configuration
+ // (PLATFORM_APPS_DOMAIN on the console), not anything about this app.
+ jsonError(w, http.StatusServiceUnavailable,
+ "this console has no platform apps domain configured — an operator enables instances in store mode (carlos instances enable) or sets PLATFORM_APPS_DOMAIN on the console")
+ return
+ }
+ // Channel and Seed are pointers because "absent" and "empty" are
+ // different requests on the wire (the apiConfigApply precedent): an
+ // omitted field preserves what the manifest already carries, an explicit
+ // value replaces it — including seed's explicit [] meaning "clear".
+ var req struct {
+ Channel *string `json:"channel"`
+ Seed *[]string `json:"seed"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ if req.Channel != nil && !release.ValidChannel(*req.Channel) {
+ jsonError(w, http.StatusBadRequest, "invalid channel name")
+ return
+ }
+ if req.Seed != nil {
+ if len(*req.Seed) > instanceSeedMaxArgs {
+ jsonError(w, http.StatusBadRequest, "seed takes at most 16 arguments")
+ return
+ }
+ for _, arg := range *req.Seed {
+ if arg == "" || len(arg) > instanceSeedMaxArgLen {
+ jsonError(w, http.StatusBadRequest, "each seed argument must be 1-256 bytes")
+ return
+ }
+ }
+ }
+ domain := acct.Sqid + "." + s.cfg.AppsDomain
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ m, err := provision.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app)
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ m = &provision.Manifest{Schema: 1, App: app, Domain: domain}
+ case err != nil:
+ log.Printf("console: api instances enable read %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not read instance settings")
+ return
+ case m.Domain != domain:
+ // Never overwritten from here: a manifest naming any other domain
+ // was written by an operator in store mode, and re-homing every
+ // FUTURE host check under the platform domain would be this door
+ // rewriting operator policy. Membership is proven, so naming the
+ // standing domain leaks nothing the caller cannot already read.
+ jsonError(w, http.StatusConflict,
+ "instances for "+app+" are managed under "+m.Domain+
+ " — changing an app's instance domain is an operator act (carlos instances enable, store mode)")
+ return
+ }
+ // Pool and Regions survive untouched on an update: they are operator
+ // policy this door never speaks for, in either direction.
+ if req.Channel != nil {
+ m.Channel = *req.Channel
+ }
+ if req.Seed != nil {
+ m.Seed = *req.Seed
+ }
+ if err := provision.PutManifest(ctx, s.cfg.Releases, acct.Sqid, app, *m); err != nil {
+ log.Printf("console: api instances enable %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not enable instances — try again")
+ return
+ }
+ s.audit(acct, app, addr, "instance-enable", "enabled instances under "+domain+" (cli)")
+ writeJSON(w, http.StatusOK, map[string]any{
+ "app": app, "domain": m.Domain, "pool": m.Pool, "channel": m.Channel,
+ "regions": m.Regions, "seed": m.Seed,
+ })
+}
+
// instanceRegionAllowed applies the manifest's region allow-list. An empty
// list means any shape-valid region (spec §3.1) — including the empty,
// unconstrained one, which is what a single-region deployment uses.
diff --git a/internal/console/apiinstancesenable_test.go b/internal/console/apiinstancesenable_test.go
new file mode 100644
index 000000000..b1ca85fbc
--- /dev/null
+++ b/internal/console/apiinstancesenable_test.go
@@ -0,0 +1,241 @@
+package console
+
+// The member-facing instances-manifest door (issue #66): the one write that
+// used to need a box shell or bucket credentials between "promoted" and
+// "reachable". The properties that matter, in the order they'd hurt if
+// lost: the manifest's domain is pinned server-side to the account's own
+// platform subdomain (nothing the request carries can name another
+// account's — or anyone's custom — domain), an operator-written manifest
+// under a different domain is refused rather than overwritten, the refusals
+// before membership is proven are the family's byte-identical 404, an app
+// token cannot enable anything, and pool/regions — operator policy — ride
+// through updates untouched.
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func enableURL(fx *consoleFixture, acct, app string) string {
+ return fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/instances-manifest"
+}
+
+// TestAPIInstanceEnableChain is issue #66's done-criteria as a test, on a
+// NON-ops account (the seam test's discipline — ops's sqid is hardcoded
+// known elsewhere, so an ops-only test would pass with the sqid agreement
+// broken): a member with a login credential and no bucket access enables
+// instances, creates the app's first record at its own platform host, and
+// the agent's reconcile pass — joined to the console by nothing but the
+// bucket — materializes the route that brings the host live.
+func TestAPIInstanceEnableChain(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.AppsDomain = "oncarlos.test"
+ token := cliToken(t, fx, "alice@keymail.dev", "code-inst-enable")
+ now := time.Now()
+ const acctID int64 = 4242
+ sqid := accountSqid(acctID)
+ if err := fx.st.CreateAccount(acctID, sqid, "Amadan", "alice@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.CreateAppIn(acctID, "hub", "alice@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := WriteAccountManifest(context.Background(), fx.rel, acctID, sqid, "Amadan", now); err != nil {
+ t.Fatal(err)
+ }
+
+ wantDomain := sqid + ".oncarlos.test"
+ status, _, out := rawReq(t, http.MethodPut, enableURL(fx, sqid, "hub"), token, `{"channel":"beta"}`)
+ if status != http.StatusOK {
+ t.Fatalf("enable = %d %s", status, out)
+ }
+ var res struct {
+ App string `json:"app"`
+ Domain string `json:"domain"`
+ Channel string `json:"channel"`
+ }
+ if err := json.Unmarshal([]byte(out), &res); err != nil {
+ t.Fatal(err)
+ }
+ if res.App != "hub" || res.Domain != wantDomain || res.Channel != "beta" {
+ t.Fatalf("enable response = %+v, want hub under %s on beta", res, wantDomain)
+ }
+ m, err := provision.GetManifest(context.Background(), fx.rel, sqid, "hub")
+ if err != nil {
+ t.Fatalf("manifest after enable: %v", err)
+ }
+ if m.Domain != wantDomain || m.App != "hub" || m.Channel != "beta" || m.Pool != "" || len(m.Regions) != 0 {
+ t.Fatalf("manifest = %+v, want domain %s, channel beta, empty pool/regions", m, wantDomain)
+ }
+
+ // The app's own platform host is one label under the pinned domain —
+ // exactly what the existing create door now accepts.
+ host := "hub." + wantDomain
+ status, _, out = rawReq(t, http.MethodPost, instancesURL(fx, sqid, "hub"), token, `{"host":"`+host+`"}`)
+ if status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+
+ // The agent's half: an unscoped box (no region, no pools — the
+ // single-box deployment shape) materializes the record as the route.
+ dataDir := t.TempDir()
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { reg.Close() })
+ if err := provision.Reconcile(context.Background(), fx.rel, reg,
+ provision.ReconcileConfig{DataDir: dataDir}); err != nil {
+ t.Fatalf("reconcile: %v", err)
+ }
+ got, err := reg.Get(host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got == nil {
+ t.Fatalf("the enabled app's record never materialized as a route")
+ }
+ want := registry.Route{
+ Host: host, Slug: "hub", Kind: "instance",
+ Socket: provision.DefaultSocket(host), DBPath: provision.DefaultDBPath(dataDir, host),
+ App: "hub", Account: sqid, Channel: "beta",
+ Hibernate: true, Provisioned: true, Status: "ready",
+ CreatedAt: got.CreatedAt,
+ }
+ if *got != want {
+ t.Fatalf("minted route =\n %+v\nwant\n %+v", *got, want)
+ }
+}
+
+// TestAPIInstanceEnableUpdatePreserves: an update through this door touches
+// only what the request names. Pool and regions are operator policy the
+// door never speaks for; channel and seed follow the absent-vs-explicit
+// split (absent preserves, explicit replaces, seed's [] clears).
+func TestAPIInstanceEnableUpdatePreserves(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-enable-upd")
+ fx.srv.cfg.AppsDomain = "oncarlos.test"
+ domain := fx.sqid + ".oncarlos.test"
+ // An operator already wrote a platform-domain manifest with policy in
+ // it: same domain this door would pin, so updates are legal — but its
+ // pool and regions must ride through untouched.
+ enableInstances(t, fx, "titogo", provision.Manifest{
+ Schema: 1, App: "titogo", Domain: domain,
+ Pool: "p1", Channel: "stable", Regions: []string{"ie"}, Seed: []string{"seed", "arg"},
+ })
+
+ u := enableURL(fx, fx.sqid, "titogo")
+ if status, _, out := rawReq(t, http.MethodPut, u, token, `{"channel":"beta"}`); status != http.StatusOK {
+ t.Fatalf("update channel = %d %s", status, out)
+ }
+ m, err := provision.GetManifest(context.Background(), fx.rel, fx.sqid, "titogo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if m.Channel != "beta" || m.Pool != "p1" || len(m.Regions) != 1 || m.Regions[0] != "ie" ||
+ len(m.Seed) != 2 {
+ t.Fatalf("after channel update, manifest = %+v: want beta with pool/regions/seed preserved", m)
+ }
+
+ if status, _, out := rawReq(t, http.MethodPut, u, token, `{"seed":[]}`); status != http.StatusOK {
+ t.Fatalf("clear seed = %d %s", status, out)
+ }
+ m, err = provision.GetManifest(context.Background(), fx.rel, fx.sqid, "titogo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(m.Seed) != 0 || m.Channel != "beta" || m.Pool != "p1" {
+ t.Fatalf("after seed clear, manifest = %+v: want seed gone, everything else standing", m)
+ }
+
+ if status, _, out := rawReq(t, http.MethodPut, u, token, `{}`); status != http.StatusOK {
+ t.Fatalf("empty update = %d %s", status, out)
+ }
+ m, err = provision.GetManifest(context.Background(), fx.rel, fx.sqid, "titogo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if m.Channel != "beta" || m.Pool != "p1" || len(m.Regions) != 1 {
+ t.Fatalf("after empty update, manifest = %+v: want everything preserved", m)
+ }
+}
+
+// TestAPIInstanceEnableRefusals: every way of not being allowed to enable,
+// and the order the answers keep secrets in. Before membership is proven the
+// answer is the family's byte-identical 404; an app token meets cliUser's
+// 401 (enabling is a human act); only after the gate do causes get names —
+// including the one refusal that guards somebody else's standing policy,
+// the foreign-domain 409.
+func TestAPIInstanceEnableRefusals(t *testing.T) {
+ fx, member := instanceFixture(t, "code-inst-enable-ref")
+ fx.srv.cfg.AppsDomain = "oncarlos.test"
+ want := canonicalNotFound(t)
+ eve := cliToken(t, fx, "eve@keymail.dev", "code-inst-enable-eve")
+ appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ for _, c := range []struct {
+ what, acct, app, token string
+ }{
+ {"non-member", "bab", "titogo", eve},
+ {"unknown account", "zzzz", "titogo", member},
+ {"unclaimed app", "bab", "ghost-app", member},
+ } {
+ status, h, out := rawReq(t, http.MethodPut, enableURL(fx, c.acct, c.app), c.token, `{}`)
+ assertNotFound(t, "enable: "+c.what, want, status, h, out)
+ }
+
+ // An app token cannot enable: CLITokenAddress answers only user tokens,
+ // so the binding that authorizes record writes meets a plain 401 here.
+ if status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), appTok, `{}`); status != http.StatusUnauthorized {
+ t.Fatalf("app token enable = %d %s, want 401", status, out)
+ }
+
+ // Cheap validation refusals, after the gate.
+ for _, c := range []struct {
+ what, body string
+ }{
+ {"bad channel", `{"channel":"NOT a channel"}`},
+ {"too many seed args", `{"seed":[` + strings.Repeat(`"a",`, 16) + `"a"]}`},
+ {"empty seed arg", `{"seed":[""]}`},
+ {"oversized seed arg", `{"seed":["` + strings.Repeat("a", 257) + `"]}`},
+ } {
+ if status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), member, c.body); status != http.StatusBadRequest {
+ t.Fatalf("enable %s = %d %s, want 400", c.what, status, out)
+ }
+ }
+
+ // A standing manifest under any other domain — an operator's custom
+ // domain — is never overwritten from here.
+ enableInstances(t, fx, "titogo", titoManifest())
+ status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), member, `{"channel":"beta"}`)
+ if status != http.StatusConflict {
+ t.Fatalf("enable over custom domain = %d %s, want 409", status, out)
+ }
+ if !strings.Contains(out, "go.tito.io") || !strings.Contains(out, "operator act") {
+ t.Fatalf("conflict body = %s, want it to name the standing domain and the operator act", out)
+ }
+ if m, err := provision.GetManifest(context.Background(), fx.rel, "bab", "titogo"); err != nil || m.Domain != "go.tito.io" {
+ t.Fatalf("manifest after refused enable = %+v %v, want go.tito.io untouched", m, err)
+ }
+
+ // No PLATFORM_APPS_DOMAIN on this console: the member-facing door does
+ // not exist, and says which operator act does.
+ fx.srv.cfg.AppsDomain = ""
+ status, _, out = rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), member, `{}`)
+ if status != http.StatusServiceUnavailable {
+ t.Fatalf("enable with no apps domain = %d %s, want 503", status, out)
+ }
+ if !strings.Contains(out, "PLATFORM_APPS_DOMAIN") {
+ t.Fatalf("no-apps-domain body = %s, want it to name the missing configuration", out)
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 6fa89c19d..6826f0d50 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -244,6 +244,10 @@ func (s *Server) registerCLIRoutes() {
// registration order, and it deliberately sits under a DIFFERENT
// prefix from instances/{host} so a host can never be mistaken for the
// mint verb.
+ // `instances-manifest` is the member-facing half of `carlos instances
+ // enable` (issue #66): a literal sibling of instances/, never under it,
+ // so a host named "instances-manifest" could still never reach it.
+ s.mux.HandleFunc("PUT /api/cli/apps/{account}/{app}/instances-manifest", s.handleAPIInstanceEnable)
s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/instances", s.handleAPIInstanceList)
s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/instances", s.handleAPIInstanceCreate)
s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/instances/{host}", s.handleAPIInstanceGet)
diff --git a/internal/console/server.go b/internal/console/server.go
index 8f62e5810..dbbb7a068 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -155,6 +155,15 @@ type Config struct {
// or without it. Optional: "" omits the field from the API response
// and the CLI falls back to requiring the env var.
SecretsPub string
+ // AppsDomain is the deployment's platform apps domain
+ // (PLATFORM_APPS_DOMAIN, the same variable the edge and the host
+ // agent read; flagship: "oncarlos.com"). The CLI's instances-manifest
+ // door pins a member-written manifest's domain to
+ // <account-sqid>.<AppsDomain> — never a domain the request names. ""
+ // follows the litestream convention: the member-facing enable door
+ // does not exist, and the handler refuses with a pointer at the
+ // operator's store-mode `carlos instances enable`.
+ AppsDomain string
}
// Server is the console's http.Handler.
diff --git a/main.go b/main.go
index fb8b01722..bcdc89bd5 100644
--- a/main.go
+++ b/main.go
@@ -82,7 +82,13 @@ commands:
version print the build id
edge run the mesh front door (router)
agent run edge + activator + release adoption on this host
- add | remove | instances manage routes
+ add | remove | instances manage routes (bare "instances" lists THIS
+ box's registry routes; "instances enable"
+ opts an app into instance provisioning —
+ signed-in mode pins it to the account's
+ platform subdomain; "instances
+ create|list|delete" manage instance records
+ through the console API, signed-in only)
route -host <h> | -app <a> [-account <sqid|name|all>]
[-channel <c>] [-environment <name>] [-dry-run]:
change which release channel and/or named