diff --git a/docs/backend/users-and-permissions.md b/docs/backend/users-and-permissions.md index 7a96163..487d1c0 100644 --- a/docs/backend/users-and-permissions.md +++ b/docs/backend/users-and-permissions.md @@ -32,6 +32,8 @@ login: decay_minutes: 1 ``` +An application created with `summer new` already has a random `SUMMER_ADMIN__JWT__SECRET` in `.env`. + The cookie carries the Secure attribute. `backend.cookie_secure: false` drops it for plain-HTTP development and is refused in the `production` environment. ## Permissions diff --git a/docs/console/scaffolding.md b/docs/console/scaffolding.md index 49dfd1a..7f540dd 100644 --- a/docs/console/scaffolding.md +++ b/docs/console/scaffolding.md @@ -46,10 +46,10 @@ The application gets these files: | `go.work` | The workspace with the application and its plugin modules, as relative paths. It is committed. | | `summer.yaml` | The manifest: module path, binary name and plugin list. | | `config/` | `app.yaml`, `database.yaml`, `http.yaml` and `storage.yaml`, enough for `serve` and `route:list`. | -| `.env`, `.env.example` | The database DSN and a freshly generated application key. `.env` is readable by its owner only and never committed; `.env.example` lists the variables without the key. | +| `.env`, `.env.example` | The database DSN, a freshly generated application key and a random admin token secret (`SUMMER_ADMIN__JWT__SECRET`). `.env` is readable by its owner only and never committed; `.env.example` lists the variables without the key and the secret. | | `compose.yaml` | PostgreSQL 16 on `127.0.0.1:54320`, with credentials matching `.env`. | | `.gitignore` | `bin/`, `tmp/`, `storage/`, `.env` and `go.work.sum`. | -| `CLAUDE.md`, `AGENTS.md` | Instructions for AI coding agents: the commands, the layout, the links to the documentation for agents and the naming convention. `AGENTS.md` is a symlink to `CLAUDE.md`. | +| `CLAUDE.md`, `AGENTS.md` | Instructions for AI coding agents: the commands, the layout, the admin, the links to the documentation for agents and the naming convention. `AGENTS.md` is a symlink to `CLAUDE.md`. | | `plugins/` | The starter plugin `golem15.`, created by the same code as `summer make:plugin` and registered by the same code as `summer plugin:add`. Its `routes.go` answers `GET /` with a JSON greeting. | | `main.go`, `plugins.gen.go` | Generated from `summer.yaml`, as `summer build` does. | @@ -62,6 +62,17 @@ summer migrate summer serve ``` +The next steps also explain the admin. It opens at `/backend` once a plugin registers an admin controller; until then `summer serve` prints `admin: off` with these steps: + +```sh +summer make:admin-controller golem15.acme Posts +summer build +summer migrate +summer admin:create --email you@example.com --superuser +``` + +[Admin controllers](../backend/admin-controllers.md) explains the permission and model the generated controller needs. + Golem15 names repositories by what they hold: `sm--app` for an application such as the one `summer new` writes, `sm--plugin` for a standalone plugin module and `sm--theme` for a Nuxt or Vue frontend. ## Creating a plugin diff --git a/docs/setup/installation.md b/docs/setup/installation.md index a30d035..db5b458 100644 --- a/docs/setup/installation.md +++ b/docs/setup/installation.md @@ -48,7 +48,7 @@ Set them on the `bash` side of the pipe, for example `curl -fsSL https://summerc The generated application brings its own database and configuration: - `compose.yaml` runs PostgreSQL 16, published on `127.0.0.1:54320` only, so it does not clash with a PostgreSQL already on port 5432. -- `.env` holds the matching `SUMMER_DATABASE__DSN` and a freshly generated `SUMMER_APP__KEY`. It is excluded by `.gitignore`; `.env.example` lists the same variables. +- `.env` holds the matching `SUMMER_DATABASE__DSN`, a freshly generated `SUMMER_APP__KEY` and a random `SUMMER_ADMIN__JWT__SECRET` for the admin. It is excluded by `.gitignore`; `.env.example` lists the same variables. - `config/` holds `app.yaml`, `database.yaml`, `http.yaml` and `storage.yaml`, enough for `summer serve` and `route:list`. From `sm-demo-app`, start the database, build the binary, run the migrations and serve: @@ -68,6 +68,14 @@ curl http://127.0.0.1:8080/ It returns `{"message":"Hello from SummerCMS","plugin":"golem15.demo"}`. Stop the database with `docker compose down`, or `docker compose down -v` to delete its data as well. +`summer serve` prints the URL to open and the admin status. The admin lives at `http://127.0.0.1:8080/backend` once a plugin registers an admin controller; until then the status line reads `admin: off` and names the steps. When the admin is there, create the first administrator: + +```sh +summer admin:create --email you@example.com --superuser +``` + +[Admin controllers](../backend/admin-controllers.md) shows how to add a controller, and [Users and permissions](../backend/users-and-permissions.md) how administrators sign in. + ## Create an application without the script When you already have a framework checkout, or want to read the code before running anything, do the script's steps yourself. Clone the framework into `summercms`, install the tool from it and run `summer new` in the directory next to the checkout: diff --git a/internal/build/new.go b/internal/build/new.go index f48d200..a2b915b 100644 --- a/internal/build/new.go +++ b/internal/build/new.go @@ -3,6 +3,7 @@ package build import ( "bytes" "context" + "crypto/rand" "encoding/base64" "errors" "fmt" @@ -38,6 +39,10 @@ type NewOptions struct { // AppKey is written to .env as SUMMER_APP__KEY. It must be the base64 // (standard encoding) of 32 bytes; the CLI passes lagoon.GenerateAppKey. AppKey string + // AdminSecret is written to .env as SUMMER_ADMIN__JWT__SECRET, the HMAC + // secret of admin tokens (admin.jwt.secret). Empty means NewApp + // generates the base64 of 32 crypto/rand bytes. + AdminSecret string // NoGit skips git init and the first commit. NoGit bool } @@ -77,15 +82,16 @@ type genFile struct { // newData feeds the new/* templates. type newData struct { - Name string - Module string - Framework string - GoVersion string - Toolchain string - Replace string - DSN string - AppKey string - NoPlugin bool + Name string + Module string + Framework string + GoVersion string + Toolchain string + Replace string + DSN string + AppKey string + AdminSecret string + NoPlugin bool } // ValidateAppName reports whether name is usable as `summer new `. @@ -127,6 +133,16 @@ func NewApp(ctx context.Context, opts NewOptions, out io.Writer) (string, error) if err := validateAppKey(opts.AppKey); err != nil { return "", err } + if opts.AdminSecret == "" { + secret, err := generateAdminSecret() + if err != nil { + return "", fmt.Errorf("build: generate admin secret: %w", err) + } + opts.AdminSecret = secret + } + if err := validateAdminSecret(opts.AdminSecret); err != nil { + return "", err + } parent := opts.Parent if parent == "" { parent = "." @@ -191,6 +207,29 @@ func validateAppKey(key string) error { return nil } +// adminSecretRe is the character set an admin secret may use: it is written +// unquoted into .env, whose reader trims whitespace and strips quotes. +var adminSecretRe = regexp.MustCompile(`^[A-Za-z0-9+/=_-]{32,}$`) + +// generateAdminSecret returns the base64 (standard encoding) of 32 +// crypto/rand bytes. +func generateAdminSecret() (string, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(raw), nil +} + +// validateAdminSecret requires at least 32 characters that are safe to +// write unquoted into .env. +func validateAdminSecret(s string) error { + if !adminSecretRe.MatchString(s) { + return fmt.Errorf("build: admin secret must be at least 32 characters of letters, digits, +, /, =, _ or -") + } + return nil +} + // devDSN is the .env database DSN matching compose.yaml. func devDSN(name string) string { return "postgres://" + devDBUser + ":" + devDBPassword + "@" + devDBHostPort + "/" + name + "?sslmode=disable" @@ -318,6 +357,9 @@ func planNewApp(opts NewOptions, target, framework string) ([]genFile, error) { if err := validateAppKey(opts.AppKey); err != nil { return nil, err } + if err := validateAdminSecret(opts.AdminSecret); err != nil { + return nil, err + } if err := checkFramework(framework); err != nil { return nil, fmt.Errorf("build: %w", err) } @@ -326,15 +368,16 @@ func planNewApp(opts NewOptions, target, framework string) ([]genFile, error) { return nil, fmt.Errorf("build: framework replace: %w", err) } data := newData{ - Name: opts.Name, - Module: appModulePath(opts.Name), - Framework: frameworkModule, - GoVersion: goVersionLine, - Toolchain: toolchainLine, - Replace: replace, - DSN: devDSN(opts.Name), - AppKey: opts.AppKey, - NoPlugin: opts.NoPlugin, + Name: opts.Name, + Module: appModulePath(opts.Name), + Framework: frameworkModule, + GoVersion: goVersionLine, + Toolchain: toolchainLine, + Replace: replace, + DSN: devDSN(opts.Name), + AppKey: opts.AppKey, + AdminSecret: opts.AdminSecret, + NoPlugin: opts.NoPlugin, } plan := []struct { rel string diff --git a/internal/build/new_golden_test.go b/internal/build/new_golden_test.go index cf7fda9..13cc8db 100644 --- a/internal/build/new_golden_test.go +++ b/internal/build/new_golden_test.go @@ -22,6 +22,16 @@ var goldenAppKey = func() string { return base64.StdEncoding.EncodeToString(raw) }() +// goldenAdminSecret is the base64 of the bytes 0x20..0x3f: a fixed, +// non-secret admin token secret that keeps .env byte-stable in the goldens. +var goldenAdminSecret = func() string { + raw := make([]byte, 32) + for i := range raw { + raw[i] = byte(0x20 + i) + } + return base64.StdEncoding.EncodeToString(raw) +}() + // newGoldenApp runs NewApp for "demo" in a temp parent whose sibling // summercms is a symlink to this repository, so auto-detection yields the // relative replace ../summercms. It returns the app dir and the output with @@ -38,11 +48,12 @@ func newGoldenApp(t *testing.T, noPlugin bool) (string, []byte) { } var out bytes.Buffer dir, err := NewApp(t.Context(), NewOptions{ - Name: "demo", - Parent: parent, - AppKey: goldenAppKey, - NoGit: true, - NoPlugin: noPlugin, + Name: "demo", + Parent: parent, + AppKey: goldenAppKey, + AdminSecret: goldenAdminSecret, + NoGit: true, + NoPlugin: noPlugin, }, &out) if err != nil { t.Fatalf("NewApp: %v\n%s", err, out.String()) diff --git a/internal/build/new_test.go b/internal/build/new_test.go index b565bfa..2f11578 100644 --- a/internal/build/new_test.go +++ b/internal/build/new_test.go @@ -2,6 +2,7 @@ package build import ( "bytes" + "encoding/base64" "go/token" "io/fs" "os" @@ -642,3 +643,88 @@ func TestNewNoGit(t *testing.T) { t.Fatalf("output lacks the created line:\n%s", out.String()) } } + +// envValue returns the value of key in an env file body, or "" and false. +func envValue(body []byte, key string) (string, bool) { + for _, line := range strings.Split(string(body), "\n") { + if v, ok := strings.CutPrefix(line, key+"="); ok { + return v, true + } + } + return "", false +} + +// TestNewAdminSecret writes SUMMER_ADMIN__JWT__SECRET to .env only, generates +// a random one when none is given and rejects values unsafe for .env. +func TestNewAdminSecret(t *testing.T) { + const key = "SUMMER_ADMIN__JWT__SECRET" + + t.Run("explicit", func(t *testing.T) { + const secret = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWY=" + parent := parentWithFramework(t) + var out bytes.Buffer + dir, err := NewApp(t.Context(), NewOptions{Name: "demo", Parent: parent, AppKey: testAppKey, AdminSecret: secret, NoGit: true, NoPlugin: true}, &out) + if err != nil { + t.Fatal(err) + } + env := readAppFile(t, dir, ".env") + if !strings.Contains(string(env), "\n"+key+"="+secret+"\n") { + t.Fatalf(".env does not hold %s=%s:\n%s", key, secret, env) + } + st, err := os.Stat(filepath.Join(dir, ".env")) + if err != nil { + t.Fatal(err) + } + if st.Mode().Perm() != 0o600 { + t.Fatalf(".env mode = %o, want 600", st.Mode().Perm()) + } + example := readAppFile(t, dir, ".env.example") + if v, ok := envValue(example, key); !ok || v != "" { + t.Fatalf(".env.example %s = (%q, %v), want an empty placeholder", key, v, ok) + } + if strings.Contains(string(example), secret) { + t.Fatal(".env.example contains the admin secret") + } + if strings.Contains(out.String(), secret) { + t.Fatal("the next steps print the admin secret") + } + }) + + t.Run("generated", func(t *testing.T) { + var seen []string + for range 2 { + parent := parentWithFramework(t) + dir, err := NewApp(t.Context(), NewOptions{Name: "demo", Parent: parent, AppKey: testAppKey, NoGit: true, NoPlugin: true}, nil) + if err != nil { + t.Fatal(err) + } + v, ok := envValue(readAppFile(t, dir, ".env"), key) + if !ok { + t.Fatalf(".env has no %s", key) + } + raw, err := base64.StdEncoding.DecodeString(v) + if err != nil || len(raw) != 32 { + t.Fatalf("generated secret %q is not the base64 of 32 bytes (%v)", v, err) + } + seen = append(seen, v) + } + if seen[0] == seen[1] { + t.Fatal("two apps got the same admin secret") + } + }) + + t.Run("invalid", func(t *testing.T) { + for _, secret := range []string{ + "short", + "abcdefghijklmnopqrst uvwxyzABCDEFGHIJKLMN", + "abcdefghijklmnopqrst\nuvwxyzABCDEFGHIJKLMN", + } { + parent := parentWithFramework(t) + _, err := NewApp(t.Context(), NewOptions{Name: "demo", Parent: parent, AppKey: testAppKey, AdminSecret: secret, NoGit: true, NoPlugin: true}, nil) + if err == nil || !strings.Contains(err.Error(), "admin secret") { + t.Fatalf("NewApp(AdminSecret %q) = %v, want an admin secret error", secret, err) + } + assertDirEntries(t, parent, []string{"summercms"}) + } + }) +} diff --git a/internal/build/stubs/new.tmpl b/internal/build/stubs/new.tmpl index e825005..de08f4d 100644 --- a/internal/build/stubs/new.tmpl +++ b/internal/build/stubs/new.tmpl @@ -34,11 +34,12 @@ uploads: public_path_prefix: "/storage/uploads" {{end}} -{{define "new/env"}}# Local secrets for this application: the database DSN and the application -# key. .gitignore excludes this file; never commit it. Real environment -# variables override the values here. +{{define "new/env"}}# Local secrets for this application: the database DSN, the application key +# and the admin token secret. .gitignore excludes this file; never commit it. +# Real environment variables override the values here. SUMMER_DATABASE__DSN={{.DSN}} SUMMER_APP__KEY={{.AppKey}} +SUMMER_ADMIN__JWT__SECRET={{.AdminSecret}} {{end}} {{define "new/env.example"}}# The variables .env sets. summer new wrote a .env with a fresh key; copy this @@ -47,6 +48,10 @@ SUMMER_DATABASE__DSN={{.DSN}} # A 32-byte base64 application key. Generate one with ./bin/{{.Name}} key:generate # and paste only the key, without the leading check mark. SUMMER_APP__KEY= +# Signs backend admin tokens; the admin refuses to start without it once a +# plugin registers an admin controller. summer new wrote a random value to .env; +# any long random string works, for example one from ./bin/{{.Name}} key:generate. +SUMMER_ADMIN__JWT__SECRET= # Uploads bucket; config/storage.yaml already sets this default. # SUMMER_STORAGE__UPLOADS__BUCKET_URL=file://./storage/app/uploads # Configuration environment; selects the config overlay (default production). @@ -110,6 +115,8 @@ summer migrate # run plugin migrations summer serve # start the HTTP server summer make:plugin golem15. # scaffold a plugin module under plugins/ summer plugin:add plugins/ # register it in summer.yaml, go.mod and go.work +summer make:admin-controller golem15. # scaffold an admin controller with its YAML +summer admin:create --email you@example.com --superuser # create a backend administrator (password prompted) ``` ## Layout @@ -117,13 +124,23 @@ summer plugin:add plugins/ # register it in summer.yaml, go.mod and go.w - `summer.yaml`: the application manifest (module path, binary name, ordered plugin list). - `plugins/`: local plugin modules.{{if not .NoPlugin}} `plugins/{{.Name}}` is the starter plugin `golem15.{{.Name}}`; its `routes.go` answers `GET /` with JSON.{{end}} - `config/`: YAML configuration (`app.yaml`, `database.yaml`, `http.yaml`, `storage.yaml`). Any key can be overridden with a `SUMMER_` environment variable, a double underscore separating path segments (`SUMMER_DATABASE__DSN` sets `database.dsn`). -- `.env`: local secrets (database DSN, application key). Never commit it; `.gitignore` excludes it. `.env.example` lists the variables. +- `.env`: local secrets (database DSN, application key, admin token secret). Never commit it; `.gitignore` excludes it. `.env.example` lists the variables. - `compose.yaml`: the local PostgreSQL 16. - `main.go`, `plugins.gen.go`: generated by `summer build` from `summer.yaml`. Do not edit them by hand; they are committed. - `go.work`: the workspace that joins the application and its plugin modules. Plugins are Go modules compiled into the binary at build time; there is no runtime plugin loading. Add a plugin with `summer make:plugin` and `summer plugin:add`, then run `summer build`. +## Admin + +The backend admin (the admin SPA and its JSON API) lives under `/backend`, at `http://127.0.0.1:8080/backend` with the default `summer serve` address. It is mounted only when a plugin registers an admin controller; until then `summer serve` prints `admin: off`. To add it: + +1. `summer make:admin-controller golem15. ` scaffolds the controller and its YAML. The generated controller requires the permission `golem15..access_` (`` is the controller name in snake case): declare it in the plugin's `Permissions()` (`pact.HasPermissions`) or the admin refuses to start. Its `NewRecord` returns `nil` until you return the plugin's model. +2. `summer build`, then `summer migrate`. +3. `summer admin:create --email you@example.com --superuser` creates the first administrator; the password is prompted. + +`.env` already holds `SUMMER_ADMIN__JWT__SECRET`, which signs admin tokens. + ## Naming convention - `sm--app`: an application, like this repository. @@ -143,4 +160,11 @@ and summer plugin:add plugins/, then run summer build again. {{else}} Then, from another terminal: curl http://127.0.0.1:8080/ -{{end}}{{end}} +{{end}} +Admin, at http://127.0.0.1:8080/backend once a plugin registers an admin +controller (.env already holds SUMMER_ADMIN__JWT__SECRET): + summer make:admin-controller golem15. + summer build + summer migrate + summer admin:create --email you@example.com --superuser +{{end}} diff --git a/internal/build/testdata/new-no-plugin/CLAUDE.md.golden b/internal/build/testdata/new-no-plugin/CLAUDE.md.golden index 1787850..970e602 100644 --- a/internal/build/testdata/new-no-plugin/CLAUDE.md.golden +++ b/internal/build/testdata/new-no-plugin/CLAUDE.md.golden @@ -23,6 +23,8 @@ summer migrate # run plugin migrations summer serve # start the HTTP server summer make:plugin golem15. # scaffold a plugin module under plugins/ summer plugin:add plugins/ # register it in summer.yaml, go.mod and go.work +summer make:admin-controller golem15. # scaffold an admin controller with its YAML +summer admin:create --email you@example.com --superuser # create a backend administrator (password prompted) ``` ## Layout @@ -30,13 +32,23 @@ summer plugin:add plugins/ # register it in summer.yaml, go.mod and go.w - `summer.yaml`: the application manifest (module path, binary name, ordered plugin list). - `plugins/`: local plugin modules. - `config/`: YAML configuration (`app.yaml`, `database.yaml`, `http.yaml`, `storage.yaml`). Any key can be overridden with a `SUMMER_` environment variable, a double underscore separating path segments (`SUMMER_DATABASE__DSN` sets `database.dsn`). -- `.env`: local secrets (database DSN, application key). Never commit it; `.gitignore` excludes it. `.env.example` lists the variables. +- `.env`: local secrets (database DSN, application key, admin token secret). Never commit it; `.gitignore` excludes it. `.env.example` lists the variables. - `compose.yaml`: the local PostgreSQL 16. - `main.go`, `plugins.gen.go`: generated by `summer build` from `summer.yaml`. Do not edit them by hand; they are committed. - `go.work`: the workspace that joins the application and its plugin modules. Plugins are Go modules compiled into the binary at build time; there is no runtime plugin loading. Add a plugin with `summer make:plugin` and `summer plugin:add`, then run `summer build`. +## Admin + +The backend admin (the admin SPA and its JSON API) lives under `/backend`, at `http://127.0.0.1:8080/backend` with the default `summer serve` address. It is mounted only when a plugin registers an admin controller; until then `summer serve` prints `admin: off`. To add it: + +1. `summer make:admin-controller golem15. ` scaffolds the controller and its YAML. The generated controller requires the permission `golem15..access_` (`` is the controller name in snake case): declare it in the plugin's `Permissions()` (`pact.HasPermissions`) or the admin refuses to start. Its `NewRecord` returns `nil` until you return the plugin's model. +2. `summer build`, then `summer migrate`. +3. `summer admin:create --email you@example.com --superuser` creates the first administrator; the password is prompted. + +`.env` already holds `SUMMER_ADMIN__JWT__SECRET`, which signs admin tokens. + ## Naming convention - `sm--app`: an application, like this repository. diff --git a/internal/build/testdata/new-no-plugin/next-steps.txt.golden b/internal/build/testdata/new-no-plugin/next-steps.txt.golden index e0f56b0..bf7fd04 100644 --- a/internal/build/testdata/new-no-plugin/next-steps.txt.golden +++ b/internal/build/testdata/new-no-plugin/next-steps.txt.golden @@ -8,3 +8,10 @@ Next steps, inside sm-demo-app: The app has no plugins yet. Add one with summer make:plugin golem15. and summer plugin:add plugins/, then run summer build again. + +Admin, at http://127.0.0.1:8080/backend once a plugin registers an admin +controller (.env already holds SUMMER_ADMIN__JWT__SECRET): + summer make:admin-controller golem15. + summer build + summer migrate + summer admin:create --email you@example.com --superuser diff --git a/internal/build/testdata/new/CLAUDE.md.golden b/internal/build/testdata/new/CLAUDE.md.golden index cdf0295..43fc894 100644 --- a/internal/build/testdata/new/CLAUDE.md.golden +++ b/internal/build/testdata/new/CLAUDE.md.golden @@ -23,6 +23,8 @@ summer migrate # run plugin migrations summer serve # start the HTTP server summer make:plugin golem15. # scaffold a plugin module under plugins/ summer plugin:add plugins/ # register it in summer.yaml, go.mod and go.work +summer make:admin-controller golem15. # scaffold an admin controller with its YAML +summer admin:create --email you@example.com --superuser # create a backend administrator (password prompted) ``` ## Layout @@ -30,13 +32,23 @@ summer plugin:add plugins/ # register it in summer.yaml, go.mod and go.w - `summer.yaml`: the application manifest (module path, binary name, ordered plugin list). - `plugins/`: local plugin modules. `plugins/demo` is the starter plugin `golem15.demo`; its `routes.go` answers `GET /` with JSON. - `config/`: YAML configuration (`app.yaml`, `database.yaml`, `http.yaml`, `storage.yaml`). Any key can be overridden with a `SUMMER_` environment variable, a double underscore separating path segments (`SUMMER_DATABASE__DSN` sets `database.dsn`). -- `.env`: local secrets (database DSN, application key). Never commit it; `.gitignore` excludes it. `.env.example` lists the variables. +- `.env`: local secrets (database DSN, application key, admin token secret). Never commit it; `.gitignore` excludes it. `.env.example` lists the variables. - `compose.yaml`: the local PostgreSQL 16. - `main.go`, `plugins.gen.go`: generated by `summer build` from `summer.yaml`. Do not edit them by hand; they are committed. - `go.work`: the workspace that joins the application and its plugin modules. Plugins are Go modules compiled into the binary at build time; there is no runtime plugin loading. Add a plugin with `summer make:plugin` and `summer plugin:add`, then run `summer build`. +## Admin + +The backend admin (the admin SPA and its JSON API) lives under `/backend`, at `http://127.0.0.1:8080/backend` with the default `summer serve` address. It is mounted only when a plugin registers an admin controller; until then `summer serve` prints `admin: off`. To add it: + +1. `summer make:admin-controller golem15. ` scaffolds the controller and its YAML. The generated controller requires the permission `golem15..access_` (`` is the controller name in snake case): declare it in the plugin's `Permissions()` (`pact.HasPermissions`) or the admin refuses to start. Its `NewRecord` returns `nil` until you return the plugin's model. +2. `summer build`, then `summer migrate`. +3. `summer admin:create --email you@example.com --superuser` creates the first administrator; the password is prompted. + +`.env` already holds `SUMMER_ADMIN__JWT__SECRET`, which signs admin tokens. + ## Naming convention - `sm--app`: an application, like this repository. diff --git a/internal/build/testdata/new/dot-env.example.golden b/internal/build/testdata/new/dot-env.example.golden index c731889..55baadf 100644 --- a/internal/build/testdata/new/dot-env.example.golden +++ b/internal/build/testdata/new/dot-env.example.golden @@ -4,6 +4,10 @@ SUMMER_DATABASE__DSN=postgres://summer:summer@127.0.0.1:54320/demo?sslmode=disab # A 32-byte base64 application key. Generate one with ./bin/demo key:generate # and paste only the key, without the leading check mark. SUMMER_APP__KEY= +# Signs backend admin tokens; the admin refuses to start without it once a +# plugin registers an admin controller. summer new wrote a random value to .env; +# any long random string works, for example one from ./bin/demo key:generate. +SUMMER_ADMIN__JWT__SECRET= # Uploads bucket; config/storage.yaml already sets this default. # SUMMER_STORAGE__UPLOADS__BUCKET_URL=file://./storage/app/uploads # Configuration environment; selects the config overlay (default production). diff --git a/internal/build/testdata/new/dot-env.golden b/internal/build/testdata/new/dot-env.golden index 698bce0..4544285 100644 --- a/internal/build/testdata/new/dot-env.golden +++ b/internal/build/testdata/new/dot-env.golden @@ -1,5 +1,6 @@ -# Local secrets for this application: the database DSN and the application -# key. .gitignore excludes this file; never commit it. Real environment -# variables override the values here. +# Local secrets for this application: the database DSN, the application key +# and the admin token secret. .gitignore excludes this file; never commit it. +# Real environment variables override the values here. SUMMER_DATABASE__DSN=postgres://summer:summer@127.0.0.1:54320/demo?sslmode=disable SUMMER_APP__KEY=AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8= +SUMMER_ADMIN__JWT__SECRET=ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8= diff --git a/internal/build/testdata/new/next-steps.txt.golden b/internal/build/testdata/new/next-steps.txt.golden index 6aebac5..1dcaea9 100644 --- a/internal/build/testdata/new/next-steps.txt.golden +++ b/internal/build/testdata/new/next-steps.txt.golden @@ -8,3 +8,10 @@ Next steps, inside sm-demo-app: Then, from another terminal: curl http://127.0.0.1:8080/ + +Admin, at http://127.0.0.1:8080/backend once a plugin registers an admin +controller (.env already holds SUMMER_ADMIN__JWT__SECRET): + summer make:admin-controller golem15. + summer build + summer migrate + summer admin:create --email you@example.com --superuser