carlos / platform Public

feat(console): instance provisioning API verbs + app-held instance token

The five verbs spec §7 names — list/get/create/delete instance records,
and the mint that hands an app its own credential for them. Placing an
instance becomes a create-only write to apps/<sqid>/<app>/instances/, so a
signup path holding no AWS credentials and no box access provisions one in
seconds; the agent's reconcile pass turns the record into a route.

The gate is the security boundary of the feature. Two shapes of bearer
reach it: a person's token, through the same membership-proving lookup
every CLI verb uses, or an APP token, which must match the path's account
(exact sqid — it holds no memberships to resolve a name within) and app.
Every refusal before that proof is the family's byte-identical 404, and
the rate limiter is consulted only AFTER it, so a refusal never changes
shape with how many times it is asked for. The instance budget is its own
and keyed by TOKEN: the app token is not a person, and a signup burst is
the workload, so sharing the address-keyed write budget would couple a
stolen user token's noise to the app's own signups in both directions.

Create follows §7's order exactly — manifest (absent is "instances are not
enabled", never a bucket error), host shape+suffix, region allow-list,
channel — and pool and the channel default are stamped from the MANIFEST,
which is why the request shape has no pool field to lie in. Delete
tolerates a retired manifest but never an unvalidated host: the label
check is path-traversal defence, since the host becomes a bucket key
segment here and <host>.sock / <host>.db on the box. The DNS seam (§10) is
marked in code where a multi-region deployment would write the CNAME.

view_test.go's deletableStore is fixed to honour release.Store's Delete
contract (deleting a missing key is not an error): a test double stricter
than the interface it stands in for made the idempotent-delete verb look
broken for relying on it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 3405d77a6c3ce63d6db5a09fc815ffbbd803311d parent 544ff54
4 files changed, +1099 −2
  • internal/console/apiinstances.go +396 −0
  • internal/console/apiinstances_test.go +664 −0
  • internal/console/authcli.go +29 −1
  • internal/console/view_test.go +10 −1
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
new file mode 100644
index 000000000..b05cae254
--- /dev/null
+++ b/internal/console/apiinstances.go
@@ -0,0 +1,396 @@
+// The CLI's instance provisioning API: the verbs that turn "place an
+// instance" into a data write in the deployment bucket, so a signup path
+// holding no AWS credentials and no box access can provision one in
+// seconds. The record it writes is the whole authorization; the agent's
+// reconcile pass (internal/provision) is what turns it into a route, on its
+// own box only.
+//
+// This is the ONE console family a non-human principal may speak to, which
+// is what makes the gate below the security boundary of the feature. Two
+// shapes of bearer reach it: a person's token, resolved through exactly the
+// same membership-proving lookup every other CLI verb uses (apiAccountApp),
+// or an APP token, which is bound to one account and one app at mint time
+// and must match the path it is used on EXACTLY. Every refusal before that
+// proof is the family's byte-identical 404 — an unknown account, someone
+// else's account, an unclaimed app and an app token probing a foreign path
+// are all one answer, so probing teaches nothing.
+//
+// Spec: docs/superpowers/specs/2026-08-04-instance-provisioning-design.md
+// §7 (the verbs), §8 (the credential), §10 (the DNS seam), §11 (security).
+package console
+
+import (
+ "context"
+ "errors"
+ "log"
+ "net/http"
+ "os"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// instanceStoreTimeout bounds every store round trip these handlers make,
+// the same 15s the rest of the CLI API uses: a wedged bucket costs one
+// request 15 seconds, not a console goroutine forever.
+const instanceStoreTimeout = 15 * time.Second
+
+// instanceListMax caps one list answer (spec §7): instance fleets can be
+// large, and a caller that needs more than 500 is not using this door for
+// what it is. The response says so with `truncated` rather than silently
+// serving a prefix.
+const instanceListMax = 500
+
+// apiInstancePrincipal authorizes one instance-verb request and returns the
+// principal to audit it under.
+//
+// The app token is tried FIRST, and deliberately: it is one indexed lookup,
+// a user token comes back !ok from it (CLIAppToken answers only kind='app'),
+// and trying it first means an app token never touches apiAccountApp's
+// membership machinery — which it has no memberships to satisfy anyway.
+//
+// An app token's path check is SQID-ONLY. apiAccountApp resolves the
+// account segment as a sqid or a name against the caller's own memberships;
+// an app token holds no memberships, so there is no set to resolve a name
+// within, and inventing one (resolve globally, then compare) would be a
+// name-collision oracle for a credential that never needed names. The
+// binding is exact or it is a 404.
+//
+// The returned principal is the audit actor: the user's address, or
+// "app:<sqid>/<app>" — deliberately not a bare address, so a row written by
+// an app is never read as a row written by the human who minted its token.
+func (s *Server) apiInstancePrincipal(w http.ResponseWriter, r *http.Request) (string, *Account, string, bool) {
+ if acctID, boundApp, ok := s.store.CLIAppToken(bearer(r), time.Now()); ok {
+ acct, err := s.store.AccountByID(acctID)
+ if err != nil {
+ log.Printf("console: api instances account %d: %v", acctID, err)
+ }
+ // Every one of these is the same 404. A binding whose account has
+ // since been deleted (acct nil), a binding with no app in it, and a
+ // path that simply names something else all refuse identically —
+ // and nothing here treats an absent account id or an empty app as
+ // a wildcard, which is the failure mode a "bound to nothing means
+ // bound to everything" bug would take.
+ if err != nil || acct == nil || boundApp == "" ||
+ r.PathValue("account") != acct.Sqid || r.PathValue("app") != boundApp {
+ http.NotFound(w, r)
+ return "", nil, "", false
+ }
+ return "app:" + acct.Sqid + "/" + boundApp, acct, boundApp, true
+ }
+ // Not an app token: the ordinary CLI gate, which 401s an unknown bearer
+ // and 404s an account or app this identity cannot see — including an
+ // app token presented here, since CLITokenAddress refuses those too.
+ addr, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return "", nil, "", false
+ }
+ return addr, acct, app, true
+}
+
+// instanceGate is the preamble every instance verb shares, in the order the
+// family fixed (handleAPIPromote): principal FIRST, budget second, then the
+// deployment's own readiness. The order is load-bearing — a rate limit
+// answered before the principal is proven would be a 429 where a 404
+// belongs, and "this path is rate limited" is itself the answer that an app
+// exists here.
+func (s *Server) instanceGate(w http.ResponseWriter, r *http.Request) (string, *Account, string, bool) {
+ principal, acct, app, ok := s.apiInstancePrincipal(w, r)
+ if !ok {
+ return "", nil, "", false
+ }
+ // Keyed on the TOKEN, not the address (see cliInstanceBurst): the
+ // principal here is frequently an app, which has no address of its own,
+ // and a signup burst is the workload rather than the abuse.
+ if !s.cli.instances.allow(hashToken(bearer(r)), time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, "too many instance requests — wait a minute and try again")
+ return "", nil, "", false
+ }
+ // A console with no deployment bucket configured can answer none of
+ // these. Same shape as promote's signing-key check, and in the same
+ // place: after the gate, before anything reaches for the store.
+ if s.cfg.Releases == nil {
+ jsonError(w, http.StatusServiceUnavailable,
+ "no deployment bucket on this host — the console is running without one")
+ return "", nil, "", false
+ }
+ return principal, acct, app, true
+}
+
+// instanceManifest reads the app's instances manifest and answers the
+// caller itself when it cannot be had. ok=false means a response has
+// already been written; m=nil with ok=true means "no manifest, and the
+// caller has said it can carry on without one" (delete, which must outlive
+// a retired manifest).
+//
+// The two failures are kept apart on purpose: ABSENCE is an answer — the
+// feature does not exist for this app — while a read failure is the bucket
+// being unreachable. Reporting a hiccup as "instances are not enabled"
+// would tell an app its feature had been switched off.
+func (s *Server) instanceManifest(ctx context.Context, w http.ResponseWriter, acct *Account, app string, absentOK bool) (*provision.Manifest, bool) {
+ m, err := provision.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app)
+ switch {
+ case err == nil:
+ return m, true
+ case errors.Is(err, os.ErrNotExist):
+ if absentOK {
+ return nil, true
+ }
+ // Membership (or binding) is already proven, so this may name its
+ // cause and the operator act that fixes it.
+ jsonError(w, http.StatusConflict,
+ "instances are not enabled for "+app+" — an operator writes apps/"+
+ acct.Sqid+"/"+app+"/instances.json (carlos instances enable)")
+ return nil, false
+ default:
+ log.Printf("console: api instances manifest %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not read instance settings")
+ return nil, false
+ }
+}
+
+// handleAPIInstanceList answers `carlos instances list`: this app's records,
+// capped and honest about the cap.
+func (s *Server) handleAPIInstanceList(w http.ResponseWriter, r *http.Request) {
+ _, acct, app, ok := s.instanceGate(w, r)
+ if !ok {
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ recs, truncated, err := provision.ListRecords(ctx, s.cfg.Releases, acct.Sqid, app, instanceListMax)
+ if err != nil {
+ log.Printf("console: api instances list %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not list instances")
+ return
+ }
+ if recs == nil {
+ recs = []provision.Record{}
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"instances": recs, "truncated": truncated})
+}
+
+// handleAPIInstanceGet answers one record. A host with no record is the
+// byte-identical 404 — a record's existence is not a thing to probe for
+// either, and this way "no such instance" and "no such app" read alike.
+func (s *Server) handleAPIInstanceGet(w http.ResponseWriter, r *http.Request) {
+ _, acct, app, ok := s.instanceGate(w, r)
+ if !ok {
+ return
+ }
+ host := r.PathValue("host")
+ // Shape-checked before the key builder, not after. A {host} segment
+ // arrives percent-decoded, so "..%2f..%2fchannels" reaches this handler
+ // as a path with separators in it; RecordKey would happily join it. A
+ // host that cannot name a record is simply not found — the same answer
+ // a host that names no record gets.
+ if !provision.ValidHostShape(host) {
+ http.NotFound(w, r)
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host)
+ if errors.Is(err, os.ErrNotExist) {
+ http.NotFound(w, r)
+ return
+ }
+ if err != nil {
+ log.Printf("console: api instances get %s/%s/%s: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, "could not read the instance")
+ return
+ }
+ writeJSON(w, http.StatusOK, rec)
+}
+
+// handleAPIInstanceCreate is the point of the feature: one create-only
+// write to the bucket, in spec §7's refusal order — gate, budget, manifest,
+// host, region, channel, write. Every field the record carries that policy
+// governs (pool, and the channel default) is stamped from the MANIFEST, not
+// from the request: the body below has no pool field to lie in at all.
+func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request) {
+ principal, acct, app, ok := s.instanceGate(w, r)
+ if !ok {
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ m, ok := s.instanceManifest(ctx, w, acct, app, false)
+ if !ok {
+ return
+ }
+ var req struct {
+ Host string `json:"host"`
+ Region string `json:"region"`
+ Channel string `json:"channel"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ host := strings.TrimSpace(req.Host)
+ // Path-traversal defence stated as such (spec §11): this host becomes a
+ // bucket key segment here and, on the box, the <host>.sock and
+ // <host>.db path segments. Nothing below runs until it is one label
+ // plus the app's own domain.
+ if !provision.ValidHost(host, m.Domain) {
+ jsonError(w, http.StatusBadRequest,
+ "invalid host — an instance host is one DNS label followed by ."+m.Domain)
+ return
+ }
+ region := strings.TrimSpace(req.Region)
+ if !provision.ValidRegion(region) || !instanceRegionAllowed(m.Regions, region) {
+ msg := "invalid region"
+ if len(m.Regions) > 0 {
+ msg = "region " + region + " is not one this app runs in — allowed: " + strings.Join(m.Regions, ", ")
+ }
+ jsonError(w, http.StatusBadRequest, msg)
+ return
+ }
+ channel := strings.TrimSpace(req.Channel)
+ if channel == "" {
+ // The manifest's channel, then the documented default (§3.1). An
+ // instance always names a channel: a record with an empty one would
+ // materialize a route pinned to nothing.
+ if channel = m.Channel; channel == "" {
+ channel = "stable"
+ }
+ }
+ if !release.ValidChannel(channel) {
+ jsonError(w, http.StatusBadRequest, "invalid channel name")
+ return
+ }
+ rec := provision.Record{
+ Schema: 1,
+ Host: host,
+ Slug: strings.TrimSuffix(host, "."+m.Domain),
+ App: app,
+ Account: acct.Sqid,
+ Pool: m.Pool,
+ Region: region,
+ Channel: channel,
+ // UTC, like every other timestamp written to the bucket: these
+ // records are read on boxes in other regions.
+ CreatedAt: time.Now().UTC(),
+ CreatedBy: principal,
+ }
+ if err := provision.CreateRecord(ctx, s.cfg.Releases, rec); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ // A lost race against a second signup for the same host, or a
+ // plain duplicate. Either way the winner's record stands.
+ jsonError(w, http.StatusConflict, "instance "+host+" already exists")
+ return
+ }
+ log.Printf("console: api instances create %s/%s/%s: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, "could not create the instance — try again")
+ return
+ }
+ // DNS seam (spec §10): on a single-region deployment nothing writes
+ // DNS — the app's wildcard already resolves every instance host to the
+ // edge. Multi-region wants <slug>.<domain> CNAME -> <region>.<domain>
+ // (TTL 60) written HERE, after the record write and before the
+ // response, behind an interface the deployment configures (nil =
+ // wildcard-covered). Recorded, not built.
+ s.audit(acct, app, principal, "instance-create", "created instance "+host+" (cli)")
+ writeJSON(w, http.StatusCreated, rec)
+}
+
+// handleAPIInstanceDelete retires an instance's route authorization. The
+// record is deleted; the parked database and blobs are deliberately
+// untouched (spec §3.2) — retiring a route is not destroying a customer's
+// data. Idempotent: the caller's goal is "not there", and a second delete
+// has reached it.
+func (s *Server) handleAPIInstanceDelete(w http.ResponseWriter, r *http.Request) {
+ principal, acct, app, ok := s.instanceGate(w, r)
+ if !ok {
+ return
+ }
+ host := r.PathValue("host")
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ // Deletion must outlive a retired manifest: an app that turned
+ // instances off still has records to clean up, and refusing to delete
+ // them would strand routes nothing can retire. So an absent manifest is
+ // tolerated here — but the host is still validated, by SHAPE alone when
+ // there is no domain left to check a suffix against. There is no path
+ // through this handler on which an unvalidated host reaches RecordKey.
+ m, ok := s.instanceManifest(ctx, w, acct, app, true)
+ if !ok {
+ return
+ }
+ valid := provision.ValidHostShape(host)
+ if m != nil {
+ valid = provision.ValidHost(host, m.Domain)
+ }
+ if !valid {
+ jsonError(w, http.StatusBadRequest, "invalid host")
+ return
+ }
+ if err := provision.DeleteRecord(ctx, s.cfg.Releases, acct.Sqid, app, host); err != nil {
+ log.Printf("console: api instances delete %s/%s/%s: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, "could not delete the instance — try again")
+ return
+ }
+ // DNS seam (spec §10): a multi-region deployment retires the instance's
+ // CNAME here, after the record is gone.
+ //
+ // Audited whether or not an object was actually there to remove:
+ // idempotency makes "deleted" and "already gone" indistinguishable from
+ // the store, and the fact worth recording is that this principal asked
+ // for this host to stop existing.
+ s.audit(acct, app, principal, "instance-delete", "deleted instance "+host+" (cli)")
+ writeJSON(w, http.StatusOK, map[string]any{"result": "deleted"})
+}
+
+// handleAPIInstanceToken mints the app-held credential (spec §8). Gated by
+// apiAccountApp DIRECTLY, not by the instance gate: minting is a human act,
+// and an app token cannot mint another — it meets cliUser's 401 here, the
+// same one a fabricated token meets, because CLITokenAddress does not
+// answer for app tokens at all. Budgeted on the address-keyed write
+// limiter for the same reason: the principal a mint belongs to is a person.
+func (s *Server) handleAPIInstanceToken(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
+ }
+ var req struct {
+ Label string `json:"label"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ // Same de-fanging every terminal label gets: this string is rendered on
+ // the minter's tokens page, where it is the one field a human reads to
+ // decide whether to revoke.
+ label := cleanLabel(req.Label)
+ if label == "" {
+ label = app + " instance token"
+ }
+ raw, err := s.store.NewAppToken(acct.ID, app, addr, label, time.Now())
+ if err != nil {
+ log.Printf("console: api instance token %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusInternalServerError, "could not mint the token — try again")
+ return
+ }
+ s.audit(acct, app, addr, "instance-token", "minted an instance token for "+app+" (cli)")
+ // Shown exactly once: only the hash is stored, so there is no second
+ // copy to hand out and no way to recover this one. Delivery to the app
+ // is the config-env layer, like any other app secret —
+ // `carlos env set -app <app> -secret CARLOS_INSTANCE_TOKEN=...`.
+ writeJSON(w, http.StatusCreated, map[string]any{
+ "token": raw, "account": acct.Sqid, "app": app,
+ })
+}
+
+// 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.
+func instanceRegionAllowed(allowed []string, region string) bool {
+ return len(allowed) == 0 || slices.Contains(allowed, region)
+}
diff --git a/internal/console/apiinstances_test.go b/internal/console/apiinstances_test.go
new file mode 100644
index 000000000..edb5dfcbb
--- /dev/null
+++ b/internal/console/apiinstances_test.go
@@ -0,0 +1,664 @@
+package console
+
+// The instance API (apiinstances.go): the console's provisioning verbs and
+// the app-held token that may speak to them. The properties that matter, in
+// the order they'd hurt if lost: every refusal before the principal is
+// proven is the family's byte-identical 404 (a probe learns nothing — not
+// whether an account exists, not whether an app does, not whether an app
+// token's binding is close to right), an app token is contained to its own
+// account+app and can mint nothing, no host reaches a bucket key without
+// passing the label+suffix check, and the rate limiter is consulted only
+// AFTER the gate so a 429 can never confirm what a 404 hides.
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// rawReq is the tests' one-shot request: any method, an optional bearer and
+// an optional JSON body, and the response handed back as status + headers +
+// bytes so a refusal can be compared to http.NotFound's own output field by
+// field rather than merely "looks like a 404".
+func rawReq(t *testing.T, method, u, bearer, body string) (int, http.Header, string) {
+ t.Helper()
+ var rdr io.Reader
+ if body != "" {
+ rdr = strings.NewReader(body)
+ }
+ req, err := http.NewRequest(method, u, rdr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if body != "" {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ if bearer != "" {
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ }
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+ out, err := io.ReadAll(res.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return res.StatusCode, res.Header, string(out)
+}
+
+// notFound404 is one refusal reduced to the parts a prober could read: the
+// status, the two headers http.Error sets, and the body.
+type notFound404 struct {
+ Status int
+ Type string
+ NoSniff string
+ Body string
+}
+
+// canonicalNotFound captures what the STDLIB writes, rather than typing the
+// expected bytes out here: the property under test is "identical to
+// http.NotFound", so http.NotFound is what the tests must compare against.
+func canonicalNotFound(t *testing.T) notFound404 {
+ t.Helper()
+ rec := httptest.NewRecorder()
+ http.NotFound(rec, httptest.NewRequest(http.MethodGet, "/", nil))
+ return notFound404{rec.Code, rec.Header().Get("Content-Type"),
+ rec.Header().Get("X-Content-Type-Options"), rec.Body.String()}
+}
+
+func assertNotFound(t *testing.T, what string, want notFound404, status int, h http.Header, body string) {
+ t.Helper()
+ got := notFound404{status, h.Get("Content-Type"), h.Get("X-Content-Type-Options"), body}
+ if got != want {
+ t.Errorf("%s\n got: %+v\nwant: the byte-identical %+v", what, got, want)
+ }
+}
+
+// instanceFixture is a console with titogo claimed in the ops account and a
+// MEMBER (not owner) token for it — member-level is exactly the gate these
+// verbs grant, the same one promote and the ledger API use.
+func instanceFixture(t *testing.T, code string) (*consoleFixture, string) {
+ t.Helper()
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil {
+ t.Fatal(err)
+ }
+ return fx, cliToken(t, fx, "alice@keymail.dev", code)
+}
+
+// enableInstances writes the app's manifest — the operator act (`carlos
+// instances enable`) whose ABSENCE is what makes the feature not exist.
+func enableInstances(t *testing.T, fx *consoleFixture, app string, m provision.Manifest) {
+ t.Helper()
+ if err := provision.PutManifest(context.Background(), fx.rel, release.OpsAccount, app, m); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func titoManifest() provision.Manifest {
+ return provision.Manifest{Schema: 1, App: "titogo", Domain: "go.tito.io",
+ Pool: "tito", Channel: "stable", Regions: []string{"ie"}, Seed: []string{"seed"}}
+}
+
+func instancesURL(fx *consoleFixture, acct, app string) string {
+ return fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/instances"
+}
+
+// TestAPIInstanceRefusalMatrix: every way of not being allowed here — a
+// stranger's token, an app token pointed at a neighbouring account or a
+// neighbouring app, an account that does not exist, an app that was never
+// claimed — answers with the SAME bytes http.NotFound writes. Asserted
+// against the stdlib's own output, on every verb.
+func TestAPIInstanceRefusalMatrix(t *testing.T) {
+ fx, member := instanceFixture(t, "code-inst-refusal")
+ enableInstances(t, fx, "titogo", titoManifest())
+ want := canonicalNotFound(t)
+
+ // A second account, so the app token's cross-account probe names one
+ // that genuinely exists: the refusal must not depend on that.
+ now := time.Now()
+ if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.CreateAppIn(42, "titogo", "bob@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ // An app token bound to (bab, titogo) — the honest binding.
+ appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // A member of nothing at all.
+ eve := cliToken(t, fx, "eve@keymail.dev", "code-inst-eve")
+
+ body := `{"host":"acme.go.tito.io","region":"ie"}`
+ cases := []struct {
+ what string
+ method string
+ url string
+ token string
+ body string
+ }{
+ {"non-member list", http.MethodGet, instancesURL(fx, "bab", "titogo"), eve, ""},
+ {"non-member create", http.MethodPost, instancesURL(fx, "bab", "titogo"), eve, body},
+ {"non-member get", http.MethodGet, instancesURL(fx, "bab", "titogo") + "/acme.go.tito.io", eve, ""},
+ {"non-member delete", http.MethodDelete, instancesURL(fx, "bab", "titogo") + "/acme.go.tito.io", eve, ""},
+ {"app token, foreign account", http.MethodGet, instancesURL(fx, "cdef", "titogo"), appTok, ""},
+ {"app token, foreign account create", http.MethodPost, instancesURL(fx, "cdef", "titogo"), appTok, body},
+ {"app token, foreign app", http.MethodGet, instancesURL(fx, "bab", "other"), appTok, ""},
+ {"app token, foreign app create", http.MethodPost, instancesURL(fx, "bab", "other"), appTok, body},
+ // The account segment resolves by NAME for a user token; an app
+ // token holds no memberships to resolve one against, so its own
+ // account's name is not a way in either.
+ {"app token, own account by name", http.MethodGet, instancesURL(fx, "ops", "titogo"), appTok, ""},
+ {"unknown account", http.MethodGet, instancesURL(fx, "zzzz", "titogo"), member, ""},
+ {"unclaimed app", http.MethodGet, instancesURL(fx, "bab", "ghost-app"), member, ""},
+ {"unclaimed app create", http.MethodPost, instancesURL(fx, "bab", "ghost-app"), member, body},
+ }
+ for _, c := range cases {
+ status, h, out := rawReq(t, c.method, c.url, c.token, c.body)
+ assertNotFound(t, c.what, want, status, h, out)
+ }
+
+ // None of the refused calls wrote anything.
+ if _, _, err := fx.rel.Get(context.Background(),
+ provision.RecordKey("bab", "titogo", "acme.go.tito.io")); err == nil {
+ t.Fatalf("a record exists after only refused creates")
+ }
+ if _, _, err := fx.rel.Get(context.Background(),
+ provision.RecordKey("cdef", "titogo", "acme.go.tito.io")); err == nil {
+ t.Fatalf("a record exists in the neighbouring account after only refused creates")
+ }
+}
+
+// TestAPIInstanceNoToken: no credential at all is the 401 every CLI door
+// gives, on all five routes.
+func TestAPIInstanceNoToken(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-inst-notoken")
+ for _, c := range []struct{ method, url, body string }{
+ {http.MethodGet, instancesURL(fx, "bab", "titogo"), ""},
+ {http.MethodPost, instancesURL(fx, "bab", "titogo"), `{"host":"a.go.tito.io"}`},
+ {http.MethodGet, instancesURL(fx, "bab", "titogo") + "/a.go.tito.io", ""},
+ {http.MethodDelete, instancesURL(fx, "bab", "titogo") + "/a.go.tito.io", ""},
+ {http.MethodPost, fx.ts.URL + "/api/cli/apps/bab/titogo/instance-token", `{}`},
+ } {
+ if status, _, out := rawReq(t, c.method, c.url, "", c.body); status != http.StatusUnauthorized {
+ t.Errorf("%s %s with no token = %d %s, want 401", c.method, c.url, status, out)
+ }
+ }
+}
+
+// TestAPIInstanceAppTokenContainment: the app token is contained in both
+// directions. It cannot speak to the verbs a person's token speaks to
+// (promote), it cannot mint another token, and it CAN do exactly the four
+// instance verbs on its own app.
+func TestAPIInstanceAppTokenContainment(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-inst-contain")
+ enableInstances(t, fx, "titogo", titoManifest())
+ appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Promote: an app token is unknown to CLITokenAddress, so it meets the
+ // same 401 a fabricated token does — never a 403 or a 404 that would
+ // tell it the door exists for someone.
+ if status, _, out := rawReq(t, http.MethodPost,
+ fx.ts.URL+"/api/cli/apps/bab/titogo/promote", appTok,
+ `{"version":"1.0.0","channel":"canary/a"}`); status != http.StatusUnauthorized {
+ t.Fatalf("app token on promote = %d %s, want 401", status, out)
+ }
+ // Minting is a human act: the mint door is apiAccountApp's, and
+ // apiAccountApp can never see an app token at all.
+ if status, _, out := rawReq(t, http.MethodPost,
+ fx.ts.URL+"/api/cli/apps/bab/titogo/instance-token", appTok, `{}`); status != http.StatusUnauthorized {
+ t.Fatalf("app token minting a token = %d %s, want 401", status, out)
+ }
+ // Its own app's verbs work.
+ status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), appTok,
+ `{"host":"acme.go.tito.io","region":"ie"}`)
+ if status != http.StatusCreated {
+ t.Fatalf("app token create on its own app = %d %s, want 201", status, out)
+ }
+ var rec provision.Record
+ if err := json.Unmarshal([]byte(out), &rec); err != nil {
+ t.Fatalf("create body %s: %v", out, err)
+ }
+ if rec.CreatedBy != "app:bab/titogo" {
+ t.Fatalf("created_by = %q, want the app principal app:bab/titogo", rec.CreatedBy)
+ }
+ if status, _, out := rawReq(t, http.MethodGet, instancesURL(fx, "bab", "titogo"), appTok, ""); status != http.StatusOK {
+ t.Fatalf("app token list = %d %s, want 200", status, out)
+ }
+ if status, _, out := rawReq(t, http.MethodDelete,
+ instancesURL(fx, "bab", "titogo")+"/acme.go.tito.io", appTok, ""); status != http.StatusOK {
+ t.Fatalf("app token delete = %d %s, want 200", status, out)
+ }
+}
+
+// TestAPIInstanceCreateRefusalOrder walks spec §7's order with a member
+// token: membership is already proven, so from here on refusals may name
+// their cause — and each one must be the RIGHT cause, in the right order.
+func TestAPIInstanceCreateRefusalOrder(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-create")
+ u := instancesURL(fx, "bab", "titogo")
+
+ // No manifest at all: the feature does not exist for this app.
+ status, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`)
+ if status != http.StatusConflict {
+ t.Fatalf("create with no manifest = %d %s, want 409", status, out)
+ }
+ if !strings.Contains(out, "carlos instances enable") {
+ t.Fatalf("no-manifest refusal = %s, want it to name `carlos instances enable`", out)
+ }
+
+ enableInstances(t, fx, "titogo", titoManifest())
+
+ for _, c := range []struct{ what, body string }{
+ {"bad host shape", `{"host":"-nope-.go.tito.io","region":"ie"}`},
+ {"traversal host", `{"host":"../../etc/passwd","region":"ie"}`},
+ {"wrong suffix", `{"host":"acme.evil.example","region":"ie"}`},
+ {"bare domain, no label", `{"host":"go.tito.io","region":"ie"}`},
+ {"region outside the allow-list", `{"host":"acme.go.tito.io","region":"us"}`},
+ {"malformed region", `{"host":"acme.go.tito.io","region":"IE/../x"}`},
+ {"bad channel", `{"host":"acme.go.tito.io","region":"ie","channel":"../etc"}`},
+ } {
+ status, _, out := rawReq(t, http.MethodPost, u, token, c.body)
+ if status != http.StatusBadRequest {
+ t.Errorf("%s = %d %s, want 400", c.what, status, out)
+ }
+ }
+ // Nothing above reached the bucket.
+ keys, err := fx.rel.List(context.Background(), provision.RecordsPrefix("bab", "titogo"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(keys) != 0 {
+ t.Fatalf("refused creates left %v in the bucket", keys)
+ }
+
+ // The happy path: channel defaults from the manifest, pool is stamped
+ // from it (never from the request), the record comes back whole.
+ status, _, out = rawReq(t, http.MethodPost, u, token,
+ `{"host":"acme.go.tito.io","region":"ie","pool":"attacker"}`)
+ if status != http.StatusCreated {
+ t.Fatalf("create = %d %s, want 201", status, out)
+ }
+ var rec provision.Record
+ if err := json.Unmarshal([]byte(out), &rec); err != nil {
+ t.Fatalf("create body %s: %v", out, err)
+ }
+ if rec.Schema != 1 || rec.Host != "acme.go.tito.io" || rec.Slug != "acme" ||
+ rec.App != "titogo" || rec.Account != "bab" || rec.Pool != "tito" ||
+ rec.Region != "ie" || rec.Channel != "stable" {
+ t.Fatalf("record = %+v", rec)
+ }
+ if rec.CreatedBy != "alice@keymail.dev" {
+ t.Fatalf("created_by = %q, want the member's address", rec.CreatedBy)
+ }
+
+ // It is in the bucket at exactly the key the reconciler will look for.
+ raw, _, err := fx.rel.Get(context.Background(), provision.RecordKey("bab", "titogo", "acme.go.tito.io"))
+ if err != nil {
+ t.Fatalf("stored record: %v", err)
+ }
+ var stored provision.Record
+ if err := json.Unmarshal(raw, &stored); err != nil {
+ t.Fatal(err)
+ }
+ if stored.CreatedBy != "alice@keymail.dev" || stored.Host != "acme.go.tito.io" {
+ t.Fatalf("stored record = %+v", stored)
+ }
+ if stored.CreatedAt.IsZero() {
+ t.Fatalf("stored record has no created_at: %+v", stored)
+ }
+
+ // The same host twice is a lost race, not a silent overwrite.
+ status, _, out = rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`)
+ if status != http.StatusConflict {
+ t.Fatalf("duplicate create = %d %s, want 409", status, out)
+ }
+ if !strings.Contains(out, "already exists") {
+ t.Fatalf("duplicate refusal = %s, want it to say the instance already exists", out)
+ }
+
+ // An explicit channel is honoured over the manifest's default.
+ status, _, out = rawReq(t, http.MethodPost, u, token,
+ `{"host":"beta.go.tito.io","region":"ie","channel":"canary/beta"}`)
+ if status != http.StatusCreated {
+ t.Fatalf("create with channel = %d %s, want 201", status, out)
+ }
+ if err := json.Unmarshal([]byte(out), &rec); err != nil {
+ t.Fatal(err)
+ }
+ if rec.Channel != "canary/beta" {
+ t.Fatalf("channel = %q, want the requested canary/beta", rec.Channel)
+ }
+}
+
+// TestAPIInstanceCreateEmptyRegionAllowList: an empty allow-list means any
+// shape-valid region, per §3.1 — including the unconstrained empty one.
+func TestAPIInstanceCreateEmptyRegionAllowList(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-anyregion")
+ m := titoManifest()
+ m.Regions = nil
+ m.Channel = ""
+ enableInstances(t, fx, "titogo", m)
+ u := instancesURL(fx, "bab", "titogo")
+
+ status, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"us-east"}`)
+ if status != http.StatusCreated {
+ t.Fatalf("create with an open allow-list = %d %s, want 201", status, out)
+ }
+ var rec provision.Record
+ if err := json.Unmarshal([]byte(out), &rec); err != nil {
+ t.Fatal(err)
+ }
+ // No manifest channel either: stable is the documented default.
+ if rec.Channel != "stable" {
+ t.Fatalf("channel = %q, want the stable default", rec.Channel)
+ }
+ if status, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"beta.go.tito.io"}`); status != http.StatusCreated {
+ t.Fatalf("create with no region = %d %s, want 201", status, out)
+ }
+}
+
+// TestAPIInstanceListAndGet: what was created lists and reads back, and a
+// host that isn't there is the byte-identical 404 — a record's existence is
+// not something to probe for either.
+func TestAPIInstanceListAndGet(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-list")
+ enableInstances(t, fx, "titogo", titoManifest())
+ u := instancesURL(fx, "bab", "titogo")
+ for _, h := range []string{"acme.go.tito.io", "beta.go.tito.io"} {
+ if status, _, out := rawReq(t, http.MethodPost, u, token,
+ fmt.Sprintf(`{"host":%q,"region":"ie"}`, h)); status != http.StatusCreated {
+ t.Fatalf("create %s = %d %s", h, status, out)
+ }
+ }
+
+ status, _, out := rawReq(t, http.MethodGet, u, token, "")
+ if status != http.StatusOK {
+ t.Fatalf("list = %d %s", status, out)
+ }
+ var list struct {
+ Instances []provision.Record `json:"instances"`
+ Truncated bool `json:"truncated"`
+ }
+ if err := json.Unmarshal([]byte(out), &list); err != nil {
+ t.Fatalf("list body %s: %v", out, err)
+ }
+ if len(list.Instances) != 2 || list.Truncated {
+ t.Fatalf("list = %+v, want both records and truncated false", list)
+ }
+ if list.Instances[0].Host != "acme.go.tito.io" || list.Instances[1].Host != "beta.go.tito.io" {
+ t.Fatalf("list order = %+v, want host order", list.Instances)
+ }
+
+ status, _, out = rawReq(t, http.MethodGet, u+"/acme.go.tito.io", token, "")
+ if status != http.StatusOK {
+ t.Fatalf("get = %d %s", status, out)
+ }
+ var rec provision.Record
+ if err := json.Unmarshal([]byte(out), &rec); err != nil {
+ t.Fatal(err)
+ }
+ if rec.Host != "acme.go.tito.io" || rec.Slug != "acme" {
+ t.Fatalf("get = %+v", rec)
+ }
+
+ want := canonicalNotFound(t)
+ status, h, out := rawReq(t, http.MethodGet, u+"/missing.go.tito.io", token, "")
+ assertNotFound(t, "get of a host with no record", want, status, h, out)
+ // A hostile host never reaches the key builder: same 404, nothing read.
+ status, h, out = rawReq(t, http.MethodGet, u+"/..%2f..%2fchannels", token, "")
+ assertNotFound(t, "get of a traversal host", want, status, h, out)
+}
+
+// TestAPIInstanceDelete: deletion is idempotent (the caller's goal is "not
+// there"), survives the manifest being retired, and still refuses a host
+// that isn't one.
+func TestAPIInstanceDelete(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-delete")
+ enableInstances(t, fx, "titogo", titoManifest())
+ u := instancesURL(fx, "bab", "titogo")
+ if status, _, out := rawReq(t, http.MethodPost, u, token,
+ `{"host":"acme.go.tito.io","region":"ie"}`); status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+
+ // A hostile host is refused before the key builder, and deletes nothing.
+ if status, _, out := rawReq(t, http.MethodDelete, u+"/..%2f..%2finstances.json", token, ""); status != http.StatusBadRequest {
+ t.Fatalf("delete of a traversal host = %d %s, want 400", status, out)
+ }
+ if _, _, err := fx.rel.Get(context.Background(), provision.ManifestKey("bab", "titogo")); err != nil {
+ t.Fatalf("the manifest was destroyed by a traversal delete: %v", err)
+ }
+
+ status, _, out := rawReq(t, http.MethodDelete, u+"/acme.go.tito.io", token, "")
+ if status != http.StatusOK || !strings.Contains(out, "deleted") {
+ t.Fatalf("delete = %d %s, want 200 deleted", status, out)
+ }
+ if _, _, err := fx.rel.Get(context.Background(),
+ provision.RecordKey("bab", "titogo", "acme.go.tito.io")); err == nil {
+ t.Fatalf("the record survived its delete")
+ }
+ // Again: still 200. "Already gone" is the goal, reached.
+ if status, _, out := rawReq(t, http.MethodDelete, u+"/acme.go.tito.io", token, ""); status != http.StatusOK {
+ t.Fatalf("second delete = %d %s, want an idempotent 200", status, out)
+ }
+
+ // Deletion outlives the manifest: an app that retired instances must
+ // still be able to clean up the records it left behind.
+ if status, _, out := rawReq(t, http.MethodPost, u, token,
+ `{"host":"beta.go.tito.io","region":"ie"}`); status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+ if err := fx.rel.Delete(context.Background(), provision.ManifestKey("bab", "titogo")); err != nil {
+ t.Fatal(err)
+ }
+ if status, _, out := rawReq(t, http.MethodDelete, u+"/beta.go.tito.io", token, ""); status != http.StatusOK {
+ t.Fatalf("delete with the manifest retired = %d %s, want 200", status, out)
+ }
+ // And the shape check still applies with no domain to check against.
+ if status, _, out := rawReq(t, http.MethodDelete, u+"/..%2f..%2fchannels", token, ""); status != http.StatusBadRequest {
+ t.Fatalf("traversal delete with no manifest = %d %s, want 400", status, out)
+ }
+}
+
+// TestAPIInstanceTokenMintEndToEnd is the whole app-token story: a member
+// mints one, the raw token is shown exactly once, and it then provisions on
+// its own — and every mutation left an audit row naming its principal.
+func TestAPIInstanceTokenMintEndToEnd(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-mint")
+ enableInstances(t, fx, "titogo", titoManifest())
+
+ status, _, out := rawReq(t, http.MethodPost, fx.ts.URL+"/api/cli/apps/bab/titogo/instance-token",
+ token, `{"label":"tito signup"}`)
+ if status != http.StatusCreated {
+ t.Fatalf("mint = %d %s, want 201", status, out)
+ }
+ var minted struct {
+ Token string `json:"token"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ }
+ if err := json.Unmarshal([]byte(out), &minted); err != nil {
+ t.Fatalf("mint body %s: %v", out, err)
+ }
+ if minted.Token == "" || minted.Account != "bab" || minted.App != "titogo" {
+ t.Fatalf("mint = %+v, want a token bound to bab/titogo", minted)
+ }
+
+ // The minted token provisions, and nothing else.
+ u := instancesURL(fx, "bab", "titogo")
+ if status, _, out := rawReq(t, http.MethodPost, u, minted.Token,
+ `{"host":"acme.go.tito.io","region":"ie"}`); status != http.StatusCreated {
+ t.Fatalf("create with the minted token = %d %s, want 201", status, out)
+ }
+ if status, _, out := rawReq(t, http.MethodDelete, u+"/acme.go.tito.io", minted.Token, ""); status != http.StatusOK {
+ t.Fatalf("delete with the minted token = %d %s, want 200", status, out)
+ }
+
+ // The audit trail: three actions, each suffixed " (cli)", the create and
+ // the delete attributed to the APP principal and the mint to the human.
+ events, err := fx.st.Events(OpsAccountID, "titogo", 50)
+ if err != nil {
+ t.Fatal(err)
+ }
+ seen := map[string]string{}
+ for _, e := range events {
+ seen[e.Action] = e.Address
+ if !strings.HasSuffix(e.Detail, " (cli)") {
+ t.Errorf("audit %s detail = %q, want the family's \" (cli)\" suffix", e.Action, e.Detail)
+ }
+ }
+ if seen["instance-token"] != "alice@keymail.dev" {
+ t.Errorf("instance-token actor = %q, want the minting member", seen["instance-token"])
+ }
+ if seen["instance-create"] != "app:bab/titogo" {
+ t.Errorf("instance-create actor = %q, want app:bab/titogo", seen["instance-create"])
+ }
+ if seen["instance-delete"] != "app:bab/titogo" {
+ t.Errorf("instance-delete actor = %q, want app:bab/titogo", seen["instance-delete"])
+ }
+
+ // The minted credential lists under the human who minted it — which is
+ // what makes revoking it possible at all.
+ toks, err := fx.st.CLITokens("alice@keymail.dev")
+ if err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, tk := range toks {
+ if tk.Kind == "app" {
+ found = true
+ if tk.App != "titogo" {
+ t.Errorf("app token row = %+v, want it bound to titogo", tk)
+ }
+ }
+ }
+ if !found {
+ t.Fatalf("the minted app token does not list under its minter: %+v", toks)
+ }
+}
+
+// TestAPIInstanceRateLimit: the instance budget is its own, keyed by token,
+// and — the property that matters — it is consulted only AFTER the
+// principal gate, so a 429 can never confirm what the 404 hides.
+func TestAPIInstanceRateLimit(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-ratelimit")
+ enableInstances(t, fx, "titogo", titoManifest())
+ u := instancesURL(fx, "bab", "titogo")
+
+ for i := 0; i < cliInstanceBurst; i++ {
+ body := fmt.Sprintf(`{"host":"host%d.go.tito.io","region":"ie"}`, i)
+ if status, _, out := rawReq(t, http.MethodPost, u, token, body); status != http.StatusCreated {
+ t.Fatalf("create %d = %d %s, want 201 inside the budget", i, status, out)
+ }
+ }
+ if status, _, out := rawReq(t, http.MethodPost, u, token,
+ `{"host":"over.go.tito.io","region":"ie"}`); status != http.StatusTooManyRequests {
+ t.Fatalf("create past the budget = %d %s, want 429", status, out)
+ }
+ // A stranger's refusal never changes shape, however many times they
+ // ask. This is the gate-before-budget ordering asserted from outside:
+ // past the burst, a limiter consulted FIRST would start answering 429
+ // where the 404 belongs — a different answer on the same door, which is
+ // a difference a prober can measure and a refusal that has stopped
+ // being byte-identical to every other refusal here.
+ eve := cliToken(t, fx, "eve@keymail.dev", "code-inst-rl-eve")
+ want := canonicalNotFound(t)
+ for i := 0; i < cliInstanceBurst+2; i++ {
+ status, h, out := rawReq(t, http.MethodPost, u, eve, `{"host":"x.go.tito.io","region":"ie"}`)
+ assertNotFound(t, fmt.Sprintf("non-member probe %d", i), want, status, h, out)
+ if t.Failed() {
+ break
+ }
+ }
+ // The write budget minting uses is a different one: the member above
+ // spent the instance budget, not this.
+ if status, _, out := rawReq(t, http.MethodPost, fx.ts.URL+"/api/cli/apps/bab/titogo/instance-token",
+ token, `{}`); status != http.StatusCreated {
+ t.Fatalf("mint after spending the instance budget = %d %s, want 201", status, out)
+ }
+}
+
+// TestAPIInstanceNoStore: a console running without a deployment bucket
+// answers 503 — the shape promote's key check has — rather than panicking
+// on a nil store.
+func TestAPIInstanceNoStore(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-nostore")
+ fx.srv.cfg.Releases = nil
+ u := instancesURL(fx, "bab", "titogo")
+ for _, c := range []struct{ what, method, url, body string }{
+ {"list", http.MethodGet, u, ""},
+ {"create", http.MethodPost, u, `{"host":"acme.go.tito.io","region":"ie"}`},
+ {"get", http.MethodGet, u + "/acme.go.tito.io", ""},
+ {"delete", http.MethodDelete, u + "/acme.go.tito.io", ""},
+ } {
+ if status, _, out := rawReq(t, c.method, c.url, token, c.body); status != http.StatusServiceUnavailable {
+ t.Errorf("%s with no bucket = %d %s, want 503", c.what, status, out)
+ }
+ }
+}
+
+// unreadableStore is a bucket whose reads fail — distinct from a MISSING
+// manifest, which is an answer ("instances are not enabled") rather than a
+// failure. Conflating the two would tell an app its feature was switched
+// off every time the bucket hiccuped.
+type unreadableStore struct{ release.Store }
+
+func (u unreadableStore) Get(context.Context, string) ([]byte, string, error) {
+ return nil, "", errors.New(brokenStoreErr)
+}
+
+// TestAPIInstanceStoreFailure: a bucket that cannot be read or written is a
+// 502, and its error text — bucket names, request ids — never reaches the
+// caller. A read failure is NOT reported as "instances are not enabled".
+func TestAPIInstanceStoreFailure(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-broken")
+ enableInstances(t, fx, "titogo", titoManifest())
+ u := instancesURL(fx, "bab", "titogo")
+
+ fx.srv.cfg.Releases = brokenStore{fx.rel}
+ status, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`)
+ if status != http.StatusBadGateway {
+ t.Fatalf("create against an unwritable bucket = %d %s, want 502", status, out)
+ }
+ if strings.Contains(out, "carlos-prod-secret-bucket") || strings.Contains(out, "ABC123") {
+ t.Fatalf("refusal leaks the store's error text: %s", out)
+ }
+
+ fx.srv.cfg.Releases = unreadableStore{fx.rel}
+ status, _, out = rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`)
+ if status != http.StatusBadGateway {
+ t.Fatalf("create against an unreadable bucket = %d %s, want 502", status, out)
+ }
+ if strings.Contains(out, "instances enable") {
+ t.Fatalf("a read failure was reported as the feature being disabled: %s", out)
+ }
+ status, _, out = rawReq(t, http.MethodDelete, u+"/acme.go.tito.io", token, "")
+ if status != http.StatusBadGateway {
+ t.Fatalf("delete against an unreadable bucket = %d %s, want 502", status, out)
+ }
+ if strings.Contains(out, "carlos-prod-secret-bucket") {
+ t.Fatalf("delete refusal leaks the store's error text: %s", out)
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 60db52c59..34102a57e 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -76,6 +76,17 @@ const (
// legitimate use while making a stolen token's usefulness for flooding
// storage negligible.
cliShipBurst = 10
+
+ // cliInstanceBurst bounds the instance verbs (apiinstances.go) per
+ // TOKEN within the ordinary minute window. Keyed by token hash, not by
+ // address, because the caller here is usually not a person at all: an
+ // app token holds no identity to charge a person's budget against, and
+ // a burst of signups — several instances created in one minute — is the
+ // legitimate workload this API exists for. Sharing the address-keyed
+ // write budget would couple the two in both directions: a stolen user
+ // token's noise would starve the app's own signups, and a busy signup
+ // hour would refuse its minter's promotes.
+ cliInstanceBurst = 60
)
// rateLimiter is a fixed-window counter, keyed by whatever the caller thinks
@@ -140,7 +151,7 @@ func (l *rateLimiter) allow(key string, now time.Time) bool {
// cliLimits is the set of limiters the CLI endpoints share, hung off the
// Server so a test gets a fresh set per fixture.
type cliLimits struct {
- start, poll, pollIP, guessAddr, guessIP, write, ship *rateLimiter
+ start, poll, pollIP, guessAddr, guessIP, write, ship, instances *rateLimiter
}
func newCLILimits() *cliLimits {
@@ -158,6 +169,11 @@ func newCLILimits() *cliLimits {
// than address (see handleAPIShip) — an upload is a different shape
// of cost (bandwidth, storage) from the JSON mutations write covers.
ship: newRateLimiter(cliShipBurst, cliShipWindow),
+ // The instance verbs' own budget, keyed by TOKEN (see
+ // cliInstanceBurst and apiinstances.go): the app token that does
+ // most of this work is not a person, so the address-keyed write
+ // budget is the wrong principal for it in both directions.
+ instances: newRateLimiter(cliInstanceBurst, cliRateWindow),
}
}
@@ -189,6 +205,18 @@ func (s *Server) registerCLIRoutes() {
s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/ledgers/{ledger}/head", s.handleAPILedgerHead)
s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/append", s.handleAPILedgerAppend)
s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/blobs", s.handleAPILedgerBlob)
+ // The instance provisioning API (apiinstances.go) — the only door onto
+ // instances/ from outside a box, and the only one an APP's own token
+ // may speak to. `instance-token` is a literal segment beside the
+ // {host} wildcards; the mux prefers a literal regardless of
+ // registration order, and it deliberately sits under a DIFFERENT
+ // prefix from instances/{host} so a host can never be mistaken for the
+ // mint verb.
+ 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)
+ s.mux.HandleFunc("DELETE /api/cli/apps/{account}/{app}/instances/{host}", s.handleAPIInstanceDelete)
+ s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/instance-token", s.handleAPIInstanceToken)
}
// clientIP is who the rate limiter counts against.
diff --git a/internal/console/view_test.go b/internal/console/view_test.go
index c93b651b2..76b22f46a 100644
--- a/internal/console/view_test.go
+++ b/internal/console/view_test.go
@@ -31,7 +31,16 @@ func (d *deletableStore) Delete(_ context.Context, key string) error {
return fmt.Errorf("deletablestore: invalid key %q", key)
}
}
- return os.Remove(filepath.Join(d.root, filepath.FromSlash(key)))
+ // Idempotent, like every real Store's Delete (release.Store's contract,
+ // store.go: "deleting a missing key is not an error"). A double that is
+ // stricter than the interface it stands in for makes callers look
+ // broken for relying on the contract — the instance delete verb, whose
+ // whole promise is that a second delete still answers 200, found this
+ // the hard way.
+ if err := os.Remove(filepath.Join(d.root, filepath.FromSlash(key))); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ return nil
}
// buildFixtures makes a little platform: a registry with routes for apps