DodoApp: Use JSON file for configuration.

Specify json schema so code editors can validate user input.
Update auth proxy to disable auth on specified paths.

Change-Id: Ic6667d802a9553444d3630c4ff73f4b33304ccfd
diff --git a/core/auth/proxy/main.go b/core/auth/proxy/main.go
index 2114127..2c10258 100644
--- a/core/auth/proxy/main.go
+++ b/core/auth/proxy/main.go
@@ -25,6 +25,7 @@
 var membershipPublicAddr = flag.String("membership-public-addr", "", "Public address of membership service")
 var groups = flag.String("groups", "", "Comma separated list of groups. User must be part of at least one of them. If empty group membership will not be checked.")
 var upstream = flag.String("upstream", "", "Upstream service address")
+var noAuthPathPrefixes = flag.String("no-auth-path-prefixes", "", "Path prefixes to disable authentication for")
 
 //go:embed unauthorized.html
 var unauthorizedHTML embed.FS
@@ -92,46 +93,60 @@
 }
 
 func handle(w http.ResponseWriter, r *http.Request) {
-	user, err := queryWhoAmI(r.Cookies())
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-	if user == nil {
-		if r.Method != http.MethodGet {
-			http.Error(w, "Unauthorized", http.StatusUnauthorized)
-			return
+	reqAuth := true
+	for _, p := range strings.Split(*noAuthPathPrefixes, ",") {
+		if strings.HasPrefix(r.URL.Path, p) {
+			reqAuth = false
+			break
 		}
-		curr, err := getAddr(r)
+	}
+	var user *user
+	if reqAuth {
+		var err error
+		user, err = queryWhoAmI(r.Cookies())
 		if err != nil {
 			http.Error(w, err.Error(), http.StatusInternalServerError)
 			return
 		}
-		addr := fmt.Sprintf("%s?return_to=%s", *loginAddr, curr.String())
-		http.Redirect(w, r, addr, http.StatusSeeOther)
-		return
-	}
-	if *groups != "" {
-		hasPermission := false
-		tg, err := getTransitiveGroups(user.Identity.Traits.Username)
-		if err != nil {
-			http.Error(w, err.Error(), http.StatusInternalServerError)
-			return
-		}
-		for _, i := range strings.Split(*groups, ",") {
-			if slices.Contains(tg, strings.TrimSpace(i)) {
-				hasPermission = true
-				break
+		if user == nil {
+			if r.Method != http.MethodGet {
+				http.Error(w, "Unauthorized", http.StatusUnauthorized)
+				return
 			}
-		}
-		if !hasPermission {
-			groupList := strings.Split(*groups, ",")
-			renderUnauthorizedPage(w, groupList)
+			curr, err := getAddr(r)
+			if err != nil {
+				http.Error(w, err.Error(), http.StatusInternalServerError)
+				return
+			}
+			addr := fmt.Sprintf("%s?return_to=%s", *loginAddr, curr.String())
+			http.Redirect(w, r, addr, http.StatusSeeOther)
 			return
 		}
+		if *groups != "" {
+			hasPermission := false
+			tg, err := getTransitiveGroups(user.Identity.Traits.Username)
+			if err != nil {
+				http.Error(w, err.Error(), http.StatusInternalServerError)
+				return
+			}
+			for _, i := range strings.Split(*groups, ",") {
+				if slices.Contains(tg, strings.TrimSpace(i)) {
+					hasPermission = true
+					break
+				}
+			}
+			if !hasPermission {
+				groupList := strings.Split(*groups, ",")
+				renderUnauthorizedPage(w, groupList)
+				return
+			}
+		}
 	}
 	rc := r.Clone(context.Background())
-	rc.Header.Add("X-User", user.Identity.Traits.Username)
+	if user != nil {
+		// TODO(gio): Rename to X-Forwarded-User
+		rc.Header.Add("X-User", user.Identity.Traits.Username)
+	}
 	ru, err := url.Parse(fmt.Sprintf("http://%s%s", *upstream, r.URL.RequestURI()))
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -237,7 +252,7 @@
 	if *groups != "" && (*membershipAddr == "" || *membershipPublicAddr == "") {
 		log.Fatal("membership-addr and membership-public-addr flags are required when groups are provided")
 	}
-	http.Handle("/static/", cachingHandler{http.FileServer(http.FS(f))})
+	http.Handle("/.auth/static/", http.StripPrefix("/.auth", cachingHandler{http.FileServer(http.FS(f))}))
 	http.HandleFunc("/", handle)
 	fmt.Printf("Starting HTTP server on port: %d\n", *port)
 	log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil))
diff --git a/core/auth/proxy/unauthorized.html b/core/auth/proxy/unauthorized.html
index 35fafab..130cf81 100644
--- a/core/auth/proxy/unauthorized.html
+++ b/core/auth/proxy/unauthorized.html
@@ -4,8 +4,8 @@
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <title>Auth Proxy</title>
-    <link rel="stylesheet" href="/static/pico.2.0.6.min.css">
-    <link rel="stylesheet" href="/static/main.css?v=0.0.1">
+    <link rel="stylesheet" href="/.auth/static/pico.2.0.6.min.css">
+    <link rel="stylesheet" href="/.auth/static/main.css?v=0.0.1">
     <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/hack-font/3.3.0/web/hack.min.css">
 </head>
 <body>
diff --git a/core/installer/app.go b/core/installer/app.go
index d7f6989..1bf632c 100644
--- a/core/installer/app.go
+++ b/core/installer/app.go
@@ -255,8 +255,8 @@
 		return cue.Value{}, fmt.Errorf("invalid")
 	}
 	ret := ctx.BuildInstance(instances[0])
-	if ret.Err() != nil {
-		return cue.Value{}, ret.Err()
+	if err := ret.Err(); err != nil {
+		return cue.Value{}, err
 	}
 	if err := ret.Validate(); err != nil {
 		return cue.Value{}, err
diff --git a/core/installer/app_configs/app_base.cue b/core/installer/app_configs/app_base.cue
index 3f0036b..4075281 100644
--- a/core/installer/app_configs/app_base.cue
+++ b/core/installer/app_configs/app_base.cue
@@ -36,8 +36,9 @@
 appType: #AppType | *"env"
 
 #Auth: {
-  enabled: bool | *false // TODO(gio): enabled by default?
-  groups: string | *"" // TODO(gio): []string
+	enabled: bool | *false // TODO(gio): enabled by default?
+	groups: string | *"" // TODO(gio): []string
+	noAuthPathPrefixes: [...string] | *[]
 }
 
 #Image: {
diff --git a/core/installer/app_configs/app_global_env.cue b/core/installer/app_configs/app_global_env.cue
index e6e8de8..fba9dcf 100644
--- a/core/installer/app_configs/app_global_env.cue
+++ b/core/installer/app_configs/app_global_env.cue
@@ -114,6 +114,7 @@
 						membershipPublicAddr: "https://memberships.\(g.privateDomain)"
 					}
 					groups: auth.groups
+					noAuthPathPrefixes: strings.Join(auth.noAuthPathPrefixes, ",")
 					portName: _authProxyHTTPPortName
 				}
 			}
diff --git a/core/installer/app_configs/dodo_app.cue b/core/installer/app_configs/dodo_app.cue
index a4bbf0c..b40eaed 100644
--- a/core/installer/app_configs/dodo_app.cue
+++ b/core/installer/app_configs/dodo_app.cue
@@ -93,42 +93,28 @@
 	type: string
 	cluster?: string
 	ingress: #AppIngress
-	volumes: {
-		for k, v in volumes {
-			"\(k)": #volume & v & {
-				name: k
-			}
-		}
-		...
-	}
-	postgresql: {
-		for k, v in postgresql {
-			"\(k)": #PostgreSQL & v & {
-				name: k
-			}
-		}
-		...
-	}
+	volumes: [...#volume]
+	postgresql: [...#PostgreSQL]
 	rootDir: string
 	runConfiguration: [...#Command]
 	dev: #Dev | *{ enabled: false }
 	vm: #VMCustomization
 
 	lastCmdEnv: [
-		for k, v in volumes {
-			"DODO_VOLUME_\(strings.ToUpper(k))=/dodo-volume/\(v.name)"
+		for v in volumes {
+			"DODO_VOLUME_\(strings.ToUpper(v.name))=/dodo-volume/\(v.name)"
 		}
-		for k, v in postgresql {
-			"DODO_POSTGRESQL_\(strings.ToUpper(k))_ADDRESS=postgres-\(v.name).\(release.namespace).svc.cluster.local"
+		for v in postgresql {
+			"DODO_POSTGRESQL_\(strings.ToUpper(v.name))_ADDRESS=postgres-\(v.name).\(release.namespace).svc.cluster.local"
 		}
-		for k, v in postgresql {
-			"DODO_POSTGRESQL_\(strings.ToUpper(k))_USERNAME=postgres"
+		for v in postgresql {
+			"DODO_POSTGRESQL_\(strings.ToUpper(v.name))_USERNAME=postgres"
 		}
-		for k, v in postgresql {
-			"DODO_POSTGRESQL_\(strings.ToUpper(k))_PASSWORD=postgres"
+		for v in postgresql {
+			"DODO_POSTGRESQL_\(strings.ToUpper(v.name))_PASSWORD=postgres"
 		}
-		for k, v in postgresql {
-			"DODO_POSTGRESQL_\(strings.ToUpper(k))_DATABASE=postgres"
+		for v in postgresql {
+			"DODO_POSTGRESQL_\(strings.ToUpper(v.name))_DATABASE=postgres"
 		}
     ]
 
@@ -151,8 +137,6 @@
 	port: int | *8080
 	rootDir: _appDir
 
-	volumes: {...}
-	postgresql: {...}
 	lastCmdEnv: [...string]
 
 	runConfiguration: [{
@@ -197,8 +181,6 @@
 	port: int | *8080
 	rootDir: _appDir
 
-	volumes: {...}
-	postgresql: {...}
 	lastCmdEnv: [...string]
 
 	runConfiguration: [{
@@ -226,8 +208,6 @@
 	port: int | *80
 	rootDir: "/var/www/html"
 
-	volumes: {...}
-	postgresql: {...}
 	lastCmdEnv: [...string]
 
 	runConfiguration: [{
@@ -337,9 +317,9 @@
 						runCfg: base64.Encode(null, json.Marshal(_app.runConfiguration))
 						managerAddr: input.managerAddr
 						volumes: [
-							for key, value in _app.volumes {
-								name: value.name
-								mountPath: "/dodo-volume/\(key)"
+							for v in _app.volumes {
+								name: v.name
+								mountPath: "/dodo-volume/\(v.name)"
 							}
 					    ]
 					}
@@ -382,8 +362,16 @@
 	if app.cluster != _|_ {
 		cluster: clusterMap[strings.ToLower(app.cluster)]
 	}
-	volumes: app.volumes
-	postgresql: app.postgresql
+	volumes: {
+		for v in app.volumes {
+			"\(v.name)": v
+		}
+	}
+	postgresql: {
+		for v in app.postgresql {
+			"\(v.name)": v
+		}
+	}
 	vm: {
 		"\(_vmName)": _devVM
 	}
diff --git a/core/installer/app_repository.go b/core/installer/app_repository.go
index 67d2833..e4f462e 100644
--- a/core/installer/app_repository.go
+++ b/core/installer/app_repository.go
@@ -21,7 +21,7 @@
 var storeEnvAppConfigs = []string{
 	"values-tmpl/dodo-app.cue",
 	"values-tmpl/virtual-machine.cue",
-	"values-tmpl/coder.cue",
+	// "values-tmpl/coder.cue",
 	"values-tmpl/url-shortener.cue",
 	"values-tmpl/matrix.cue",
 	"values-tmpl/vaultwarden.cue",
diff --git a/core/installer/app_test.go b/core/installer/app_test.go
index bcad861..47eb02c 100644
--- a/core/installer/app_test.go
+++ b/core/installer/app_test.go
@@ -479,12 +479,14 @@
 		enabled: true
 		username: "gio"
 	}
-    volumes: {
-      data: size: "5Gi"
-    }
-    postgresql: {
-      db: size: "10Gi"
-    }
+    volumes: [{
+      name: "data"
+      size: "5Gi"
+    }]
+    postgresql: [{
+      name: "db"
+      size: "10Gi"
+    }]
 }`
 
 func TestDodoAppDevDisabled(t *testing.T) {
diff --git a/core/installer/cmd/dodo_app.go b/core/installer/cmd/dodo_app.go
index bcd8a68..7954690 100644
--- a/core/installer/cmd/dodo_app.go
+++ b/core/installer/cmd/dodo_app.go
@@ -29,6 +29,7 @@
 	sshKey            string
 	repoAddr          string
 	self              string
+	selfPublic        string
 	repoPublicAddr    string
 	namespace         string
 	envAppManagerAddr string
@@ -88,6 +89,12 @@
 		"",
 	)
 	cmd.Flags().StringVar(
+		&dodoAppFlags.selfPublic,
+		"self-public",
+		"",
+		"",
+	)
+	cmd.Flags().StringVar(
 		&dodoAppFlags.fetchUsersAddr,
 		"fetch-users-addr",
 		"",
@@ -214,6 +221,7 @@
 		dodoAppFlags.port,
 		dodoAppFlags.apiPort,
 		dodoAppFlags.self,
+		dodoAppFlags.selfPublic,
 		dodoAppFlags.repoPublicAddr,
 		string(sshKey),
 		dodoAppFlags.gitRepoPublicKey,
diff --git a/core/installer/values-tmpl/dodo-app.cue b/core/installer/values-tmpl/dodo-app.cue
index 8bb57c0..cb61b45 100644
--- a/core/installer/values-tmpl/dodo-app.cue
+++ b/core/installer/values-tmpl/dodo-app.cue
@@ -105,6 +105,11 @@
 				}
 				if !input.external {
 					enabled: true
+					noAuthPathPrefixes: [
+						"/static/",
+						"/schemas/",
+						"/api/public-data",
+				    ]
 				}
 			}
 			network: input.network
@@ -150,6 +155,7 @@
 				repoAddr: "soft-serve.\(release.namespace).svc.cluster.local:22"
 				sshPrivateKey: base64.Encode(null, input.dAppKeys.private)
 				self: "api.\(release.namespace).svc.cluster.local"
+				selfPublic: url
 				repoPublicAddr: "ssh://\(_domain):\(input.sshPort)"
 				namespace: release.namespace
 				envAppManagerAddr: "http://appmanager.\(global.namespacePrefix)appmanager.svc.cluster.local"
diff --git a/core/installer/welcome/app-tmpl/golang-1.20.0/app.cue.gotmpl b/core/installer/welcome/app-tmpl/golang-1.20.0/app.cue.gotmpl
deleted file mode 100755
index 9cd1442..0000000
--- a/core/installer/welcome/app-tmpl/golang-1.20.0/app.cue.gotmpl
+++ /dev/null
@@ -1,9 +0,0 @@
-app: {
-	type: "golang:1.20.0"
-	run: "main.go"
-	ingress: {
-		network: "{{ .Network.Name }}"
-		subdomain: "{{ .Subdomain }}"
-		auth: enabled: false
-	}
-}
diff --git a/core/installer/welcome/app-tmpl/golang-1.20.0/app.json.gotmpl b/core/installer/welcome/app-tmpl/golang-1.20.0/app.json.gotmpl
new file mode 100755
index 0000000..60356c6
--- /dev/null
+++ b/core/installer/welcome/app-tmpl/golang-1.20.0/app.json.gotmpl
@@ -0,0 +1,14 @@
+{
+	"$schema": "{{ .SchemaAddr }}",
+	"app": {
+		"type": "golang:1.20.0",
+		"run": "main.go",
+		"ingress": {
+			"network": "{{ .Network.Name }}",
+			"subdomain": "{{ .Subdomain }}",
+			"auth": {
+				"enabled": false
+			}
+		}
+	}
+}
\ No newline at end of file
diff --git a/core/installer/welcome/app-tmpl/hugo-latest/app.cue.gotmpl b/core/installer/welcome/app-tmpl/hugo-latest/app.cue.gotmpl
deleted file mode 100644
index cbf7b23..0000000
--- a/core/installer/welcome/app-tmpl/hugo-latest/app.cue.gotmpl
+++ /dev/null
@@ -1,8 +0,0 @@
-app: {
-	type: "hugo:latest"
-	ingress: {
-		network: "{{ .Network.Name }}"
-		subdomain: "{{ .Subdomain }}"
-		auth: enabled: false
-	}
-}
diff --git a/core/installer/welcome/app-tmpl/hugo-latest/app.json.gotmpl b/core/installer/welcome/app-tmpl/hugo-latest/app.json.gotmpl
new file mode 100644
index 0000000..bc71973
--- /dev/null
+++ b/core/installer/welcome/app-tmpl/hugo-latest/app.json.gotmpl
@@ -0,0 +1,13 @@
+{
+	"$schema": "{{ .SchemaAddr }}",
+	"app": {
+		"type": "hugo:latest",
+		"ingress": {
+			"network": "{{ .Network.Name }}",
+			"subdomain": "{{ .Subdomain }}",
+			"auth": {
+				"enabled": false
+			}
+		}
+	}
+}
\ No newline at end of file
diff --git a/core/installer/welcome/app-tmpl/php-8.2-apache/app.cue.gotmpl b/core/installer/welcome/app-tmpl/php-8.2-apache/app.cue.gotmpl
index fa925fe..f5a061d 100755
--- a/core/installer/welcome/app-tmpl/php-8.2-apache/app.cue.gotmpl
+++ b/core/installer/welcome/app-tmpl/php-8.2-apache/app.cue.gotmpl
@@ -1,8 +1,13 @@
-app: {
-	type: "php:8.2-apache"
-	ingress: {
-		network: "{{ .Network.Name }}"
-		subdomain: "{{ .Subdomain }}"
-		auth: enabled: false
+{
+	"$schema": "{{ .SchemaAddr }}",
+	"app": {
+		"type": "php:8.2-apache",
+		"ingress": {
+			"network": "{{ .Network.Name }}",
+			"subdomain": "{{ .Subdomain }}",
+			"auth": {
+				"enabled": false
+			}
+		}
 	}
-}
+}
\ No newline at end of file
diff --git a/core/installer/welcome/app_tmpl.go b/core/installer/welcome/app_tmpl.go
index fb512fa..911b9b8 100644
--- a/core/installer/welcome/app_tmpl.go
+++ b/core/installer/welcome/app_tmpl.go
@@ -68,7 +68,7 @@
 }
 
 type AppTmpl interface {
-	Render(network installer.Network, subdomain string) (map[string][]byte, error)
+	Render(schemaAddr string, network installer.Network, subdomain string) (map[string][]byte, error)
 }
 
 type appTmplFS struct {
@@ -108,7 +108,7 @@
 	return &appTmplFS{files, tmpls}, nil
 }
 
-func (a *appTmplFS) Render(network installer.Network, subdomain string) (map[string][]byte, error) {
+func (a *appTmplFS) Render(schemaAddr string, network installer.Network, subdomain string) (map[string][]byte, error) {
 	ret := map[string][]byte{}
 	for path, contents := range a.files {
 		ret[path] = contents
@@ -116,8 +116,9 @@
 	for path, tmpl := range a.tmpls {
 		var buf bytes.Buffer
 		if err := tmpl.Execute(&buf, map[string]any{
-			"Network":   network,
-			"Subdomain": subdomain,
+			"SchemaAddr": schemaAddr,
+			"Network":    network,
+			"Subdomain":  subdomain,
 		}); err != nil {
 			return nil, err
 		}
diff --git a/core/installer/welcome/app_tmpl_test.go b/core/installer/welcome/app_tmpl_test.go
index 00e5a3c..f3ff71e 100644
--- a/core/installer/welcome/app_tmpl_test.go
+++ b/core/installer/welcome/app_tmpl_test.go
@@ -35,7 +35,7 @@
 	if err != nil {
 		t.Fatal(err)
 	}
-	if _, err := a.Render(network, "testapp"); err != nil {
+	if _, err := a.Render("schema.json", network, "testapp"); err != nil {
 		t.Fatal(err)
 	}
 }
@@ -53,7 +53,7 @@
 	if err != nil {
 		t.Fatal(err)
 	}
-	if _, err := a.Render(network, "testapp"); err != nil {
+	if _, err := a.Render("schema.json", network, "testapp"); err != nil {
 		t.Fatal(err)
 	}
 }
@@ -71,7 +71,7 @@
 	if err != nil {
 		t.Fatal(err)
 	}
-	if _, err := a.Render(network, "testapp"); err != nil {
+	if _, err := a.Render("schema.json", network, "testapp"); err != nil {
 		t.Fatal(err)
 	}
 }
diff --git a/core/installer/welcome/dodo_app.go b/core/installer/welcome/dodo_app.go
index 4307866..d71bf83 100644
--- a/core/installer/welcome/dodo_app.go
+++ b/core/installer/welcome/dodo_app.go
@@ -35,12 +35,16 @@
 //go:embed all:app-tmpl
 var appTmplsFS embed.FS
 
+//go:embed stat/schemas/app.schema.json
+var dodoAppJsonSchema []byte
+
 const (
 	ConfigRepoName = "config"
 	appConfigsFile = "/apps.json"
 	loginPath      = "/login"
 	logoutPath     = "/logout"
 	staticPath     = "/stat/"
+	schemasPath    = "/schemas/"
 	apiPublicData  = "/api/public-data"
 	apiCreateApp   = "/api/apps"
 	sessionCookie  = "dodo-app-session"
@@ -94,6 +98,7 @@
 	port              int
 	apiPort           int
 	self              string
+	selfPublic        string
 	repoPublicAddr    string
 	sshKey            string
 	gitRepoPublicKey  string
@@ -128,6 +133,7 @@
 	port int,
 	apiPort int,
 	self string,
+	selfPublic string,
 	repoPublicAddr string,
 	sshKey string,
 	gitRepoPublicKey string,
@@ -163,6 +169,7 @@
 		port,
 		apiPort,
 		self,
+		selfPublic,
 		repoPublicAddr,
 		sshKey,
 		gitRepoPublicKey,
@@ -218,6 +225,7 @@
 	go func() {
 		r := mux.NewRouter()
 		r.Use(s.mwAuth)
+		r.HandleFunc(schemasPath+"app.schema.json", s.handleSchema).Methods(http.MethodGet)
 		r.PathPrefix(staticPath).Handler(cachingHandler{http.FileServer(http.FS(statAssets))})
 		r.HandleFunc(logoutPath, s.handleLogout).Methods(http.MethodGet)
 		r.HandleFunc(apiPublicData, s.handleAPIPublicData)
@@ -320,6 +328,7 @@
 		if strings.HasSuffix(r.URL.Path, loginPath) ||
 			strings.HasPrefix(r.URL.Path, logoutPath) ||
 			strings.HasPrefix(r.URL.Path, staticPath) ||
+			strings.HasPrefix(r.URL.Path, schemasPath) ||
 			strings.HasPrefix(r.URL.Path, apiPublicData) ||
 			strings.HasPrefix(r.URL.Path, apiCreateApp) {
 			next.ServeHTTP(w, r)
@@ -340,6 +349,11 @@
 	})
 }
 
+func (s *DodoAppServer) handleSchema(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "application/schema+json")
+	w.Write(dodoAppJsonSchema)
+}
+
 func (s *DodoAppServer) handleLogout(w http.ResponseWriter, r *http.Request) {
 	// TODO(gio): move to UserGetter
 	http.SetCookie(w, &http.Cookie{
@@ -1017,7 +1031,7 @@
 	if err != nil {
 		return err
 	}
-	appCfg, err := soft.ReadFile(appRepo, "app.cue")
+	appCfg, err := soft.ReadFile(appRepo, "app.json")
 	if err != nil {
 		return err
 	}
@@ -1025,7 +1039,7 @@
 	if err != nil {
 		return err
 	}
-	return s.createAppForBranch(appRepo, appName, toBranch, user, network, map[string][]byte{"app.cue": branchCfg})
+	return s.createAppForBranch(appRepo, appName, toBranch, user, network, map[string][]byte{"app.json": branchCfg})
 }
 
 func (s *DodoAppServer) createAppForBranch(
@@ -1238,7 +1252,7 @@
 	if err != nil {
 		return installer.ReleaseResources{}, err
 	}
-	appCfg, err := soft.ReadFile(repo, "app.cue")
+	appCfg, err := soft.ReadFile(repo, "app.json")
 	if err != nil {
 		return installer.ReleaseResources{}, err
 	}
@@ -1316,7 +1330,7 @@
 	if err != nil {
 		return nil, err
 	}
-	return appTmpl.Render(network, subdomain)
+	return appTmpl.Render(fmt.Sprintf("%s/stat/schemas/dodo_app.jsonschema", s.selfPublic), network, subdomain)
 }
 
 func generatePassword() string {
@@ -1677,7 +1691,9 @@
 }
 
 func createDevBranchAppConfig(from []byte, branch, username string) (string, []byte, error) {
-	cfg, err := installer.ParseCueAppConfig(installer.CueAppData{"app.cue": from})
+	cfg, err := installer.ParseCueAppConfig(installer.CueAppData{
+		"app.cue": from,
+	})
 	if err != nil {
 		return "", nil, err
 	}
diff --git a/core/installer/welcome/dodo_app_test.go b/core/installer/welcome/dodo_app_test.go
index 0f0f526..8fc83e7 100644
--- a/core/installer/welcome/dodo_app_test.go
+++ b/core/installer/welcome/dodo_app_test.go
@@ -5,14 +5,17 @@
 )
 
 func TestCreateDevBranch(t *testing.T) {
-	cfg := []byte(`
-app: {
-	type: "golang:1.22.0"
-	run: "main.go"
-	ingress: {
-		network: "private"
-		subdomain: "testapp"
-		auth: enabled: false
+	cfg := []byte(`{
+	"app": {
+		"type": "golang:1.22.0",
+		"run": "main.go",
+		"ingress": {
+			"network": "private",
+			"subdomain": "testapp",
+			"auth": {
+				"enabled": false
+			}
+		}
 	}
 }`)
 	network, newCfg, err := createDevBranchAppConfig(cfg, "foo", "bar")
diff --git a/core/installer/welcome/stat/schemas/app.schema.json b/core/installer/welcome/stat/schemas/app.schema.json
new file mode 100644
index 0000000..f6264de
--- /dev/null
+++ b/core/installer/welcome/stat/schemas/app.schema.json
@@ -0,0 +1,210 @@
+{
+  "$id": "https://dodo.cloud/schemas/app.schema.json",
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "type": "object",
+  "properties": {
+    "app": {
+      "type": "object",
+      "oneOf": [
+        {
+          "$ref": "#/definitions/golang"
+        },
+        {
+          "$ref": "#/definitions/hugo"
+        },
+        {
+          "$ref": "#/definitions/php"
+        }
+      ]
+    }
+  },
+  "definitions": {
+    "golang": {
+      "type": "object",
+      "properties": {
+        "type": {
+          "type": "string",
+          "oneOf": [
+            {
+              "const": "golang:1.22.0"
+            },
+            {
+              "const": "golang:1.20.0"
+            }
+          ]
+        },
+        "run": {
+          "type": "string"
+        },
+        "ingress": {
+          "$ref": "#/definitions/ingress"
+        },
+        "volumes": {
+          "$ref": "#/definitions/volumes"
+        },
+        "postgresql": {
+          "$ref": "#/definitions/postgresql"
+        }
+      },
+      "additionalProperties": false,
+      "required": [
+        "type"
+      ]
+    },
+    "hugo": {
+      "type": "object",
+      "properties": {
+        "type": {
+          "type": "string",
+          "oneOf": [
+            {
+              "const": "hugo:latest"
+            }
+          ]
+        },
+        "ingress": {
+          "$ref": "#/definitions/ingress"
+        }
+      },
+      "additionalProperties": false,
+      "required": [
+        "type"
+      ]
+    },
+    "php": {
+      "type": "object",
+      "properties": {
+        "type": {
+          "type": "string",
+          "oneOf": [
+            {
+              "const": "php:8.2-apache"
+            }
+          ]
+        },
+        "ingress": {
+          "$ref": "#/definitions/ingress"
+        },
+        "volumes": {
+          "$ref": "#/definitions/volumes"
+        },
+        "postgresql": {
+          "$ref": "#/definitions/postgresql"
+        }
+      },
+      "additionalProperties": false,
+      "required": [
+        "type"
+      ]
+    },
+    "volume": {
+      "type": "object",
+      "properties": {
+        "name": {
+          "type": "string"
+        },
+        "size": {
+          "$ref": "#/definitions/size"
+        }
+      },
+      "additionalProperties": false,
+      "required": [
+        "name",
+        "size"
+      ]
+    },
+    "volumes": {
+      "type": "array",
+      "items": {
+        "$ref": "#/definitions/volume"
+      }
+    },
+    "postgre": {
+      "type": "object",
+      "properties": {
+        "name": {
+          "type": "string"
+        },
+        "size": {
+          "$ref": "#/definitions/size"
+        }
+      },
+      "additionalProperties": false,
+      "required": [
+        "name",
+        "size"
+      ]
+    },
+    "postgresql": {
+      "type": "array",
+      "items": {
+        "$ref": "#/definitions/postgre"
+      }
+    },
+    "size": {
+      "type": "string",
+      "pattern": "[1-9][0-9]*(Mi|Gi)"
+    },
+    "ingress": {
+      "type": "object",
+      "properties": {
+        "network": {
+          "type": "string",
+          "minLength": 1
+        },
+        "subdomain": {
+          "type": "string",
+          "minLength": 1
+        },
+        "auth": {
+          "type": "object",
+          "oneOf": [
+            {
+              "type": "object",
+              "properties": {
+                "enabled": {
+                  "enum": [
+                    false
+                  ]
+                }
+              },
+              "additionalProperties": false,
+              "required": [
+                "enabled"
+              ]
+            },
+            {
+              "type": "object",
+              "properties": {
+                "enabled": {
+                  "enum": [
+                    true
+                  ]
+                },
+                "groups": {
+                  "type": "string"
+                },
+                "noAuthPathPrefixes": {
+                  "type": "array",
+                  "items": {
+                    "type": "string",
+					"pattern": "^/.*"
+                  }
+                }
+              },
+              "additionalProperties": false,
+              "required": [
+                "enabled"
+              ]
+            }
+          ]
+        }
+      },
+      "additionalProperties": false,
+      "required": [
+        "network",
+        "subdomain"
+      ]
+    }
+  }
+}