diff --git a/cmd/app/cli/cli.go b/cmd/app/cli/cli.go
index d92cc32..054893b 100644
--- a/cmd/app/cli/cli.go
+++ b/cmd/app/cli/cli.go
@@ -3,12 +3,19 @@ package cli
 import (
 	"context"
 
+	"github.com/urfave/cli/v2"
 	"go.uber.org/fx"
 
 	"exusiai.dev/roguestats-backend/internal/app"
 	"exusiai.dev/roguestats-backend/internal/app/appenv"
 )
 
-func Start(module fx.Option) {
-	app.New(appenv.Declare(appenv.EnvCLI), module).Start(context.Background())
+func Start(module fx.Option) error {
+	return app.New(appenv.Declare(appenv.EnvCLI), module).
+		Start(context.Background())
+}
+
+func RunFunc(ctx *cli.Context, r any) error {
+	return app.New(appenv.Declare(appenv.EnvCLI), fx.Supply(ctx), fx.Invoke(r)).
+		Start(ctx.Context)
 }
diff --git a/cmd/app/cli/script/command.go b/cmd/app/cli/script/command.go
index 0125d59..043c73a 100644
--- a/cmd/app/cli/script/command.go
+++ b/cmd/app/cli/script/command.go
@@ -3,6 +3,7 @@ package script
 import (
 	"log"
 
+	"exusiai.dev/roguestats-backend/cmd/app/cli/script/syncschema"
 	"github.com/urfave/cli/v2"
 )
 
@@ -48,6 +49,7 @@ func Command() *cli.Command {
 					return nil
 				},
 			},
+			syncschema.Command(),
 		},
 	}
 }
diff --git a/cmd/app/cli/script/import_util.go b/cmd/app/cli/script/import_util.go
index 84bd450..1b3e2f2 100644
--- a/cmd/app/cli/script/import_util.go
+++ b/cmd/app/cli/script/import_util.go
@@ -29,9 +29,9 @@ func PostEvent(content map[string]any, researchID string) {
 	req := graphql.NewRequest(`
 	mutation CreateEvent($input: CreateEventInput!) {
 		createEvent(input: $input) {
-		  content
+			content
 		}
-	  }`,
+	}`,
 	)
 	userAgent := "cli"
 	input := model.CreateEventInput{
@@ -40,7 +40,7 @@ func PostEvent(content map[string]any, researchID string) {
 		UserAgent:  userAgent,
 	}
 	req.Var("input", input)
-	req.Header.Set("Authorization", "Bearer eyJhbGciOiJFUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyb2d1ZXN0YXRzIiwiZXhwIjoxNjk0MjA0MjY1LCJpYXQiOjE2OTI5OTQ2NjUsImlzcyI6InJvZ3Vlc3RhdHMvdjAuMC4wIiwibmJmIjoxNjkyOTk0NjY1LCJzdWIiOiIwMWg4cTVlYnJuNWV0aG0xcDZ6anhyOWVmdyJ9.AHlIYrx7tKj6nnXO4MYRd_0mXqzOVWPyG6FHidPitfI2IbrtZI3-lXA-bZP_nl0Op7d4TgzacdYwJPDgYGLoZcznAfopT-ahoHmDZrflhrK-Soo8ji7OZENjOIH5VetkkTaKl9zuqdAivds4DQPefSYngsn5vqzIgIZhaoR8nJoaq6MT")
+	req.Header.Set("Authorization", "Bearer <token>")
 	ctx := context.Background()
 	var respData any
 	if err := client.Run(ctx, req, &respData); err != nil {
diff --git a/cmd/app/cli/script/syncschema/command.go b/cmd/app/cli/script/syncschema/command.go
new file mode 100644
index 0000000..e9d2c4b
--- /dev/null
+++ b/cmd/app/cli/script/syncschema/command.go
@@ -0,0 +1,24 @@
+package syncschema
+
+import (
+	appcli "exusiai.dev/roguestats-backend/cmd/app/cli"
+	"github.com/urfave/cli/v2"
+)
+
+func Command() *cli.Command {
+	return &cli.Command{
+		Name:  "sync-schema",
+		Usage: "Sync JSON Schemas under DIR with the database.",
+		Flags: []cli.Flag{
+			&cli.StringFlag{
+				Name:    "dir",
+				Aliases: []string{"d"},
+				Usage:   "Directory containing the JSON Schemas.",
+				Value:   "./schema",
+			},
+		},
+		Action: func(c *cli.Context) error {
+			return appcli.RunFunc(c, Run)
+		},
+	}
+}
diff --git a/cmd/app/cli/script/syncschema/syncschema.go b/cmd/app/cli/script/syncschema/syncschema.go
new file mode 100644
index 0000000..293a922
--- /dev/null
+++ b/cmd/app/cli/script/syncschema/syncschema.go
@@ -0,0 +1,95 @@
+package syncschema
+
+import (
+	"io"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"exusiai.dev/roguestats-backend/internal/ent"
+	"exusiai.dev/roguestats-backend/internal/ent/research"
+	"github.com/rs/zerolog/log"
+	"github.com/urfave/cli/v2"
+	"go.uber.org/fx"
+)
+
+type SyncSchemaCommandDeps struct {
+	fx.In
+
+	Ent *ent.Client
+}
+
+func Run(c *cli.Context, d SyncSchemaCommandDeps) error {
+	tx, err := d.Ent.BeginTx(c.Context, nil)
+	if err != nil {
+		return err
+	}
+	defer tx.Rollback()
+
+	dir := c.String("dir")
+	err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+		if info.IsDir() || filepath.Ext(path) != ".json" {
+			return nil
+		}
+
+		// id is the beginning of the filename until the first dot.
+		segments := strings.Split(info.Name(), ".")
+		id := segments[0]
+		name := segments[1]
+		log.Info().Str("path", path).Str("id", id).Msg("processing research")
+
+		// Check if the research exists.
+		research, err := tx.Research.Query().Where(research.ID(id)).Only(c.Context)
+		if err != nil {
+			if ent.IsNotFound(err) {
+				log.Info().Str("id", id).Msg("research does not exist, creating")
+				// Create the research.
+				research, err = tx.Research.Create().
+					SetID(id).
+					SetName(name).
+					Save(c.Context)
+				if err != nil {
+					return err
+				}
+			} else {
+				return err
+			}
+		}
+
+		jsonBytes, err := minifiedJsonFile(path)
+		if err != nil {
+			return err
+		}
+
+		// Update the research schema.
+		log.Info().Str("id", id).Msg("updating research schema")
+		_, err = research.Update().
+			SetSchema(jsonBytes).
+			Save(c.Context)
+		if err != nil {
+			return err
+		}
+
+		return nil
+	})
+
+	if err != nil {
+		return err
+	}
+	log.Info().Msg("committing transaction")
+
+	return tx.Commit()
+}
+
+func minifiedJsonFile(file string) ([]byte, error) {
+	f, err := os.Open(file)
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+
+	return io.ReadAll(f)
+}
diff --git a/go.mod b/go.mod
index 994c8e7..d8ebf8e 100644
--- a/go.mod
+++ b/go.mod
@@ -18,10 +18,12 @@ require (
 	github.com/machinebox/graphql v0.2.2
 	github.com/oklog/ulid/v2 v2.1.0
 	github.com/pkg/errors v0.9.1
+	github.com/redis/go-redis/v9 v9.1.0
 	github.com/resendlabs/resend-go v1.7.0
 	github.com/rs/zerolog v1.30.0
 	github.com/urfave/cli/v2 v2.25.7
 	github.com/vektah/gqlparser/v2 v2.5.8
+	github.com/wagslane/go-password-validator v0.3.0
 	go.uber.org/fx v1.20.0
 	golang.org/x/crypto v0.12.0
 	gopkg.in/guregu/null.v4 v4.0.0
@@ -30,7 +32,10 @@ require (
 require (
 	ariga.io/atlas v0.10.2-0.20230427182402-87a07dfb83bf // indirect
 	github.com/agext/levenshtein v1.2.1 // indirect
+	github.com/ake-persson/mapslice-json v0.0.0-20210720081907-22c8edf57807 // indirect
 	github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect
+	github.com/cespare/xxhash/v2 v2.2.0 // indirect
+	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
 	github.com/go-openapi/inflect v0.19.0 // indirect
 	github.com/google/go-cmp v0.5.9 // indirect
 	github.com/hashicorp/errwrap v1.0.0 // indirect
diff --git a/go.sum b/go.sum
index e2bef57..f018726 100644
--- a/go.sum
+++ b/go.sum
@@ -11,6 +11,8 @@ github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tj
 github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
 github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8=
 github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo=
+github.com/ake-persson/mapslice-json v0.0.0-20210720081907-22c8edf57807 h1:w3nrGk00TWs/4iZ3Q0k9c0vL0e/wRziArKU4e++d/nA=
+github.com/ake-persson/mapslice-json v0.0.0-20210720081907-22c8edf57807/go.mod h1:fGnnfniJiO/ajHAVHqMSUSL8sE9LmU9rzclCtoeB+y8=
 github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
 github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
 github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
@@ -22,6 +24,11 @@ github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkE
 github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
 github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
 github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
+github.com/bsm/ginkgo/v2 v2.9.5 h1:rtVBYPs3+TC5iLUVOis1B9tjLTup7Cj5IfzosKtvTJ0=
+github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y=
+github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
+github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
 github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
 github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
@@ -30,8 +37,11 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g=
 github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
 github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g=
 github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
+github.com/fzipp/gocyclo v0.3.1/go.mod h1:DJHO6AUmbdqj2ET4Z9iArSuwWgYDRryYt2wASxc7x3E=
 github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4=
 github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4=
 github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68=
@@ -49,6 +59,7 @@ github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
 github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gordonklaus/ineffassign v0.0.0-20210522101830-0589229737b2/go.mod h1:M9mZEtGIsR1oDaZagNPNG9iq9n2HrhZ17dsXk73V3Lw=
 github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
 github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
 github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
@@ -63,6 +74,7 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
 github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
 github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
+github.com/kisielk/errcheck v1.6.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
 github.com/klauspost/compress v1.16.3 h1:XuJt9zzcnaz6a16/OU53ZjWp/v7/42WcR5t2a0PcNQY=
 github.com/klauspost/compress v1.16.3/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
 github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
@@ -100,6 +112,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/redis/go-redis/v9 v9.1.0 h1:137FnGdk+EQdCbye1FW+qOEcY5S+SpY9T0NiuqvtfMY=
+github.com/redis/go-redis/v9 v9.1.0/go.mod h1:urWj3He21Dj5k4TK1y59xH8Uj6ATueP8AH1cY3lZl4c=
 github.com/resendlabs/resend-go v1.7.0 h1:DycOqSXtw2q7aB+Nt9DDJUDtaYcrNPGn1t5RFposas0=
 github.com/resendlabs/resend-go v1.7.0/go.mod h1:yip1STH7Bqfm4fD0So5HgyNbt5taG5Cplc4xXxETyLI=
 github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
@@ -137,8 +151,13 @@ github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q
 github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI=
 github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
 github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
+github.com/wagslane/go-password-validator v0.3.0 h1:vfxOPzGHkz5S146HDpavl0cw1DSVP061Ry2PX0/ON6I=
+github.com/wagslane/go-password-validator v0.3.0/go.mod h1:TI1XJ6T5fRdRnHqHt14pvy1tNVnrwe7m3/f1f2fDphQ=
 github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
 github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
+github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
 github.com/zclconf/go-cty v1.8.0 h1:s4AvqaeQzJIu3ndv4gVIhplVD0krU+bgrcLSVUnaWuA=
 github.com/zclconf/go-cty v1.8.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk=
 go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
@@ -153,30 +172,63 @@ go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTV
 go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
 go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk=
 golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
 golang.org/x/exp v0.0.0-20221230185412-738e83a70c30 h1:m9O6OTJ627iFnN2JIWfdqlZCzneRO6EEBsHXI25P8ws=
 golang.org/x/exp v0.0.0-20221230185412-738e83a70c30/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
+golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
 golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
 golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc=
 golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
+golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.9.3 h1:Gn1I8+64MsuTb/HpH+LmQtNas23LhUVr3rYZ0eKuaMM=
 golang.org/x/tools v0.9.3/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/internal/app/appconfig/spec.go b/internal/app/appconfig/spec.go
index 19f1266..73131ff 100644
--- a/internal/app/appconfig/spec.go
+++ b/internal/app/appconfig/spec.go
@@ -11,6 +11,9 @@ type ConfigSpec struct {
 	// DatabaseURL is the URL to the PostgreSQL database.
 	DatabaseURL string `split_words:"true" required:"true"`
 
+	// RedisURL is the URL to the Redis database.
+	RedisURL string `split_words:"true" required:"true"`
+
 	// ServiceListenAddress is the address that the Fiber HTTP server will listen on.
 	ServiceListenAddress string `split_words:"true" required:"true" default:":3000"`
 
@@ -39,6 +42,9 @@ type ConfigSpec struct {
 
 	// ResendApiKey is the API key used to send emails via Resend.
 	ResendApiKey string `split_words:"true" required:"true"`
+
+	// PasswordResetTokenTTL is the time to live of the password reset token.
+	PasswordResetTokenTTL time.Duration `split_words:"true" required:"true" default:"1h"`
 }
 
 type Config struct {
diff --git a/internal/blob/template/password-reset.html.tmpl b/internal/blob/template/password-reset.html.tmpl
new file mode 100644
index 0000000..c832677
--- /dev/null
+++ b/internal/blob/template/password-reset.html.tmpl
@@ -0,0 +1 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html lang="zh"><head data-id="__react-email-head"><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/><meta charSet="utf-8"/><title>你的 RogueStats 登录信息重置请求已就绪</title></head><div id="__react-email-preview" style="display:none;overflow:hidden;line-height:1px;opacity:0;max-height:0;max-width:0">Hi, {{ .Username }}! 你的 RogueStats 登录信息重置请求已就绪<div> ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏</div></div><body data-id="__react-email-body" style="background-color:rgb(255,255,255);margin-top:auto;margin-bottom:auto;margin-left:auto;margin-right:auto;font-family:ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji"><table align="center" width="100%" data-id="__react-email-container" role="presentation" cellSpacing="0" cellPadding="0" border="0" style="max-width:600px;margin-left:auto;margin-right:auto;padding:1rem"><tbody><tr style="width:100%"><td><table align="center" width="100%" data-id="react-email-section" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-top:32px"><tbody><tr><td><img data-id="react-email-img" alt="Penguin Statistics" src="https://penguin.upyun.galvincdn.com/logos/penguin_stats_logo.png" width="64" height="64" style="display:block;outline:none;border:none;text-decoration:none"/></td></tr></tbody></table><h1 data-id="react-email-heading" style="color:rgb(0,0,0);font-size:24px;font-weight:400;padding:0px;margin-top:30px;margin-bottom:30px;margin-left:0px;margin-right:0px">你的 RogueStats 登录信息重置请求已就绪</h1><p data-id="react-email-text" style="font-size:0.875rem;line-height:24px;margin:16px 0;color:rgb(0,0,0);font-weight:400">{{ .Username }},你好:</p><p data-id="react-email-text" style="font-size:0.875rem;line-height:24px;margin:16px 0;color:rgb(0,0,0);font-weight:400">你的 RogueStats 登录信息重置请求已就绪,请点击下方按钮以重置你的 RogueStats 登录信息。此次登录信息重置请求将在 {{ .TokenTTL }} 后失效。</p><table align="center" width="100%" data-id="react-email-section" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="text-align:center;margin-top:32px;margin-bottom:32px"><tbody><tr><td><a href="https://rogue.penguin-stats.io/auth/reset-password?token={{ .Token }}" data-id="react-email-button" target="_blank" style="line-height:100%;text-decoration:none;display:inline-block;max-width:100%;padding:12px 20px;background-color:rgb(0,0,0);border-radius:0.25rem;color:rgb(255,255,255);font-size:12px;font-weight:600;text-decoration-line:none;text-align:center"><span></span><span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px">重置密码</span><span></span></a></td></tr></tbody></table><p data-id="react-email-text" style="font-size:0.875rem;line-height:24px;margin:16px 0;color:rgb(0,0,0);font-weight:400">如果你有任何问题,欢迎随时联系 RogueStats 开发组。</p><p data-id="react-email-text" style="font-size:0.875rem;line-height:24px;margin:16px 0;color:rgb(0,0,0);font-weight:400">祝好,</p><p data-id="react-email-text" style="font-size:0.875rem;line-height:24px;margin:16px 0;color:rgb(0,0,0);font-weight:400">RogueStats 开发组</p><hr data-id="react-email-hr" style="width:100%;border:none;border-top:1px solid #eaeaea;height:1px;background-color:rgb(234,234,234);margin-top:26px;margin-bottom:26px;margin-left:0px;margin-right:0px"/><p data-id="react-email-text" style="font-size:12px;line-height:24px;margin:16px 0;color:rgb(102,102,102);margin-top:0px;margin-bottom:0px">RogueStats 是一个 Penguin Statistics 下的所属项目。Penguin Statistics 是一个由玩家驱动的明日方舟数据统计网站,致力于为玩家提供更好的游戏体验。</p><p data-id="react-email-text" style="font-size:12px;line-height:24px;margin:16px 0;color:rgb(102,102,102);margin-bottom:0px;margin-top:1rem">此邮件是为 Penguin Statistics 自动发送,请勿回复。</p></td></tr></tbody></table></body></html>
\ No newline at end of file
diff --git a/internal/blob/template/password-reset.txt.tmpl b/internal/blob/template/password-reset.txt.tmpl
new file mode 100644
index 0000000..a74aef0
--- /dev/null
+++ b/internal/blob/template/password-reset.txt.tmpl
@@ -0,0 +1,21 @@
+你的 RogueStats 登录信息重置请求已就绪
+
+{{ .Username }},你好:
+
+你的 RogueStats 登录信息重置请求已就绪,请点击下方按钮以重置你的 RogueStats 登录信息。此次登录信息重置请求将在 {{ .TokenTTL
+}} 后失效。
+
+重置密码 [https://rogue.penguin-stats.io/auth/reset-password?token={{ .Token }}]
+
+如果你有任何问题,欢迎随时联系 RogueStats 开发组。
+
+祝好,
+
+RogueStats 开发组
+
+--------------------------------------------------------------------------------
+
+RogueStats 是一个 Penguin Statistics 下的所属项目。Penguin Statistics
+是一个由玩家驱动的明日方舟数据统计网站,致力于为玩家提供更好的游戏体验。
+
+此邮件是为 Penguin Statistics 自动发送,请勿回复。
\ No newline at end of file
diff --git a/internal/ent/migrate/schema.go b/internal/ent/migrate/schema.go
index 836d54b..51a3d9f 100644
--- a/internal/ent/migrate/schema.go
+++ b/internal/ent/migrate/schema.go
@@ -63,7 +63,7 @@ var (
 	ResearchesColumns = []*schema.Column{
 		{Name: "research_id", Type: field.TypeString, Unique: true},
 		{Name: "name", Type: field.TypeString, Size: 64},
-		{Name: "schema", Type: field.TypeJSON},
+		{Name: "schema", Type: field.TypeBytes},
 	}
 	// ResearchesTable holds the schema information for the "researches" table.
 	ResearchesTable = &schema.Table{
diff --git a/internal/ent/mutation.go b/internal/ent/mutation.go
index 4ec6c0d..1de8786 100644
--- a/internal/ent/mutation.go
+++ b/internal/ent/mutation.go
@@ -684,7 +684,7 @@ type ResearchMutation struct {
 	typ           string
 	id            *string
 	name          *string
-	schema        *map[string]interface{}
+	schema        *[]byte
 	clearedFields map[string]struct{}
 	events        map[string]struct{}
 	removedevents map[string]struct{}
@@ -835,12 +835,12 @@ func (m *ResearchMutation) ResetName() {
 }
 
 // SetSchema sets the "schema" field.
-func (m *ResearchMutation) SetSchema(value map[string]interface{}) {
-	m.schema = &value
+func (m *ResearchMutation) SetSchema(b []byte) {
+	m.schema = &b
 }
 
 // Schema returns the value of the "schema" field in the mutation.
-func (m *ResearchMutation) Schema() (r map[string]interface{}, exists bool) {
+func (m *ResearchMutation) Schema() (r []byte, exists bool) {
 	v := m.schema
 	if v == nil {
 		return
@@ -851,7 +851,7 @@ func (m *ResearchMutation) Schema() (r map[string]interface{}, exists bool) {
 // OldSchema returns the old "schema" field's value of the Research entity.
 // If the Research object wasn't provided to the builder, the object is fetched from the database.
 // An error is returned if the mutation operation is not UpdateOne, or the database query fails.
-func (m *ResearchMutation) OldSchema(ctx context.Context) (v map[string]interface{}, err error) {
+func (m *ResearchMutation) OldSchema(ctx context.Context) (v []byte, err error) {
 	if !m.op.Is(OpUpdateOne) {
 		return v, errors.New("OldSchema is only allowed on UpdateOne operations")
 	}
@@ -1007,7 +1007,7 @@ func (m *ResearchMutation) SetField(name string, value ent.Value) error {
 		m.SetName(v)
 		return nil
 	case research.FieldSchema:
-		v, ok := value.(map[string]interface{})
+		v, ok := value.([]byte)
 		if !ok {
 			return fmt.Errorf("unexpected type %T for field %s", value, name)
 		}
diff --git a/internal/ent/research.go b/internal/ent/research.go
index 8401c68..12dd90f 100644
--- a/internal/ent/research.go
+++ b/internal/ent/research.go
@@ -3,7 +3,6 @@
 package ent
 
 import (
-	"encoding/json"
 	"fmt"
 	"strings"
 
@@ -20,7 +19,7 @@ type Research struct {
 	// Name holds the value of the "name" field.
 	Name string `json:"name,omitempty"`
 	// Schema holds the value of the "schema" field.
-	Schema map[string]interface{} `json:"schema,omitempty"`
+	Schema []byte `json:"schema,omitempty"`
 	// Edges holds the relations/edges for other nodes in the graph.
 	// The values are being populated by the ResearchQuery when eager-loading is set.
 	Edges        ResearchEdges `json:"edges"`
@@ -88,10 +87,8 @@ func (r *Research) assignValues(columns []string, values []any) error {
 		case research.FieldSchema:
 			if value, ok := values[i].(*[]byte); !ok {
 				return fmt.Errorf("unexpected type %T for field schema", values[i])
-			} else if value != nil && len(*value) > 0 {
-				if err := json.Unmarshal(*value, &r.Schema); err != nil {
-					return fmt.Errorf("unmarshal field schema: %w", err)
-				}
+			} else if value != nil {
+				r.Schema = *value
 			}
 		default:
 			r.selectValues.Set(columns[i], values[i])
diff --git a/internal/ent/research/where.go b/internal/ent/research/where.go
index 414cda7..e2c76e9 100644
--- a/internal/ent/research/where.go
+++ b/internal/ent/research/where.go
@@ -68,6 +68,11 @@ func Name(v string) predicate.Research {
 	return predicate.Research(sql.FieldEQ(FieldName, v))
 }
 
+// Schema applies equality check predicate on the "schema" field. It's identical to SchemaEQ.
+func Schema(v []byte) predicate.Research {
+	return predicate.Research(sql.FieldEQ(FieldSchema, v))
+}
+
 // NameEQ applies the EQ predicate on the "name" field.
 func NameEQ(v string) predicate.Research {
 	return predicate.Research(sql.FieldEQ(FieldName, v))
@@ -133,6 +138,46 @@ func NameContainsFold(v string) predicate.Research {
 	return predicate.Research(sql.FieldContainsFold(FieldName, v))
 }
 
+// SchemaEQ applies the EQ predicate on the "schema" field.
+func SchemaEQ(v []byte) predicate.Research {
+	return predicate.Research(sql.FieldEQ(FieldSchema, v))
+}
+
+// SchemaNEQ applies the NEQ predicate on the "schema" field.
+func SchemaNEQ(v []byte) predicate.Research {
+	return predicate.Research(sql.FieldNEQ(FieldSchema, v))
+}
+
+// SchemaIn applies the In predicate on the "schema" field.
+func SchemaIn(vs ...[]byte) predicate.Research {
+	return predicate.Research(sql.FieldIn(FieldSchema, vs...))
+}
+
+// SchemaNotIn applies the NotIn predicate on the "schema" field.
+func SchemaNotIn(vs ...[]byte) predicate.Research {
+	return predicate.Research(sql.FieldNotIn(FieldSchema, vs...))
+}
+
+// SchemaGT applies the GT predicate on the "schema" field.
+func SchemaGT(v []byte) predicate.Research {
+	return predicate.Research(sql.FieldGT(FieldSchema, v))
+}
+
+// SchemaGTE applies the GTE predicate on the "schema" field.
+func SchemaGTE(v []byte) predicate.Research {
+	return predicate.Research(sql.FieldGTE(FieldSchema, v))
+}
+
+// SchemaLT applies the LT predicate on the "schema" field.
+func SchemaLT(v []byte) predicate.Research {
+	return predicate.Research(sql.FieldLT(FieldSchema, v))
+}
+
+// SchemaLTE applies the LTE predicate on the "schema" field.
+func SchemaLTE(v []byte) predicate.Research {
+	return predicate.Research(sql.FieldLTE(FieldSchema, v))
+}
+
 // HasEvents applies the HasEdge predicate on the "events" edge.
 func HasEvents() predicate.Research {
 	return predicate.Research(func(s *sql.Selector) {
diff --git a/internal/ent/research_create.go b/internal/ent/research_create.go
index ab55073..dcc9c7f 100644
--- a/internal/ent/research_create.go
+++ b/internal/ent/research_create.go
@@ -27,8 +27,8 @@ func (rc *ResearchCreate) SetName(s string) *ResearchCreate {
 }
 
 // SetSchema sets the "schema" field.
-func (rc *ResearchCreate) SetSchema(m map[string]interface{}) *ResearchCreate {
-	rc.mutation.SetSchema(m)
+func (rc *ResearchCreate) SetSchema(b []byte) *ResearchCreate {
+	rc.mutation.SetSchema(b)
 	return rc
 }
 
@@ -155,7 +155,7 @@ func (rc *ResearchCreate) createSpec() (*Research, *sqlgraph.CreateSpec) {
 		_node.Name = value
 	}
 	if value, ok := rc.mutation.Schema(); ok {
-		_spec.SetField(research.FieldSchema, field.TypeJSON, value)
+		_spec.SetField(research.FieldSchema, field.TypeBytes, value)
 		_node.Schema = value
 	}
 	if nodes := rc.mutation.EventsIDs(); len(nodes) > 0 {
diff --git a/internal/ent/research_update.go b/internal/ent/research_update.go
index 3a75c48..f54051d 100644
--- a/internal/ent/research_update.go
+++ b/internal/ent/research_update.go
@@ -35,8 +35,8 @@ func (ru *ResearchUpdate) SetName(s string) *ResearchUpdate {
 }
 
 // SetSchema sets the "schema" field.
-func (ru *ResearchUpdate) SetSchema(m map[string]interface{}) *ResearchUpdate {
-	ru.mutation.SetSchema(m)
+func (ru *ResearchUpdate) SetSchema(b []byte) *ResearchUpdate {
+	ru.mutation.SetSchema(b)
 	return ru
 }
 
@@ -134,7 +134,7 @@ func (ru *ResearchUpdate) sqlSave(ctx context.Context) (n int, err error) {
 		_spec.SetField(research.FieldName, field.TypeString, value)
 	}
 	if value, ok := ru.mutation.Schema(); ok {
-		_spec.SetField(research.FieldSchema, field.TypeJSON, value)
+		_spec.SetField(research.FieldSchema, field.TypeBytes, value)
 	}
 	if ru.mutation.EventsCleared() {
 		edge := &sqlgraph.EdgeSpec{
@@ -208,8 +208,8 @@ func (ruo *ResearchUpdateOne) SetName(s string) *ResearchUpdateOne {
 }
 
 // SetSchema sets the "schema" field.
-func (ruo *ResearchUpdateOne) SetSchema(m map[string]interface{}) *ResearchUpdateOne {
-	ruo.mutation.SetSchema(m)
+func (ruo *ResearchUpdateOne) SetSchema(b []byte) *ResearchUpdateOne {
+	ruo.mutation.SetSchema(b)
 	return ruo
 }
 
@@ -337,7 +337,7 @@ func (ruo *ResearchUpdateOne) sqlSave(ctx context.Context) (_node *Research, err
 		_spec.SetField(research.FieldName, field.TypeString, value)
 	}
 	if value, ok := ruo.mutation.Schema(); ok {
-		_spec.SetField(research.FieldSchema, field.TypeJSON, value)
+		_spec.SetField(research.FieldSchema, field.TypeBytes, value)
 	}
 	if ruo.mutation.EventsCleared() {
 		edge := &sqlgraph.EdgeSpec{
diff --git a/internal/ent/schema/research.go b/internal/ent/schema/research.go
index 7bf6fc2..360c50c 100644
--- a/internal/ent/schema/research.go
+++ b/internal/ent/schema/research.go
@@ -28,7 +28,7 @@ func (Research) Fields() []ent.Field {
 				entgql.OrderField("ID"),
 			),
 		field.String("name").MaxLen(64),
-		field.JSON("schema", map[string]any{}),
+		field.Bytes("schema"),
 	}
 }
 
diff --git a/internal/graph/defs/schema.graphqls b/internal/graph/defs/schema.graphqls
index 048f189..65eb1ae 100644
--- a/internal/graph/defs/schema.graphqls
+++ b/internal/graph/defs/schema.graphqls
@@ -10,6 +10,7 @@ https://relay.dev/graphql/connections.htm#sec-Cursor
 scalar Cursor
 scalar Time
 scalar Any
+scalar Void
 
 type Event implements Node {
   id: ID!
@@ -142,7 +143,7 @@ type PageInfo {
 type Research implements Node {
   id: ID!
   name: String!
-  schema: Map!
+  schema: Any!
 }
 
 """
@@ -311,9 +312,21 @@ input LoginInput {
   turnstileResponse: String!
 }
 
+input RequestPasswordResetInput {
+  email: String!
+  turnstileResponse: String!
+}
+
+input ResetPasswordInput {
+  token: String!
+  password: String!
+}
+
 type Mutation {
   login(input: LoginInput!): User!
   createEvent(input: CreateEventInput!): Event!
+  requestPasswordReset(input: RequestPasswordResetInput!): Boolean!
+  resetPassword(input: ResetPasswordInput!): Boolean!
 
   createUser(input: CreateUserInput!): User! @admin
 }
diff --git a/internal/graph/generated.go b/internal/graph/generated.go
index aeb4510..93ce135 100644
--- a/internal/graph/generated.go
+++ b/internal/graph/generated.go
@@ -42,6 +42,7 @@ type Config struct {
 type ResolverRoot interface {
 	Mutation() MutationResolver
 	Query() QueryResolver
+	Research() ResearchResolver
 }
 
 type DirectiveRoot struct {
@@ -81,9 +82,11 @@ type ComplexityRoot struct {
 	}
 
 	Mutation struct {
-		CreateEvent func(childComplexity int, input model.CreateEventInput) int
-		CreateUser  func(childComplexity int, input model.CreateUserInput) int
-		Login       func(childComplexity int, input model.LoginInput) int
+		CreateEvent          func(childComplexity int, input model.CreateEventInput) int
+		CreateUser           func(childComplexity int, input model.CreateUserInput) int
+		Login                func(childComplexity int, input model.LoginInput) int
+		RequestPasswordReset func(childComplexity int, input model.RequestPasswordResetInput) int
+		ResetPassword        func(childComplexity int, input model.ResetPasswordInput) int
 	}
 
 	PageInfo struct {
@@ -132,6 +135,8 @@ type ComplexityRoot struct {
 type MutationResolver interface {
 	Login(ctx context.Context, input model.LoginInput) (*ent.User, error)
 	CreateEvent(ctx context.Context, input model.CreateEventInput) (*ent.Event, error)
+	RequestPasswordReset(ctx context.Context, input model.RequestPasswordResetInput) (bool, error)
+	ResetPassword(ctx context.Context, input model.ResetPasswordInput) (bool, error)
 	CreateUser(ctx context.Context, input model.CreateUserInput) (*ent.User, error)
 }
 type QueryResolver interface {
@@ -144,6 +149,9 @@ type QueryResolver interface {
 	Research(ctx context.Context, id string) (*ent.Research, error)
 	Researches(ctx context.Context, after *entgql.Cursor[string], first *int, before *entgql.Cursor[string], last *int, orderBy *ent.ResearchOrder) (*ent.ResearchConnection, error)
 }
+type ResearchResolver interface {
+	Schema(ctx context.Context, obj *ent.Research) (interface{}, error)
+}
 
 type executableSchema struct {
 	resolvers  ResolverRoot
@@ -301,6 +309,30 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
 
 		return e.complexity.Mutation.Login(childComplexity, args["input"].(model.LoginInput)), true
 
+	case "Mutation.requestPasswordReset":
+		if e.complexity.Mutation.RequestPasswordReset == nil {
+			break
+		}
+
+		args, err := ec.field_Mutation_requestPasswordReset_args(context.TODO(), rawArgs)
+		if err != nil {
+			return 0, false
+		}
+
+		return e.complexity.Mutation.RequestPasswordReset(childComplexity, args["input"].(model.RequestPasswordResetInput)), true
+
+	case "Mutation.resetPassword":
+		if e.complexity.Mutation.ResetPassword == nil {
+			break
+		}
+
+		args, err := ec.field_Mutation_resetPassword_args(context.TODO(), rawArgs)
+		if err != nil {
+			return 0, false
+		}
+
+		return e.complexity.Mutation.ResetPassword(childComplexity, args["input"].(model.ResetPasswordInput)), true
+
 	case "PageInfo.endCursor":
 		if e.complexity.PageInfo.EndCursor == nil {
 			break
@@ -517,7 +549,9 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler {
 		ec.unmarshalInputEventOrder,
 		ec.unmarshalInputGroupCountInput,
 		ec.unmarshalInputLoginInput,
+		ec.unmarshalInputRequestPasswordResetInput,
 		ec.unmarshalInputResearchOrder,
+		ec.unmarshalInputResetPasswordInput,
 	)
 	first := true
 
@@ -694,6 +728,36 @@ func (ec *executionContext) field_Mutation_login_args(ctx context.Context, rawAr
 	return args, nil
 }
 
+func (ec *executionContext) field_Mutation_requestPasswordReset_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
+	var err error
+	args := map[string]interface{}{}
+	var arg0 model.RequestPasswordResetInput
+	if tmp, ok := rawArgs["input"]; ok {
+		ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("input"))
+		arg0, err = ec.unmarshalNRequestPasswordResetInput2exusiaiᚗdevᚋroguestatsᚑbackendᚋinternalᚋmodelᚐRequestPasswordResetInput(ctx, tmp)
+		if err != nil {
+			return nil, err
+		}
+	}
+	args["input"] = arg0
+	return args, nil
+}
+
+func (ec *executionContext) field_Mutation_resetPassword_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
+	var err error
+	args := map[string]interface{}{}
+	var arg0 model.ResetPasswordInput
+	if tmp, ok := rawArgs["input"]; ok {
+		ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("input"))
+		arg0, err = ec.unmarshalNResetPasswordInput2exusiaiᚗdevᚋroguestatsᚑbackendᚋinternalᚋmodelᚐResetPasswordInput(ctx, tmp)
+		if err != nil {
+			return nil, err
+		}
+	}
+	args["input"] = arg0
+	return args, nil
+}
+
 func (ec *executionContext) field_Query___type_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
 	var err error
 	args := map[string]interface{}{}
@@ -1766,6 +1830,116 @@ func (ec *executionContext) fieldContext_Mutation_createEvent(ctx context.Contex
 	return fc, nil
 }
 
+func (ec *executionContext) _Mutation_requestPasswordReset(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_Mutation_requestPasswordReset(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.Mutation().RequestPasswordReset(rctx, fc.Args["input"].(model.RequestPasswordResetInput))
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(bool)
+	fc.Result = res
+	return ec.marshalNBoolean2bool(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_Mutation_requestPasswordReset(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "Mutation",
+		Field:      field,
+		IsMethod:   true,
+		IsResolver: true,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type Boolean does not have child fields")
+		},
+	}
+	defer func() {
+		if r := recover(); r != nil {
+			err = ec.Recover(ctx, r)
+			ec.Error(ctx, err)
+		}
+	}()
+	ctx = graphql.WithFieldContext(ctx, fc)
+	if fc.Args, err = ec.field_Mutation_requestPasswordReset_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
+		ec.Error(ctx, err)
+		return fc, err
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _Mutation_resetPassword(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_Mutation_resetPassword(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.Mutation().ResetPassword(rctx, fc.Args["input"].(model.ResetPasswordInput))
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(bool)
+	fc.Result = res
+	return ec.marshalNBoolean2bool(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_Mutation_resetPassword(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "Mutation",
+		Field:      field,
+		IsMethod:   true,
+		IsResolver: true,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type Boolean does not have child fields")
+		},
+	}
+	defer func() {
+		if r := recover(); r != nil {
+			err = ec.Recover(ctx, r)
+			ec.Error(ctx, err)
+		}
+	}()
+	ctx = graphql.WithFieldContext(ctx, fc)
+	if fc.Args, err = ec.field_Mutation_resetPassword_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
+		ec.Error(ctx, err)
+		return fc, err
+	}
+	return fc, nil
+}
+
 func (ec *executionContext) _Mutation_createUser(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
 	fc, err := ec.fieldContext_Mutation_createUser(ctx, field)
 	if err != nil {
@@ -2723,7 +2897,7 @@ func (ec *executionContext) _Research_schema(ctx context.Context, field graphql.
 	}()
 	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
 		ctx = rctx // use context from middleware stack in children
-		return obj.Schema, nil
+		return ec.resolvers.Research().Schema(rctx, obj)
 	})
 	if err != nil {
 		ec.Error(ctx, err)
@@ -2735,19 +2909,19 @@ func (ec *executionContext) _Research_schema(ctx context.Context, field graphql.
 		}
 		return graphql.Null
 	}
-	res := resTmp.(map[string]interface{})
+	res := resTmp.(interface{})
 	fc.Result = res
-	return ec.marshalNMap2map(ctx, field.Selections, res)
+	return ec.marshalNAny2interface(ctx, field.Selections, res)
 }
 
 func (ec *executionContext) fieldContext_Research_schema(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
 	fc = &graphql.FieldContext{
 		Object:     "Research",
 		Field:      field,
-		IsMethod:   false,
-		IsResolver: false,
+		IsMethod:   true,
+		IsResolver: true,
 		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
-			return nil, errors.New("field of type Map does not have child fields")
+			return nil, errors.New("field of type Any does not have child fields")
 		},
 	}
 	return fc, nil
@@ -5218,6 +5392,44 @@ func (ec *executionContext) unmarshalInputLoginInput(ctx context.Context, obj in
 	return it, nil
 }
 
+func (ec *executionContext) unmarshalInputRequestPasswordResetInput(ctx context.Context, obj interface{}) (model.RequestPasswordResetInput, error) {
+	var it model.RequestPasswordResetInput
+	asMap := map[string]interface{}{}
+	for k, v := range obj.(map[string]interface{}) {
+		asMap[k] = v
+	}
+
+	fieldsInOrder := [...]string{"email", "turnstileResponse"}
+	for _, k := range fieldsInOrder {
+		v, ok := asMap[k]
+		if !ok {
+			continue
+		}
+		switch k {
+		case "email":
+			var err error
+
+			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("email"))
+			data, err := ec.unmarshalNString2string(ctx, v)
+			if err != nil {
+				return it, err
+			}
+			it.Email = data
+		case "turnstileResponse":
+			var err error
+
+			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("turnstileResponse"))
+			data, err := ec.unmarshalNString2string(ctx, v)
+			if err != nil {
+				return it, err
+			}
+			it.TurnstileResponse = data
+		}
+	}
+
+	return it, nil
+}
+
 func (ec *executionContext) unmarshalInputResearchOrder(ctx context.Context, obj interface{}) (ent.ResearchOrder, error) {
 	var it ent.ResearchOrder
 	asMap := map[string]interface{}{}
@@ -5260,6 +5472,44 @@ func (ec *executionContext) unmarshalInputResearchOrder(ctx context.Context, obj
 	return it, nil
 }
 
+func (ec *executionContext) unmarshalInputResetPasswordInput(ctx context.Context, obj interface{}) (model.ResetPasswordInput, error) {
+	var it model.ResetPasswordInput
+	asMap := map[string]interface{}{}
+	for k, v := range obj.(map[string]interface{}) {
+		asMap[k] = v
+	}
+
+	fieldsInOrder := [...]string{"token", "password"}
+	for _, k := range fieldsInOrder {
+		v, ok := asMap[k]
+		if !ok {
+			continue
+		}
+		switch k {
+		case "token":
+			var err error
+
+			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("token"))
+			data, err := ec.unmarshalNString2string(ctx, v)
+			if err != nil {
+				return it, err
+			}
+			it.Token = data
+		case "password":
+			var err error
+
+			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("password"))
+			data, err := ec.unmarshalNString2string(ctx, v)
+			if err != nil {
+				return it, err
+			}
+			it.Password = data
+		}
+	}
+
+	return it, nil
+}
+
 // endregion **************************** input.gotpl *****************************
 
 // region    ************************** interface.gotpl ***************************
@@ -5626,6 +5876,20 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet)
 			if out.Values[i] == graphql.Null {
 				out.Invalids++
 			}
+		case "requestPasswordReset":
+			out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) {
+				return ec._Mutation_requestPasswordReset(ctx, field)
+			})
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "resetPassword":
+			out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) {
+				return ec._Mutation_resetPassword(ctx, field)
+			})
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
 		case "createUser":
 			out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) {
 				return ec._Mutation_createUser(ctx, field)
@@ -5932,18 +6196,49 @@ func (ec *executionContext) _Research(ctx context.Context, sel ast.SelectionSet,
 		case "id":
 			out.Values[i] = ec._Research_id(ctx, field, obj)
 			if out.Values[i] == graphql.Null {
-				out.Invalids++
+				atomic.AddUint32(&out.Invalids, 1)
 			}
 		case "name":
 			out.Values[i] = ec._Research_name(ctx, field, obj)
 			if out.Values[i] == graphql.Null {
-				out.Invalids++
+				atomic.AddUint32(&out.Invalids, 1)
 			}
 		case "schema":
-			out.Values[i] = ec._Research_schema(ctx, field, obj)
-			if out.Values[i] == graphql.Null {
-				out.Invalids++
+			field := field
+
+			innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) {
+				defer func() {
+					if r := recover(); r != nil {
+						ec.Error(ctx, ec.Recover(ctx, r))
+					}
+				}()
+				res = ec._Research_schema(ctx, field, obj)
+				if res == graphql.Null {
+					atomic.AddUint32(&fs.Invalids, 1)
+				}
+				return res
+			}
+
+			if field.Deferrable != nil {
+				dfs, ok := deferred[field.Deferrable.Label]
+				di := 0
+				if ok {
+					dfs.AddField(field)
+					di = len(dfs.Values) - 1
+				} else {
+					dfs = graphql.NewFieldSet([]graphql.CollectedField{field})
+					deferred[field.Deferrable.Label] = dfs
+				}
+				dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler {
+					return innerFunc(ctx, dfs)
+				})
+
+				// don't run the out.Concurrently() call below
+				out.Values[i] = graphql.Null
+				continue
 			}
+
+			out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
 		default:
 			panic("unknown field " + strconv.Quote(field.Name))
 		}
@@ -6801,6 +7096,11 @@ func (ec *executionContext) marshalNPageInfo2entgoᚗioᚋcontribᚋentgqlᚐPag
 	return ec._PageInfo(ctx, sel, &v)
 }
 
+func (ec *executionContext) unmarshalNRequestPasswordResetInput2exusiaiᚗdevᚋroguestatsᚑbackendᚋinternalᚋmodelᚐRequestPasswordResetInput(ctx context.Context, v interface{}) (model.RequestPasswordResetInput, error) {
+	res, err := ec.unmarshalInputRequestPasswordResetInput(ctx, v)
+	return res, graphql.ErrorOnPath(ctx, err)
+}
+
 func (ec *executionContext) marshalNResearch2ᚖexusiaiᚗdevᚋroguestatsᚑbackendᚋinternalᚋentᚐResearch(ctx context.Context, sel ast.SelectionSet, v *ent.Research) graphql.Marshaler {
 	if v == nil {
 		if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
@@ -6895,6 +7195,11 @@ func (ec *executionContext) marshalNResearchOrderField2ᚖexusiaiᚗdevᚋrogues
 	return v
 }
 
+func (ec *executionContext) unmarshalNResetPasswordInput2exusiaiᚗdevᚋroguestatsᚑbackendᚋinternalᚋmodelᚐResetPasswordInput(ctx context.Context, v interface{}) (model.ResetPasswordInput, error) {
+	res, err := ec.unmarshalInputResetPasswordInput(ctx, v)
+	return res, graphql.ErrorOnPath(ctx, err)
+}
+
 func (ec *executionContext) unmarshalNString2string(ctx context.Context, v interface{}) (string, error) {
 	res, err := graphql.UnmarshalString(v)
 	return res, graphql.ErrorOnPath(ctx, err)
diff --git a/internal/graph/schema.resolvers.go b/internal/graph/schema.resolvers.go
index 4d222b4..e9c5fba 100644
--- a/internal/graph/schema.resolvers.go
+++ b/internal/graph/schema.resolvers.go
@@ -6,6 +6,7 @@ package graph
 
 import (
 	"context"
+	"encoding/json"
 
 	"entgo.io/contrib/entgql"
 	"exusiai.dev/roguestats-backend/internal/ent"
@@ -22,6 +23,16 @@ func (r *mutationResolver) CreateEvent(ctx context.Context, input model.CreateEv
 	return r.EventService.CreateEventFromInput(ctx, input)
 }
 
+// RequestPasswordReset is the resolver for the requestPasswordReset field.
+func (r *mutationResolver) RequestPasswordReset(ctx context.Context, input model.RequestPasswordResetInput) (bool, error) {
+	return r.AuthService.RequestPasswordReset(ctx, input)
+}
+
+// ResetPassword is the resolver for the resetPassword field.
+func (r *mutationResolver) ResetPassword(ctx context.Context, input model.ResetPasswordInput) (bool, error) {
+	return r.AuthService.ResetPassword(ctx, input)
+}
+
 // CreateUser is the resolver for the createUser field.
 func (r *mutationResolver) CreateUser(ctx context.Context, input model.CreateUserInput) (*ent.User, error) {
 	return r.AuthService.CreateUser(ctx, input)
@@ -73,11 +84,20 @@ func (r *queryResolver) Researches(ctx context.Context, after *entgql.Cursor[str
 		)
 }
 
+// Schema is the resolver for the schema field.
+func (r *researchResolver) Schema(ctx context.Context, obj *ent.Research) (interface{}, error) {
+	return json.RawMessage(obj.Schema), nil
+}
+
 // Mutation returns MutationResolver implementation.
 func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
 
 // Query returns QueryResolver implementation.
 func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }
 
+// Research returns ResearchResolver implementation.
+func (r *Resolver) Research() ResearchResolver { return &researchResolver{r} }
+
 type mutationResolver struct{ *Resolver }
 type queryResolver struct{ *Resolver }
+type researchResolver struct{ *Resolver }
diff --git a/internal/infra/0module.go b/internal/infra/0module.go
index 073fb4a..b22f231 100644
--- a/internal/infra/0module.go
+++ b/internal/infra/0module.go
@@ -7,5 +7,5 @@ import (
 )
 
 func Module() fx.Option {
-	return fx.Module("infra", db.Module(), fx.Provide(Resend))
+	return fx.Module("infra", db.Module(), fx.Provide(Resend), fx.Provide(Redis))
 }
diff --git a/internal/infra/db/db.go b/internal/infra/db/db.go
index c8c81d8..5f3d5e2 100644
--- a/internal/infra/db/db.go
+++ b/internal/infra/db/db.go
@@ -20,5 +20,5 @@ func New(conf *appconfig.Config) *ent.Client {
 	if err := client.Schema.Create(context.Background()); err != nil {
 		log.Fatal().Err(err).Msg("failed creating schema resources")
 	}
-	return client.Debug()
+	return client
 }
diff --git a/internal/infra/redis.go b/internal/infra/redis.go
new file mode 100644
index 0000000..d42d2fd
--- /dev/null
+++ b/internal/infra/redis.go
@@ -0,0 +1,33 @@
+package infra
+
+import (
+	"context"
+	"time"
+
+	"github.com/redis/go-redis/v9"
+	"github.com/rs/zerolog/log"
+
+	"exusiai.dev/roguestats-backend/internal/app/appconfig"
+)
+
+func Redis(conf *appconfig.Config) (*redis.Client, error) {
+	u, err := redis.ParseURL(conf.RedisURL)
+	if err != nil {
+		log.Error().Err(err).Msg("infra: redis: failed to parse redis url")
+		return nil, err
+	}
+
+	// Open a Redis Client
+	client := redis.NewClient(u)
+
+	// check redis connection
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
+	defer cancel()
+	ping := client.Ping(ctx)
+	if ping.Err() != nil {
+		log.Error().Err(ping.Err()).Msg("infra: redis: failed to ping database")
+		return nil, ping.Err()
+	}
+
+	return client, nil
+}
diff --git a/internal/model/models_gen.go b/internal/model/models_gen.go
index 99c4227..929219f 100644
--- a/internal/model/models_gen.go
+++ b/internal/model/models_gen.go
@@ -29,3 +29,13 @@ type GroupCountResult struct {
 	Results []*CategoryCount `json:"results"`
 	Total   int              `json:"total"`
 }
+
+type RequestPasswordResetInput struct {
+	Email             string `json:"email"`
+	TurnstileResponse string `json:"turnstileResponse"`
+}
+
+type ResetPasswordInput struct {
+	Token    string `json:"token"`
+	Password string `json:"password"`
+}
diff --git a/internal/service/auth.go b/internal/service/auth.go
index 6e1a518..615da1b 100644
--- a/internal/service/auth.go
+++ b/internal/service/auth.go
@@ -2,12 +2,13 @@ package service
 
 import (
 	"context"
-	"crypto/rand"
-	"encoding/base64"
 	"time"
 
+	"github.com/dchest/uniuri"
 	"github.com/pkg/errors"
+	"github.com/redis/go-redis/v9"
 	"github.com/vektah/gqlparser/v2/gqlerror"
+	passwordvalidator "github.com/wagslane/go-password-validator"
 	"go.uber.org/fx"
 	"golang.org/x/crypto/bcrypt"
 
@@ -17,6 +18,7 @@ import (
 	"exusiai.dev/roguestats-backend/internal/ent"
 	"exusiai.dev/roguestats-backend/internal/ent/user"
 	"exusiai.dev/roguestats-backend/internal/model"
+	"exusiai.dev/roguestats-backend/internal/x/rediskey"
 )
 
 type Auth struct {
@@ -24,6 +26,7 @@ type Auth struct {
 
 	Config      *appconfig.Config
 	Ent         *ent.Client
+	Redis       *redis.Client
 	JWT         JWT
 	Turnstile   Turnstile
 	MailService Mail
@@ -32,7 +35,7 @@ type Auth struct {
 func (s Auth) AuthByLoginInput(ctx context.Context, args model.LoginInput) (*ent.User, error) {
 	err := s.Turnstile.Verify(ctx, args.TurnstileResponse, "login")
 	if err != nil {
-		return nil, gqlerror.Errorf("captcha verification failed: invalid turnstile response")
+		return nil, err
 	}
 
 	user, err := s.Ent.User.Query().
@@ -73,12 +76,7 @@ func (s Auth) AuthByToken(ctx context.Context, token string) (*ent.User, error)
 }
 
 func (s Auth) CreateUser(ctx context.Context, args model.CreateUserInput) (*ent.User, error) {
-	var randomBytes [16]byte
-	_, err := rand.Read(randomBytes[:])
-	if err != nil {
-		return nil, err
-	}
-	randomString := base64.RawURLEncoding.EncodeToString(randomBytes[:])
+	randomString := uniuri.NewLen(24)
 
 	hashedPassword, err := bcrypt.GenerateFromPassword([]byte(randomString), 12)
 	if err != nil {
@@ -140,3 +138,81 @@ func (s Auth) SetUserToken(ctx context.Context, user *ent.User) error {
 
 	return nil
 }
+
+func (s Auth) RequestPasswordReset(ctx context.Context, input model.RequestPasswordResetInput) (bool, error) {
+	err := s.Turnstile.Verify(ctx, input.TurnstileResponse, "reset-password")
+	if err != nil {
+		return false, err
+	}
+	user, err := s.Ent.User.Query().
+		Where(user.Email(input.Email)).
+		First(ctx)
+	if err != nil {
+		return false, err
+	}
+
+	resetToken := user.ID + "_" + uniuri.NewLen(32)
+
+	err = s.Redis.Set(ctx, rediskey.ResetToken(resetToken), user.ID, s.Config.PasswordResetTokenTTL).Err()
+	if err != nil {
+		return false, err
+	}
+
+	rendered, err := blob.RenderPair("password-reset", map[string]any{
+		"Username": user.Name,
+		"Token":    resetToken,
+		"TokenTTL": s.Config.PasswordResetTokenTTL.String(),
+	})
+	if err != nil {
+		return false, err
+	}
+
+	_, err = s.MailService.Send(&SendEmailRequest{
+		To:      []string{user.Email},
+		Subject: "你的 RogueStats 登录信息重置请求已就绪",
+		Html:    rendered.HTML,
+		Text:    rendered.Text,
+	})
+	if err != nil {
+		return false, err
+	}
+
+	return true, nil
+}
+
+func (s Auth) ResetPassword(ctx context.Context, input model.ResetPasswordInput) (bool, error) {
+	cmd := s.Redis.Get(ctx, rediskey.ResetToken(input.Token))
+	if cmd.Err() != nil {
+		return false, gqlerror.Errorf("invalid password reset token: the token is either expired, invalid or has been used")
+	}
+
+	user, err := s.Ent.User.Query().
+		Where(user.ID(cmd.Val())).
+		First(ctx)
+	if err != nil {
+		return false, err
+	}
+
+	err = passwordvalidator.Validate(input.Password, 60)
+	if err != nil {
+		return false, err
+	}
+
+	hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), 12)
+	if err != nil {
+		return false, err
+	}
+
+	_, err = s.Ent.User.UpdateOneID(user.ID).
+		SetCredential(string(hashedPassword)).
+		Save(ctx)
+	if err != nil {
+		return false, err
+	}
+
+	if s.Redis.Del(ctx, rediskey.ResetToken(input.Token)).Err() != nil {
+		return false, err
+	}
+
+	return true, nil
+}
diff --git a/internal/service/event.go b/internal/service/event.go
index 21af664..e403c34 100644
--- a/internal/service/event.go
+++ b/internal/service/event.go
@@ -3,7 +3,6 @@ package service
 import (
 	"context"
 	"database/sql"
-	"encoding/json"
 	"fmt"
 	"reflect"
 	"sort"
@@ -44,11 +43,7 @@ func (s Event) CreateEventFromInput(ctx context.Context, input model.CreateEvent
 	}
 
 	// validate event json
-	schema, err := json.Marshal(research.Schema)
-	if err != nil {
-		return nil, err
-	}
-	sch, err := jsonschema.CompileString("schema.json", string(schema))
+	sch, err := jsonschema.CompileString("schema.json", string(research.Schema))
 	if err != nil {
 		return nil, err
 	}
diff --git a/internal/x/rediskey/rediskey.go b/internal/x/rediskey/rediskey.go
new file mode 100644
index 0000000..217f753
--- /dev/null
+++ b/internal/x/rediskey/rediskey.go
@@ -0,0 +1,9 @@
+package rediskey
+
+func K(suffix string) string {
+	return "roguestats|" + suffix
+}
+
+func ResetToken(userID string) string {
+	return K("reset_token|" + userID)
+}
diff --git a/internal/research/schema/battle.schema.json b/schema/rsc_01h8yfh5y5vff7sss16ra735rc.battle.json
similarity index 95%
rename from internal/research/schema/battle.schema.json
rename to schema/rsc_01h8yfh5y5vff7sss16ra735rc.battle.json
index b6429d1..2d6b441 100644
--- a/internal/research/schema/battle.schema.json
+++ b/schema/rsc_01h8yfh5y5vff7sss16ra735rc.battle.json
@@ -174,9 +174,29 @@
       "type": "array",
       "minItems": 0,
       "maxItems": 2,
+      "x-nullableArray": {
+        "options": [
+          {
+            "value": "not_reporting",
+            "label": "不汇报此数据项、或藏品有电台"
+          },
+          {
+            "value": "reporting_0",
+            "label": "未掉落招募券"
+          },
+          {
+            "value": "reporting_1",
+            "label": "掉落了招募券:仅有 1 个「掉落框」"
+          },
+          {
+            "value": "reporting_2",
+            "label": "掉落了招募券:有 2 个「掉落框」"
+          }
+        ]
+      },
       "items": {
         "type": "array",
-        "minItems": 0,
+        "minItems": 1,
         "maxItems": 2,
         "uniqueItems": true,
         "items": {
@@ -279,7 +299,7 @@
       "title": "掉落密文板",
       "description": "若藏品有伶牙毁林者,请留空不填;请同时把两个选项都选上",
       "type": "array",
-      "minItems": 1,
+      "minItems": 0,
       "maxItems": 3,
       "uniqueItems": true,
       "items": {
diff --git a/internal/research/schema/incident.schema.json b/schema/rsc_01h8yfh5y9mf4ws7ehf1q9n9n5.incident.json
similarity index 100%
rename from internal/research/schema/incident.schema.json
rename to schema/rsc_01h8yfh5y9mf4ws7ehf1q9n9n5.incident.json
diff --git a/internal/research/schema/portal.schema.json b/schema/rsc_01h8yfh5ycezqx0pkgw0t72wcg.portal.json
similarity index 100%
rename from internal/research/schema/portal.schema.json
rename to schema/rsc_01h8yfh5ycezqx0pkgw0t72wcg.portal.json
index f1c06ac..6941cbd 100644
--- a/internal/research/schema/portal.schema.json
+++ b/schema/rsc_01h8yfh5ycezqx0pkgw0t72wcg.portal.json
@@ -4,18 +4,18 @@
   "type": "object",
   "additionalProperties": false,
   "properties": {
-    "floor": {
-      "title": "层数",
-      "type": "integer",
-      "maximum": 6,
-      "minimum": 1
-    },
     "grade": {
       "title": "难度",
       "type": "integer",
       "maximum": 15,
       "minimum": 0
     },
+    "floor": {
+      "title": "层数",
+      "type": "integer",
+      "maximum": 6,
+      "minimum": 1
+    },
     "layout": {
       "title": "节点布局",
       "description": "“战斗”代表只有战斗节点,“事件”代表完全无战斗节点,“混合”代表二者皆有。后面的每个数字代表每一列有多少个节点。",
diff --git a/internal/research/schema/rest.schema.json b/schema/rsc_01h8yfh5yg0wergmjhewagsrq2.rest.json
similarity index 100%
rename from internal/research/schema/rest.schema.json
rename to schema/rsc_01h8yfh5yg0wergmjhewagsrq2.rest.json