settings-2 task 6: the console's authorize door (person-only, root-signed)
GET and POST /api/cli/apps/{account}/{app}/releases/{version}/authorize.
GET shows a build's manifest and artifact digests; POST signs a root-key
promotion record for exactly the digest the person confirmed. Both are
closed doors (svcrole.Set{}). The no-key and write-failed bodies come from
internal/updatewords (R8). The host-claim census gets a door fixture for
the POST and a read probe for the GET (R4); closedDoors and claim-routes
name both routes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+215
−41
internal/console/apiauthorize.go+74 −0internal/console/apiauthorize_test.go+46 −0internal/console/authcli.go+5 −0internal/console/claim_invariant_fixtures_test.go+77 −38internal/console/claim_invariant_probes_read_test.go+3 −1internal/console/svcroutes_test.go+8 −2internal/console/testdata/claim-routes.txt+2 −0
diff --git a/internal/console/apiauthorize.go b/internal/console/apiauthorize.go| new file mode 100644 |
| index 000000000..8a4c642be |
| --- /dev/null |
| +++ b/internal/console/apiauthorize.go |
| @@ -0,0 +1,74 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "log" |
| + "net/http" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/updatewords" |
| +) |
| + |
| +// handleAPIAuthorize is `carlos releases authorize` (settings spec §4.1). GET |
| +// shows the digests; POST signs a promotion record for exactly the digest the |
| +// person confirmed, if the manifest still hashes to it. The console signs |
| +// with its root key for an app member. Person-only: a credential the app |
| +// holds must not vouch for builds. |
| +func (s *Server) handleAPIAuthorize(w http.ResponseWriter, r *http.Request) { |
| + addr, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + version := r.PathValue("version") |
| + if !release.ValidVersion(version) { |
| + jsonError(w, http.StatusBadRequest, "invalid version") |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) |
| + defer cancel() |
| + plan, err := release.PlanAuthorize(ctx, s.cfg.Releases, acct.Sqid, app, version) |
| + if err != nil { |
| + jsonError(w, http.StatusNotFound, err.Error()) |
| + return |
| + } |
| + if r.Method == http.MethodGet { |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "version": plan.Version, "manifest_digest": plan.ManifestDigest, "artifacts": plan.Artifacts, |
| + }) |
| + return |
| + } |
| + var req struct { |
| + ManifestDigest string `json:"manifest_digest"` |
| + } |
| + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil || req.ManifestDigest != plan.ManifestDigest { |
| + jsonError(w, http.StatusConflict, release.ErrAuthorizeChanged.Error()) |
| + return |
| + } |
| + if len(s.cfg.Key) == 0 { |
| + jsonError(w, http.StatusServiceUnavailable, updatewords.Text("settings.authorize.no-key")) |
| + return |
| + } |
| + already, err := release.RootSigner(s.cfg.Key).Authorize(ctx, s.cfg.Releases, plan, addr) |
| + switch { |
| + case errors.Is(err, release.ErrAuthorizeChanged): |
| + jsonError(w, http.StatusConflict, err.Error()) |
| + return |
| + case err != nil: |
| + log.Printf("console: api authorize %s/%s %s: %v", acct.Sqid, app, version, err) |
| + jsonError(w, http.StatusBadGateway, updatewords.Text("settings.authorize.write-failed")) |
| + return |
| + } |
| + result := "authorized" |
| + if already { |
| + result = "already" |
| + } else { |
| + s.auditActivity(acct, app, addr, Activity{ |
| + Action: "authorize", TargetType: "release", Target: version, |
| + Detail: "signed a promotion record for " + version + " (manifest " + plan.ManifestDigest[:12] + ") (cli)", |
| + }) |
| + } |
| + writeJSON(w, http.StatusOK, map[string]string{"result": result}) |
| +} |
diff --git a/internal/console/apiauthorize_test.go b/internal/console/apiauthorize_test.go| new file mode 100644 |
| index 000000000..fc4aaa1c4 |
| --- /dev/null |
| +++ b/internal/console/apiauthorize_test.go |
| @@ -0,0 +1,46 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestAuthorizeDoorShowsThenSigns(t *testing.T) { |
| + fx := newDigestsFixture(t) |
| + if err := fx.store.Delete(context.Background(), release.PromotionRecordKey("bab", "km", "v1")); err != nil { |
| + t.Fatal(err) |
| + } |
| + st, out := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/releases/v1/authorize", fx.member) |
| + if st != http.StatusOK { |
| + t.Fatalf("plan: %d %s", st, out) |
| + } |
| + var plan struct { |
| + ManifestDigest string `json:"manifest_digest"` |
| + } |
| + if err := json.Unmarshal([]byte(out), &plan); err != nil || plan.ManifestDigest == "" { |
| + t.Fatalf("plan body %s: %v", out, err) |
| + } |
| + st, out = postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/releases/v1/authorize", map[string]string{"manifest_digest": "00"}, fx.member) |
| + if st != http.StatusConflict { |
| + t.Fatalf("a confirmation for other bytes: %d %s", st, out) |
| + } |
| + st, out = postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/releases/v1/authorize", map[string]string{"manifest_digest": plan.ManifestDigest}, fx.member) |
| + if st != http.StatusOK { |
| + t.Fatalf("sign: %d %s", st, out) |
| + } |
| + if rec, err := release.ReadPromotionRecord(context.Background(), fx.store, fx.pub, "bab", "km", "v1"); err != nil || !rec.Authorized { |
| + t.Fatalf("record = %+v, %v", rec, err) |
| + } |
| +} |
| + |
| +func TestAuthorizeDoorRefusesServiceCredentials(t *testing.T) { |
| + fx := newDigestsFixture(t) |
| + st, _ := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/releases/v1/authorize", fx.serviceToken) |
| + if st != http.StatusNotFound && st != http.StatusForbidden { |
| + t.Fatalf("a service credential reached authorize: %d", st) |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index cd717aa4f..8dc10dc91 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -528,6 +528,11 @@ func (s *Server) registerCLIRoutes() { |
| // manifest digests the console then signs into the channel's pointer, so |
| // no credential reaches it. |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/channels/resign", svcrole.Set{}, s.handleAPIChannelResign) |
| + // `carlos releases authorize` (settings spec §4.1): GET shows a build's |
| + // digests, POST signs a promotion record for the digest a person |
| + // confirmed. A credential the app holds never vouches for a build. |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/releases/{version}/authorize", svcrole.Set{}, s.handleAPIAuthorize) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/releases/{version}/authorize", svcrole.Set{}, s.handleAPIAuthorize) |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/releases", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIShip) |
| // `carlos restart` (issue #113): a member's "cycle this app's processes" |
| // with no version and no config to push — the same write-door class as |
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go| index 1763d515a..5425428da 100644 |
| --- a/internal/console/claim_invariant_fixtures_test.go |
| +++ b/internal/console/claim_invariant_fixtures_test.go |
| @@ -21,6 +21,7 @@ package console |
| import ( |
| "context" |
| "encoding/json" |
| + "errors" |
| "net/http" |
| "net/url" |
| "strconv" |
| @@ -238,6 +239,22 @@ func (cx *claimCase) resignPlan(t *testing.T, channel string) { |
| cx.vars["digest"], cx.vars["history_digests"] = plan.Digest, string(hd) |
| } |
| +// authorizePlan deletes the promotion record kass's v1 promotion wrote and |
| +// keeps the authorize plan's digest in vars, as `carlos releases authorize` |
| +// shows it before the person confirms. |
| +func (cx *claimCase) authorizePlan(t *testing.T) { |
| + t.Helper() |
| + ctx := context.Background() |
| + if err := cx.rel.Delete(ctx, release.PromotionRecordKey("bab", "kass", "v1")); err != nil { |
| + t.Fatalf("clear kass v1's promotion record: %v", err) |
| + } |
| + plan, err := release.PlanAuthorize(ctx, cx.rel, "bab", "kass", "v1") |
| + if err != nil { |
| + t.Fatalf("stage an authorize plan: %v", err) |
| + } |
| + cx.vars["manifest_digest"] = plan.ManifestDigest |
| +} |
| + |
| func (cx *claimCase) trash(t *testing.T, app string) { |
| t.Helper() |
| cx.mustAPI(t, http.MethodPost, "/api/cli/apps/bab/"+app+"/delete", "", http.StatusOK) |
| @@ -682,44 +699,46 @@ var claimFixtures = map[string]claimFixture{ |
| "route GET /api/cli/apps/{account}/{app}/regions": claimRead("reads armed regions"), |
| "route GET /api/cli/apps/{account}/{app}/relay": claimRead("reads relay status and heartbeats"), |
| "route GET /api/cli/apps/{account}/{app}/releases": claimRead("lists releases"), |
| - "route GET /api/cli/apps/{account}/{app}/retention": claimRead("reads the retention policy"), |
| - "route GET /api/cli/apps/{account}/{app}/routes": claimRead("lists registry routes"), |
| - "route GET /api/cli/apps/{account}/{app}/schedules": claimRead("lists schedules and their box status"), |
| - "route GET /api/cli/apps/{account}/{app}/sidecars": claimRead("lists sidecar records"), |
| - "route GET /api/cli/apps/{account}/{app}/sidecars/{name}": claimRead("reads one sidecar record"), |
| - "route GET /api/cli/apps/{account}/{app}/store": claimRead("reads the store record (never a secret)"), |
| - "route GET /api/cli/apps/{account}/{app}/store/scan": claimRead("reads scanning status"), |
| - "route GET /api/cli/apps/{account}/{app}/tags": claimRead("lists release tags"), |
| - "route GET /api/cli/apps/{account}/{app}/tags/{version}": claimRead("reads one tag"), |
| - "route GET /api/cli/apps/{account}/{app}/version-target": claimRead("reads the version target"), |
| - "route GET /api/cli/fleets/{account}": claimRead("lists an account's fleets"), |
| - "route GET /api/cli/services/{account}": claimRead("lists service credentials"), |
| - "route GET /api/cli/status/instances": claimRead("operator live tenancy view"), |
| - "route GET /api/cli/status/layout": claimRead("operator layout view"), |
| - "route GET /api/cli/store/approvals": claimRead("operator entitlement queue"), |
| - "route GET /api/cli/system/antivirus": claimRead("reads the deployment scanning setting"), |
| - "route GET /api/cli/system/geoip": claimRead("operator view of the GeoIP pointer, box heartbeats and feed status (control/geoip/, status/, edge files); writes nothing"), |
| - "route GET /api/cli/system/jobs/{id}": claimRead("reads a system update job"), |
| - "route GET /api/cli/system/layout": claimRead("reads the deployment layout intent"), |
| - "route GET /api/cli/system/pointer-digests": claimRead("operator report of each account's pointer digest marker and the channels whose head has no digest; writes nothing"), |
| - "route GET /api/cli/system/status": claimRead("reads platform release status"), |
| - "route GET /apps": claimRead("legacy redirect into the account"), |
| - "route GET /apps/{app}": claimRead("legacy redirect into the account"), |
| - "route GET /auth/callback": claimNonApp("completes sign-in: session, user and possibly a person's first account"), |
| - "route GET /auth/cli": claimRead("renders the device-code approval page"), |
| - "route GET /auth/cli/tokens": claimRead("redirects to /settings"), |
| - "route GET /auth/cli/whoami": claimRead("names the bearer's principal"), |
| - "route GET /auth/confirm": claimNonApp("consumes a sign-in confirmation link: confirmations and sessions"), |
| - "route GET /auth/signin": claimNonApp("starts an OAuth flow: the auth cookie and its PKCE state"), |
| - "route GET /fleet": claimRead("operator fleet view"), |
| - "route GET /fleet/economics": claimRead("operator economics view"), |
| - "route GET /fleet/economics/accounts": claimRead("operator per-account economics table"), |
| - "route GET /fleet/metrics": claimRead("operator box metrics"), |
| - "route GET /fleet/store": claimRead("operator entitlement queue page"), |
| - "route GET /healthz": claimRead("answers ok and the build"), |
| - "route GET /invite/{token}": claimRead("shows an invitation; joining is the POST"), |
| - "route GET /settings": claimRead("personal settings: the caller's own tokens"), |
| - "route GET /{$}": claimRead("the front door: redirects or renders sign-in"), |
| + "route GET /api/cli/apps/{account}/{app}/releases/{version}/authorize": claimRead( |
| + "reads one build's manifest digests"), |
| + "route GET /api/cli/apps/{account}/{app}/retention": claimRead("reads the retention policy"), |
| + "route GET /api/cli/apps/{account}/{app}/routes": claimRead("lists registry routes"), |
| + "route GET /api/cli/apps/{account}/{app}/schedules": claimRead("lists schedules and their box status"), |
| + "route GET /api/cli/apps/{account}/{app}/sidecars": claimRead("lists sidecar records"), |
| + "route GET /api/cli/apps/{account}/{app}/sidecars/{name}": claimRead("reads one sidecar record"), |
| + "route GET /api/cli/apps/{account}/{app}/store": claimRead("reads the store record (never a secret)"), |
| + "route GET /api/cli/apps/{account}/{app}/store/scan": claimRead("reads scanning status"), |
| + "route GET /api/cli/apps/{account}/{app}/tags": claimRead("lists release tags"), |
| + "route GET /api/cli/apps/{account}/{app}/tags/{version}": claimRead("reads one tag"), |
| + "route GET /api/cli/apps/{account}/{app}/version-target": claimRead("reads the version target"), |
| + "route GET /api/cli/fleets/{account}": claimRead("lists an account's fleets"), |
| + "route GET /api/cli/services/{account}": claimRead("lists service credentials"), |
| + "route GET /api/cli/status/instances": claimRead("operator live tenancy view"), |
| + "route GET /api/cli/status/layout": claimRead("operator layout view"), |
| + "route GET /api/cli/store/approvals": claimRead("operator entitlement queue"), |
| + "route GET /api/cli/system/antivirus": claimRead("reads the deployment scanning setting"), |
| + "route GET /api/cli/system/geoip": claimRead("operator view of the GeoIP pointer, box heartbeats and feed status (control/geoip/, status/, edge files); writes nothing"), |
| + "route GET /api/cli/system/jobs/{id}": claimRead("reads a system update job"), |
| + "route GET /api/cli/system/layout": claimRead("reads the deployment layout intent"), |
| + "route GET /api/cli/system/pointer-digests": claimRead("operator report of each account's pointer digest marker and the channels whose head has no digest; writes nothing"), |
| + "route GET /api/cli/system/status": claimRead("reads platform release status"), |
| + "route GET /apps": claimRead("legacy redirect into the account"), |
| + "route GET /apps/{app}": claimRead("legacy redirect into the account"), |
| + "route GET /auth/callback": claimNonApp("completes sign-in: session, user and possibly a person's first account"), |
| + "route GET /auth/cli": claimRead("renders the device-code approval page"), |
| + "route GET /auth/cli/tokens": claimRead("redirects to /settings"), |
| + "route GET /auth/cli/whoami": claimRead("names the bearer's principal"), |
| + "route GET /auth/confirm": claimNonApp("consumes a sign-in confirmation link: confirmations and sessions"), |
| + "route GET /auth/signin": claimNonApp("starts an OAuth flow: the auth cookie and its PKCE state"), |
| + "route GET /fleet": claimRead("operator fleet view"), |
| + "route GET /fleet/economics": claimRead("operator economics view"), |
| + "route GET /fleet/economics/accounts": claimRead("operator per-account economics table"), |
| + "route GET /fleet/metrics": claimRead("operator box metrics"), |
| + "route GET /fleet/store": claimRead("operator entitlement queue page"), |
| + "route GET /healthz": claimRead("answers ok and the build"), |
| + "route GET /invite/{token}": claimRead("shows an invitation; joining is the POST"), |
| + "route GET /settings": claimRead("personal settings: the caller's own tokens"), |
| + "route GET /{$}": claimRead("the front door: redirects or renders sign-in"), |
| // GET that writes: a replica file download streams versioned objects (reads |
| // only) but audits every successful GET as an activity row of the app |
| @@ -1294,6 +1313,26 @@ var claimFixtures = map[string]claimFixture{ |
| return "" |
| }, |
| }}, |
| + "route POST /api/cli/apps/{account}/{app}/releases/{version}/authorize": {door: &claimDoor{ |
| + apps: claimRefs("bab/kass"), |
| + setup: func(t *testing.T, cx *claimCase) { cx.authorizePlan(t) }, |
| + do: claimAPIVar(http.MethodPost, "/api/cli/apps/bab/kass/releases/v1/authorize", |
| + `{"manifest_digest":"<manifest_digest>"}`), |
| + check: func(t *testing.T, cx *claimCase, open bool) string { |
| + rec, err := release.ReadPromotionRecord(context.Background(), cx.rel, cx.pub, "bab", "kass", "v1") |
| + switch { |
| + case !open && errors.Is(err, release.ErrNoPromotionRecord): |
| + return "" |
| + case !open && err == nil: |
| + return "kass v1's promotion record was written while kass was foreign-owned" |
| + case err != nil: |
| + return err.Error() |
| + case !rec.Authorized || rec.ManifestDigest != cx.vars["manifest_digest"]: |
| + return "kass v1's promotion record is not an authorization of the confirmed digest" |
| + } |
| + return "" |
| + }, |
| + }}, |
| "route POST /api/cli/apps/{account}/{app}/rollback": {door: &claimDoor{ |
| apps: claimRefs("bab/kass"), |
| setup: func(t *testing.T, cx *claimCase) { |
diff --git a/internal/console/claim_invariant_probes_read_test.go b/internal/console/claim_invariant_probes_read_test.go| index 176b3bb2f..c94acb2ac 100644 |
| --- a/internal/console/claim_invariant_probes_read_test.go |
| +++ b/internal/console/claim_invariant_probes_read_test.go |
| @@ -200,7 +200,9 @@ var claimReadProbes = map[string]claimProbe{ |
| "route GET /api/cli/apps/{account}/{app}/regions": claimAPIRead(nil, "/api/cli/apps/bab/titogo/regions", `"regions"`), |
| "route GET /api/cli/apps/{account}/{app}/relay": claimAPIRead(func(t *testing.T, cx *claimCase) { cx.relayEnabled(t) }, |
| "/api/cli/apps/bab/titogo/relay", `"app":"titogo"`, `"enabled":true`), |
| - "route GET /api/cli/apps/{account}/{app}/releases": claimAPIRead(nil, "/api/cli/apps/bab/kass/releases", `"version":"v1"`), |
| + "route GET /api/cli/apps/{account}/{app}/releases": claimAPIRead(nil, "/api/cli/apps/bab/kass/releases", `"version":"v1"`), |
| + "route GET /api/cli/apps/{account}/{app}/releases/{version}/authorize": claimAPIRead(nil, |
| + "/api/cli/apps/bab/kass/releases/v1/authorize", `"version":"v1"`, `"manifest_digest":"`), |
| "route GET /api/cli/apps/{account}/{app}/retention": claimAPIRead(nil, "/api/cli/apps/bab/titogo/retention", `"keep"`), |
| "route GET /api/cli/apps/{account}/{app}/routes": claimAPIRead(nil, "/api/cli/apps/bab/kass/routes", `"kass.carlosapp.dev"`), |
| "route GET /api/cli/apps/{account}/{app}/schedules": claimAPIRead(claimWorldSchedule, "/api/cli/apps/bab/titogo/schedules", `"sync"`), |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index 9e7e6ef88..bf736963b 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -224,6 +224,11 @@ var closedDoors = []string{ |
| // `carlos channels resign`: the console signs digests a person has |
| // confirmed. A credential never confirms on a person's behalf. |
| "POST /api/cli/apps/{account}/{app}/channels/resign", |
| + // `carlos releases authorize`: the console signs a promotion record for |
| + // the digest a person confirmed. The GET is closed with it, as the |
| + // pointer-digests read is: a credential has no plan to confirm. |
| + "GET /api/cli/apps/{account}/{app}/releases/{version}/authorize", |
| + "POST /api/cli/apps/{account}/{app}/releases/{version}/authorize", |
| } |
| // TestEveryCLIDoorNamesItsRoles is the default-deny guarantee for the |
| @@ -890,6 +895,7 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) { |
| "{id}", "deadbeefdeadbeef", |
| "{level}", "0", |
| "{span}", "1-1", |
| + "{version}", "v1", |
| ).Replace(path) |
| if strings.Contains(u, "{") { |
| t.Fatalf("closedDoors entry %q has a wildcard this test does not fill", pattern) |
| @@ -907,8 +913,8 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) { |
| } |
| seen++ |
| } |
| - if seen != len(closedDoors) || seen != 45 { |
| - t.Errorf("walked %d closed doors, want all %d (and 45 today: instance-token, three door-key doors, the routes door, instances-manifest, sidecars-manifest, the route-channel move, two db replica doors, six fleets, four services, two email credentials, email pause, the build probe, the live tenancy read, the layout read, seven system doors, two deployment-wide antivirus, three platform geoip doors, two pointer digest doors, the channel resign, and four store-entitlement doors)", |
| + if seen != len(closedDoors) || seen != 47 { |
| + t.Errorf("walked %d closed doors, want all %d (and 47 today: instance-token, three door-key doors, the routes door, instances-manifest, sidecars-manifest, the route-channel move, two db replica doors, six fleets, four services, two email credentials, email pause, the build probe, the live tenancy read, the layout read, seven system doors, two deployment-wide antivirus, three platform geoip doors, two pointer digest doors, the channel resign, two release authorize doors, and four store-entitlement doors)", |
| seen, len(closedDoors)) |
| } |
| } |
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt| index 392cdeaf2..558ba6d63 100644 |
| --- a/internal/console/testdata/claim-routes.txt |
| +++ b/internal/console/testdata/claim-routes.txt |
| @@ -65,6 +65,7 @@ route GET /api/cli/apps/{account}/{app}/pipeline |
| route GET /api/cli/apps/{account}/{app}/regions |
| route GET /api/cli/apps/{account}/{app}/relay |
| route GET /api/cli/apps/{account}/{app}/releases |
| +route GET /api/cli/apps/{account}/{app}/releases/{version}/authorize |
| route GET /api/cli/apps/{account}/{app}/retention |
| route GET /api/cli/apps/{account}/{app}/routes |
| route GET /api/cli/apps/{account}/{app}/schedules |
| @@ -185,6 +186,7 @@ route POST /api/cli/apps/{account}/{app}/relay |
| route POST /api/cli/apps/{account}/{app}/relay/adopted |
| route POST /api/cli/apps/{account}/{app}/relay/disable |
| route POST /api/cli/apps/{account}/{app}/releases |
| +route POST /api/cli/apps/{account}/{app}/releases/{version}/authorize |
| route POST /api/cli/apps/{account}/{app}/restart |
| route POST /api/cli/apps/{account}/{app}/restart/scope |
| route POST /api/cli/apps/{account}/{app}/restore |