carlos / platform Public

Merge pull request #67 from carlosframework/issue-66-api-go-live

Console API + CLI: bring an app live on its platform domain with a login alone (#66)
Paul Campbell dd87bf6752968f327baf876d79ba61d18b60f7eb parent 91d0311parent 15f3a8b
14 files changed, +1150 −43
  • README.md +6 −3
  • apimode.go +133 −0
  • cli.go +20 −7
  • cmd/console/main.go +27 −2
  • docs/superpowers/specs/2026-08-04-instance-provisioning-design.md +65 −0
  • instances_cli.go +204 −25
  • instances_e2e_test.go +142 −0
  • internal/console/apiinstances.go +169 −0
  • internal/console/apiinstancesenable_test.go +293 −0
  • internal/console/authcli.go +4 −0
  • internal/console/server.go +9 −0
  • internal/provision/provision.go +31 −5
  • internal/provision/provision_test.go +40 −0
  • main.go +7 −1
diff --git a/README.md b/README.md
index d02050170..f9c453f57 100644
--- a/README.md
+++ b/README.md
@@ -292,9 +292,12 @@ publish an inflated instance count.
**Config surface:**
- `PLATFORM_APPS_DOMAIN` — the deployment's apps domain (flagship:
- `oncarlos.com`), read by `carlos agent`. **Unset = the whole feature is
- inert**: no minting, no manifests read, zero behavior change — the same
- litestream convention as everything else here.
+ `oncarlos.com`), read by `carlos agent`, the edge, and the console (the
+ member-facing `carlos instances enable` pins manifests to
+ `<sqid>.<this domain>`; the console refuses a malformed value at boot).
+ **Unset = the whole feature is inert**: no minting, no manifests read,
+ the enable door answers 503 — the same litestream convention as
+ everything else here.
- `PLATFORM_EDGE_IPS` — comma-separated edge IPs the DNS health checks
compare against, read by both the agent (checks) and the console
(guidance text), so the two can never name different addresses. Default:
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..c91aabf00 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 (
@@ -49,6 +52,7 @@ import (
"github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/console"
+ "github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
)
@@ -227,6 +231,7 @@ func serve() {
Key: key,
EdgeIPs: edgeIPs(),
SecretsPub: secretsPub(),
+ AppsDomain: appsDomain(),
}, st)
if err != nil {
log.Fatal(err)
@@ -302,6 +307,26 @@ func secretsPub() string {
return raw
}
+// appsDomain reads PLATFORM_APPS_DOMAIN — the same variable the edge and
+// the host agent read — for the CLI's member-facing `carlos instances
+// enable` door, which pins manifests to <sqid>.<this domain>. Garbage is
+// refused HERE, at boot, rather than stamped into a manifest every future
+// instance host gets validated against (the secretsPub precedent): the
+// suffix rides into bucket keys verbatim, so "*.oncarlos.com" copied off a
+// DNS record would produce records the reconcile pass skips — and protects
+// — forever. Unset is a legal deployment: the door answers 503 naming the
+// store-mode operator act.
+func appsDomain() string {
+ d := strings.ToLower(strings.TrimSpace(os.Getenv("PLATFORM_APPS_DOMAIN")))
+ if d == "" {
+ return ""
+ }
+ if !provision.ValidHostShape(d) {
+ log.Fatalf("console: PLATFORM_APPS_DOMAIN %q is not a valid domain (dot-joined DNS labels, no wildcard, no leading dot)", d)
+ }
+ return d
+}
+
// edgeIPs parses PLATFORM_EDGE_IPS (comma-separated) — the SAME variable the
// agent's domains reconcile loop reads, so the console's point-your-DNS-here
// guidance and the agent's DNS health checks can never name different
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..d412fb808 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,71 @@ 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.
+
+Review outcomes (Opus, 2026-08-06), built:
+
+- **Sibling-apex refusal.** The platform subdomain is a namespace every
+ app's apex shares, so a platform-pinned manifest makes sibling apexes
+ valid host shapes for the wrong app — and a record there would mint a
+ real route the domains pass defers to forever. The create door refuses a
+ label naming another claimed app in the account (user and app tokens
+ both); custom-domain manifests never enter apex space and are untouched.
+- **Guarded manifest write.** The enable door writes with
+ `PutManifestIfMatch` against the etag it READ ("" = create-only), so an
+ operator's store-mode manifest landing between check and write surfaces
+ as a 409, never a silent re-home. `PutManifest`'s upsert stays the
+ store-mode (operator-authority) semantics.
+- **Domain validated at both ends.** The console refuses a malformed
+ `PLATFORM_APPS_DOMAIN` at boot (the secretsPub precedent) and the door
+ re-checks the composed `<sqid>.<domain>` shape before stamping it.
+
+Recorded, not built: no per-app/per-account cap on records beyond the rate
+limiter (the operator opt-in used to be the admission control — a ceiling
+wants its own ruling); the enable door's read-modify-write would drop
+schema-1 fields added by a NEWER binary (upgrade the console before
+shipping such a field — same property the store-mode upsert has); a
+non-apex instance (e.g. `alice.<app>.<sqid>.<domain>`-style multi-tenant
+use under the account pin) still causes candidateApps/firstReadyUpstream to
+mint the app's apex onto that one customer's socket — pre-existing
+behaviour, now reachable by members, worth its own slice if platform-pinned
+multi-tenant apps become a real shape.
+
## 13. Testing
- `internal/provision`: record/manifest validation matrices, key parsing
diff --git a/instances_cli.go b/instances_cli.go
index c3f84378b..79e3f3bac 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,77 @@ 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: an ABSENT -channel takes
+ // the documented "stable" default, while an explicitly empty one stays
+ // the "not a valid channel" refusal it has always been (the flag's
+ // default moved from "stable" to "" for the signed-in mode's
+ // preserve-vs-replace split; set[] is what keeps the two cases apart).
+ if *domain == "" {
+ log.Fatal(instancesEnableUsage)
+ }
+ if *channel == "" && !set["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 +188,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..31c3a208c 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -240,6 +240,32 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request)
"invalid host — an instance host is one DNS label followed by ."+m.Domain)
return
}
+ // The account's platform subdomain is a SHARED namespace: every app's
+ // apex lives at <app>.<sqid>.<AppsDomain>, and a manifest pinned there
+ // (the member-facing enable door) makes every sibling's apex a valid
+ // host shape for THIS app. A record at a sibling's apex would mint a
+ // real route there first, and the domains pass would then skip the
+ // sibling's own apex mint forever (byHost) — one app quietly serving
+ // another's public URL (review 2026-08-06 #1). Within-account only, but
+ // reachable by an app token, so it is refused rather than trusted to
+ // membership: a label naming another claimed app in the account belongs
+ // to that app. Custom-domain manifests never enter apex space (ValidHost
+ // pinned the suffix above), so this check cannot touch them.
+ if s.cfg.AppsDomain != "" && m.Domain == acct.Sqid+"."+s.cfg.AppsDomain {
+ if label := strings.TrimSuffix(host, "."+m.Domain); label != app {
+ claimed, err := s.store.HasAppIn(acct.ID, label)
+ if err != nil {
+ log.Printf("console: api instances create sibling check %s/%s: %v", acct.Sqid, label, err)
+ jsonError(w, http.StatusInternalServerError, "could not check the host — try again")
+ return
+ }
+ if claimed {
+ jsonError(w, http.StatusConflict,
+ host+" is app "+label+"'s platform apex — create it as an instance of "+label+" instead")
+ return
+ }
+ }
+ }
region := strings.TrimSpace(req.Region)
if !provision.ValidRegion(region) || !instanceRegionAllowed(m.Regions, region) {
msg := "invalid region"
@@ -386,6 +412,149 @@ 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
+ // The domain is about to be stamped into a manifest every future host
+ // gets checked against, and ValidHost only regex-checks the leading
+ // label — the suffix rides into bucket keys verbatim. Store mode
+ // validates its -domain for exactly this reason (instances_cli.go); a
+ // misconfigured PLATFORM_APPS_DOMAIN ("*.oncarlos.com" copied off a DNS
+ // record) must refuse here, not surface as records reconcile skips
+ // forever. The console also refuses garbage at boot (cmd/console); this
+ // guard covers embedders that build Config directly.
+ if !provision.ValidHostShape(domain) {
+ jsonError(w, http.StatusServiceUnavailable,
+ "this console's PLATFORM_APPS_DOMAIN is not a valid domain — fix the console configuration")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ // Read WITH the etag and write guarded by it below: the domain-conflict
+ // refusal is only honest if nothing can land between this read and the
+ // write. PutManifest's upsert re-reads inside, so an operator's
+ // store-mode manifest written into that window would be silently
+ // re-homed — the exact overwrite the 409 exists to prevent.
+ m, etag, err := provision.GetManifestETag(ctx, s.cfg.Releases, acct.Sqid, app)
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ m, etag = &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. (Recorded
+ // debt: fields ADDED to schema 1 after this binary would be dropped by
+ // this read-modify-write, the same property the store-mode upsert has —
+ // an additive manifest field needs the console upgraded before members
+ // enable through it.)
+ if req.Channel != nil {
+ m.Channel = *req.Channel
+ }
+ if req.Seed != nil {
+ m.Seed = *req.Seed
+ }
+ if err := provision.PutManifestIfMatch(ctx, s.cfg.Releases, acct.Sqid, app, *m, etag); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ jsonError(w, http.StatusConflict,
+ "instance settings changed while this request was in flight — try again")
+ return
+ }
+ 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..82896501b
--- /dev/null
+++ b/internal/console/apiinstancesenable_test.go
@@ -0,0 +1,293 @@
+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)
+ }
+}
+
+// TestAPIInstanceCreateSiblingApexRefused: the account's platform subdomain
+// is a namespace every app's apex shares, so a manifest pinned there
+// (exactly what the enable door writes) makes sibling apexes valid host
+// SHAPES for the wrong app. A record at a sibling's apex would mint a real
+// route the domains pass then defers to forever — one app serving another's
+// public URL — so the create door refuses a label that names another
+// claimed app in the account, for user and app tokens both. Unclaimed
+// labels and the app's own apex stay open.
+func TestAPIInstanceCreateSiblingApexRefused(t *testing.T) {
+ fx, member := instanceFixture(t, "code-inst-sibling")
+ fx.srv.cfg.AppsDomain = "oncarlos.test"
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "marketing", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), member, `{}`); status != http.StatusOK {
+ t.Fatalf("enable = %d %s", status, out)
+ }
+ domain := fx.sqid + ".oncarlos.test"
+
+ status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member,
+ `{"host":"marketing.`+domain+`"}`)
+ if status != http.StatusConflict {
+ t.Fatalf("sibling apex create = %d %s, want 409", status, out)
+ }
+ if !strings.Contains(out, "marketing") || !strings.Contains(out, "platform apex") {
+ t.Fatalf("sibling refusal body = %s, want it to name the app and the apex", out)
+ }
+
+ // The app token meets the same wall: it authorizes titogo's records,
+ // and a sibling's apex is not one of them.
+ appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), appTok,
+ `{"host":"marketing.`+domain+`"}`); status != http.StatusConflict {
+ t.Fatalf("app-token sibling apex create = %d %s, want 409", status, out)
+ }
+
+ // The app's own apex and an unclaimed label are exactly what the door
+ // is for.
+ if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member,
+ `{"host":"titogo.`+domain+`"}`); status != http.StatusCreated {
+ t.Fatalf("own apex create = %d %s, want 201", status, out)
+ }
+ if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member,
+ `{"host":"acme.`+domain+`"}`); status != http.StatusCreated {
+ t.Fatalf("unclaimed label create = %d %s, want 201", status, out)
+ }
+}
+
+// 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/internal/provision/provision.go b/internal/provision/provision.go
index 2c3cb0838..6b149476f 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -155,18 +155,26 @@ func ValidPool(pool string) bool { return regionPoolRe.MatchString(pool) }
// truncated by json.Unmarshal (appconfig/store.go's precedent) — acting on
// a newer manifest would silently discard whatever the unknown fields meant.
func GetManifest(ctx context.Context, s release.Store, acct, app string) (*Manifest, error) {
- raw, _, err := s.Get(ctx, ManifestKey(acct, app))
+ m, _, err := GetManifestETag(ctx, s, acct, app)
+ return m, err
+}
+
+// GetManifestETag is GetManifest carrying the etag its bytes were read
+// under — for a caller that must write back GUARDED against what it read
+// (PutManifestIfMatch), not against whatever is there at write time.
+func GetManifestETag(ctx context.Context, s release.Store, acct, app string) (*Manifest, string, error) {
+ raw, etag, err := s.Get(ctx, ManifestKey(acct, app))
if err != nil {
- return nil, err
+ return nil, "", err
}
var m Manifest
if err := json.Unmarshal(raw, &m); err != nil {
- return nil, fmt.Errorf("provision: manifest %s/%s: %w", acct, app, err)
+ return nil, "", fmt.Errorf("provision: manifest %s/%s: %w", acct, app, err)
}
if m.Schema > 1 {
- return nil, fmt.Errorf("provision: manifest %s/%s: schema %d is newer than this binary understands — upgrade carlos", acct, app, m.Schema)
+ return nil, "", fmt.Errorf("provision: manifest %s/%s: schema %d is newer than this binary understands — upgrade carlos", acct, app, m.Schema)
}
- return &m, nil
+ return &m, etag, nil
}
// PutManifest publishes app's instances manifest — a CAS upsert (read
@@ -197,6 +205,24 @@ func PutManifest(ctx context.Context, s release.Store, acct, app string, m Manif
return nil
}
+// PutManifestIfMatch writes app's instances manifest guarded by the etag
+// the caller READ it under — "" meaning it read nothing, which makes the
+// write create-only. Unlike PutManifest's upsert (which re-reads inside and
+// retries, so the last writer always wins) a lost race here surfaces as
+// release.ErrCASConflict for the caller to answer: the console's
+// member-facing enable door uses this because its domain-conflict check is
+// only honest if nothing can slip in between the check and the write
+// (review 2026-08-06 — an operator's store-mode manifest landing in that
+// window must never be silently re-homed).
+func PutManifestIfMatch(ctx context.Context, s release.Store, acct, app string, m Manifest, etag string) error {
+ body, err := json.Marshal(m)
+ if err != nil {
+ return err
+ }
+ _, err = s.Put(ctx, ManifestKey(acct, app), body, etag)
+ return err
+}
+
// CreateRecord writes a new instance record, create-only: a lost race
// against a second signup for the same host comes back wrapping
// release.ErrCASConflict, never a silent overwrite of the winner's record.
diff --git a/internal/provision/provision_test.go b/internal/provision/provision_test.go
index 30f94c15b..a42cbc7c4 100644
--- a/internal/provision/provision_test.go
+++ b/internal/provision/provision_test.go
@@ -335,3 +335,43 @@ func TestDefaults(t *testing.T) {
t.Errorf("DefaultDBPath(%q, %q) = %q, want /data/h.db", "/data/", "h", got)
}
}
+
+// TestPutManifestIfMatch pins the guarded write's two properties: etag ""
+// is CREATE-ONLY (a manifest landing between a caller's read-nothing and
+// its write surfaces as the conflict it is, never an overwrite), and a
+// stale etag — the caller's read racing somebody else's update — is refused
+// the same way. This is what makes the console enable door's domain check
+// honest: PutManifest's upsert re-reads inside and the last writer wins,
+// which is exactly the wrong property when the write was authorized by
+// what the FIRST read said.
+func TestPutManifestIfMatch(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ m := Manifest{Schema: 1, App: "tito", Domain: "go.tito.io"}
+
+ if err := PutManifestIfMatch(ctx, s, "bad", "tito", m, ""); err != nil {
+ t.Fatalf("create-only put: %v", err)
+ }
+ if err := PutManifestIfMatch(ctx, s, "bad", "tito", m, ""); !errors.Is(err, release.ErrCASConflict) {
+ t.Fatalf("second create-only put: err = %v, want ErrCASConflict", err)
+ }
+
+ got, etag, err := GetManifestETag(ctx, s, "bad", "tito")
+ if err != nil || etag == "" {
+ t.Fatalf("GetManifestETag: %+v %q %v, want a manifest with an etag", got, etag, err)
+ }
+ got.Channel = "beta"
+ if err := PutManifestIfMatch(ctx, s, "bad", "tito", *got, etag); err != nil {
+ t.Fatalf("guarded update: %v", err)
+ }
+ // The first read's etag is now stale; a write still guarded by it must
+ // lose, not overwrite the update that beat it.
+ got.Channel = "edge"
+ if err := PutManifestIfMatch(ctx, s, "bad", "tito", *got, etag); !errors.Is(err, release.ErrCASConflict) {
+ t.Fatalf("stale-etag put: err = %v, want ErrCASConflict", err)
+ }
+ final, err := GetManifest(ctx, s, "bad", "tito")
+ if err != nil || final.Channel != "beta" {
+ t.Fatalf("manifest after stale write = %+v %v, want the guarded update's beta", final, err)
+ }
+}
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