feat(build): make summer new apps admin-ready

- NewOptions.AdminSecret: summer new writes SUMMER_ADMIN__JWT__SECRET
  to .env (still 0600), generated from 32 crypto/rand bytes when empty
  and validated as at least 32 .env-safe characters
- .env.example gets an empty SUMMER_ADMIN__JWT__SECRET= placeholder
  with a comment; the secret never reaches the printed next steps
- The next steps and the generated CLAUDE.md explain the admin at
  /backend: make:admin-controller, build, migrate and
  summer admin:create --email you@example.com --superuser
- Goldens use a fixed, non-secret admin secret fixture
- Scaffolding, installation and users-and-permissions docs updated
This commit is contained in:
Jakub Zych
2026-10-08 17:20:19 +02:00
parent da21670870
commit f70d80ec7e
13 changed files with 264 additions and 36 deletions

View File

@@ -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 <name>`.
@@ -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

View File

@@ -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())

View File

@@ -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"})
}
})
}

View File

@@ -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.<name> # scaffold a plugin module under plugins/<name>
summer plugin:add plugins/<name> # register it in summer.yaml, go.mod and go.work
summer make:admin-controller golem15.<plugin> <Name> # 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/<name> # 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.<plugin> <Name>` scaffolds the controller and its YAML. The generated controller requires the permission `golem15.<plugin>.access_<name>` (`<name>` 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-<name>-app`: an application, like this repository.
@@ -143,4 +160,11 @@ and summer plugin:add plugins/<name>, 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.<plugin> <Name>
summer build
summer migrate
summer admin:create --email you@example.com --superuser
{{end}}

View File

@@ -23,6 +23,8 @@ summer migrate # run plugin migrations
summer serve # start the HTTP server
summer make:plugin golem15.<name> # scaffold a plugin module under plugins/<name>
summer plugin:add plugins/<name> # register it in summer.yaml, go.mod and go.work
summer make:admin-controller golem15.<plugin> <Name> # 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/<name> # 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.<plugin> <Name>` scaffolds the controller and its YAML. The generated controller requires the permission `golem15.<plugin>.access_<name>` (`<name>` 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-<name>-app`: an application, like this repository.

View File

@@ -8,3 +8,10 @@ Next steps, inside sm-demo-app:
The app has no plugins yet. Add one with summer make:plugin golem15.<name>
and summer plugin:add plugins/<name>, 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.<plugin> <Name>
summer build
summer migrate
summer admin:create --email you@example.com --superuser

View File

@@ -23,6 +23,8 @@ summer migrate # run plugin migrations
summer serve # start the HTTP server
summer make:plugin golem15.<name> # scaffold a plugin module under plugins/<name>
summer plugin:add plugins/<name> # register it in summer.yaml, go.mod and go.work
summer make:admin-controller golem15.<plugin> <Name> # 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/<name> # 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.<plugin> <Name>` scaffolds the controller and its YAML. The generated controller requires the permission `golem15.<plugin>.access_<name>` (`<name>` 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-<name>-app`: an application, like this repository.

View File

@@ -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).

View File

@@ -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=

View File

@@ -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.<plugin> <Name>
summer build
summer migrate
summer admin:create --email you@example.com --superuser