diff --git a/.planning/STATE.md b/.planning/STATE.md index 23a5a01..0af4c02 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-09-16) Phase: 14.3 (One-command app scaffolding: summer new and summercms.io/new bootstrap script) — EXECUTING Plan: 4 of 4 Status: Phase complete — ready for verification -Last activity: 2026-10-08 - Completed quick task 261008-kdf: summer delegates pass flags through and forward signals +Last activity: 2026-10-08 - Completed quick task 261008-kdh: serve prints URL and admin status; summer new apps admin-ready Progress: [██████████] 100% @@ -671,6 +671,7 @@ Recent decisions affecting current work: | 261007-qgx | Admin record heading, crumb and title from the first text or mltext field (default locale) | 2026-10-07 | a714bb4 | [261007-qgx-admin-record-heading-uses-translatable-m](./quick/261007-qgx-admin-record-heading-uses-translatable-m/) | | 261008-k7s | Bootstrap script PATH hint per login shell (fish/zsh/bash) | 2026-10-08 | ddf1178 | [261008-k7s-bootstrap-script-path-hint-per-shell](./quick/261008-k7s-bootstrap-script-path-hint-per-shell/) | | 261008-kdf | summer delegates pass flags through, admin/route/key delegates, signal forwarding | 2026-10-08 | 50c8cee | [261008-kdf-delegated-summer-commands-pass-flags-thr](./quick/261008-kdf-delegated-summer-commands-pass-flags-thr/) | +| 261008-kdh | serve prints URL and admin status; summer new apps admin-ready | 2026-10-08 | f70d80e | [261008-kdh-serve-startup-and-admin-feedback-admin-r](./quick/261008-kdh-serve-startup-and-admin-feedback-admin-r/) | ## Deferred Items diff --git a/.planning/quick/261008-kdh-serve-startup-and-admin-feedback-admin-r/261008-kdh-PLAN.md b/.planning/quick/261008-kdh-serve-startup-and-admin-feedback-admin-r/261008-kdh-PLAN.md new file mode 100644 index 0000000..6275383 --- /dev/null +++ b/.planning/quick/261008-kdh-serve-startup-and-admin-feedback-admin-r/261008-kdh-PLAN.md @@ -0,0 +1,277 @@ +--- +phase: quick-261008-kdh +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - modules/surf/router.go + - modules/surf/routetable.go + - modules/surf/serve.go + - modules/surf/routelist_command.go + - modules/surf/serve_test.go + - modules/surf/admin_prefix_test.go + - modules/surf/README.md + - modules/cabana/README.md + - docs/console/setup-and-maintenance.md + - docs/services/routing.md + - internal/build/new.go + - internal/build/stubs/new.tmpl + - internal/build/new_golden_test.go + - internal/build/new_test.go + - internal/build/testdata/new/dot-env.golden + - internal/build/testdata/new/dot-env.example.golden + - internal/build/testdata/new/CLAUDE.md.golden + - internal/build/testdata/new/next-steps.txt.golden + - internal/build/testdata/new-no-plugin/CLAUDE.md.golden + - internal/build/testdata/new-no-plugin/next-steps.txt.golden + - docs/console/scaffolding.md + - docs/setup/installation.md + - docs/backend/users-and-permissions.md +autonomous: true +requirements: [QUICK-261008-kdh] + +estimate: + tokens: 110000 + raw_tokens: 110000 + tasks: 2 + confidence: low + +must_haves: + truths: + - "`serve --addr :8123` prints `listening on :8123, open http://127.0.0.1:8123/`. An explicit host is kept, an IPv6 host is bracketed, an unspecified host (0.0.0.0, ::) becomes 127.0.0.1 and port 80 is left out of the URL. An address that does not parse prints the old `listening on ` line alone." + - "The listen line is printed only after the port is bound: serve calls net.Listen before printing, so a port that is already in use fails without a misleading URL." + - "When no plugin registers an admin controller, serve prints `admin: off — no plugin registers an admin controller` followed by a hint naming `summer make:admin-controller `, `summer build`, `summer migrate`, ` admin:create --email you@example.com --superuser` and SUMMER_ADMIN__JWT__SECRET." + - "When the admin is mounted, serve prints `admin: http://127.0.0.1:8123/backend` (base URL plus the real prefix). It adds a warning with the admin:create command when backend_users is empty or missing, and a softer reminder when the count could not be read. The count is bounded by a 2 s timeout and never fails boot." + - "`` is the binary as the operator can retype it: `./bin/demo` both under `summer serve` (absolute argv0, cwd = app dir) and under `./bin/demo serve`." + - "route:list ends with an admin status line: `admin: mounted at /backend` or the same `admin: off — …` text." + - "surf.Router.AdminPrefix returns (prefix, true) when BuildRouter mounted the admin and (\"\", false) otherwise, and is nil-safe. surf.Assemble, surf.BuildRouter, cabana.Activate and cabana.Routes keep their signatures and behaviour; the change only adds API." + - "summer new writes `SUMMER_ADMIN__JWT__SECRET=` to .env (still mode 0600) and an empty `SUMMER_ADMIN__JWT__SECRET=` placeholder with a comment to .env.example. Goldens stay deterministic through a fixed NewOptions.AdminSecret. No user/front-end JWT key is added." + - "The summer new next steps and the generated CLAUDE.md gain an Admin part: the admin lives at /backend once a plugin registers an admin controller, `summer make:admin-controller golem15. `, then build and migrate, then `./bin/ admin:create --email you@example.com --superuser`." + - "go vet ./... and go test ./... -short are green; TestDocsTree and `go run ./cmd/summer docs:build --check` pass; TestNewGolden and TestNewGoldenNoPlugin pass with only the six listed goldens changed." + artifacts: + - path: modules/surf/routetable.go + provides: "Router.AdminPrefix accessor" + contains: "func (r *Router) AdminPrefix() (string, bool)" + - path: modules/surf/serve.go + provides: "serveURL, selfCommand, adminPresence, countAdmins, serveBanner; listener-first serve that prints the banner" + contains: "func serveURL(addr string) string" + - path: modules/surf/routelist_command.go + provides: "admin status line after the route table" + contains: "AdminPrefix()" + - path: modules/surf/serve_test.go + provides: "URL formatting, self command, banner (mounted/off/each presence) tests" + contains: "TestServeURL" + - path: internal/build/new.go + provides: "NewOptions.AdminSecret, generation and validation" + contains: "AdminSecret" + - path: internal/build/stubs/new.tmpl + provides: ".env / .env.example secret lines, CLAUDE.md Admin section, next-steps Admin block" + contains: "SUMMER_ADMIN__JWT__SECRET" + - path: internal/build/testdata/new/next-steps.txt.golden + provides: "pinned admin next steps" + contains: "admin:create --email you@example.com --superuser" + key_links: + - from: modules/surf/router.go + to: modules/surf/routetable.go + via: "BuildRouter stores admin.Prefix in Router.adminPrefix after checkAdminPrefix succeeds; AdminPrefix reads it" + pattern: "adminPrefix" + - from: modules/surf/serve.go + to: modules/surf/router.go + via: "serve calls BuildRouter then r.compile() (instead of Assemble) so it keeps the *Router for AdminPrefix" + pattern: "BuildRouter(app, plugins)" + - from: modules/surf/serve.go + to: modules/cabana/contracts.go + via: "countAdmins uses Migrator().HasTable and Count on cabana.BackendUser (table backend_users)" + pattern: "cabana.BackendUser" + - from: internal/build/new.go + to: internal/build/stubs/new.tmpl + via: "newData.AdminSecret renders into new/env" + pattern: "{{.AdminSecret}}" +--- + + +Fix the reported confusion: in an app made by `summer new`, `summer serve` printed only `ℹ listening on :8123`, and /admin and /backend answered 404 with no explanation. The cause is that cabana mounts the admin only when a plugin registers an admin controller, and then it also needs `admin.jwt.secret` and an administrator. + +The work has two parts. +1. `serve` prints a clickable URL and an admin status line: the admin URL, a warning when no administrator exists, or "admin: off" with the exact steps. `route:list` prints the same status. surf gains the additive accessor `surf.Router.AdminPrefix`. +2. `summer new` makes the app admin-ready. It writes a random `SUMMER_ADMIN__JWT__SECRET` to .env, and the next steps and the generated CLAUDE.md explain how to add an admin controller and create the first administrator. + +Purpose: a developer who opens /backend on a fresh app learns from the terminal why it is not there and what to run, and once a controller exists the admin starts without hunting for a secret. + +Output: two commits, each with code, tests, the module READMEs and the docs pages it affects (project rule: code and its README/docs land together). Nothing is tagged, pushed or deployed. + + + +@~/.claude/gsd-core/workflows/execute-plan.md +@~/.claude/gsd-core/templates/summary.md + + + +@.planning/STATE.md +@CLAUDE.md +@modules/surf/serve.go +@modules/surf/routelist_command.go +@modules/surf/routetable.go +@modules/surf/admin_prefix_test.go +@modules/surf/serve_test.go +@internal/build/new.go +@internal/build/stubs/new.tmpl +@internal/build/new_golden_test.go + +Facts established while planning (do not re-derive): + +- Ordering: quick tasks 261008-k7s (modules/sunrise, the PATH sections of docs/setup/installation.md) and 261008-kdf (modules/bonfire, cmd/summer/runtime.go, console docs about `summer serve` flags) run BEFORE this plan. Re-read docs/setup/installation.md and docs/console/setup-and-maintenance.md at execution time; edit only the sections named here and keep their edits. The working tree may hold other tasks' uncommitted files (for example .planning/state.json, modules/sunrise/*). Stage this plan's files by explicit path only, never `git add -A` or `git commit -a`. +- Admin mounting lives in modules/surf/router.go BuildRouter (around line 546): `admin, err := cabana.Activate(app, plugins)`; when `admin != nil` it registers the `backend` middleware, mounts and runs `r.checkAdminPrefix(admin.Prefix)`. `cabana.Activate` returns (nil, nil) when no plugin registers an admin controller (modules/cabana/http.go:74-81), and fails boot with "cabana: admin.jwt.secret is empty (set SUMMER_ADMIN__JWT__SECRET)" when one does and the secret is missing. `cabana.Routes.Prefix` is already exported, so cabana code does not change. +- `surf.Assemble` = BuildRouter + the unexported `(*Router).compile()`. serve.go is in package surf and can call compile directly. Keep Assemble as is (exported API). +- serve default `--addr` is `:8080` (modules/surf/serve.go:31); ":8123" in the report was the user's flag. +- Output: `bonfire.Output` has Info (prefix ℹ), Warning (prefix ⚠), Println and Table. Tests capture it with `bonfire.NewOutput(strings.NewReader(""), &buf, &buf)`, and `cmd.Run(ctx, nil, out)` works for commands that read no input (see modules/lagoon/keygen_test.go:23). A buffer is not a TTY, so the output has no colour codes. +- `summer admin:create` is NOT a command of the summer CLI. cmd/summer/main.go delegates only migrate, migrate:rollback, migrate:status, serve, queue:work, schedule:run and queue:clear, and docs/console/introduction.md:43 says `admin:create` runs on the binary directly. Every hint therefore uses the binary form `./bin/ admin:create --email you@example.com --superuser`. Without `--superuser` or a role, the first administrator would see nothing, because generated controllers require a permission. The user's request text said `summer admin:create`; this is the working equivalent. +- `summer make:admin-controller` takes ` ` (cmd/summer/main.go:197-237). The generated controller requires the permission `.access_`; boot fails until the plugin declares it in pact.HasPermissions, and NewRecord returns nil until it returns the model (docs/backend/admin-controllers.md around line 150). +- `summer migrate` always creates backend_users: lagoon.Migrate runs `BackendAdminMigrations` under history id summercms.cabana (modules/lagoon/migrations.go:75). `cabana.BackendUser.TableName()` is "backend_users". +- compass loads `SUMMER_ADMIN__JWT__SECRET` into `admin.jwt.secret` from the environment or .env with no YAML file declaring the key (koanf env provider, modules/compass/env.go). The .env reader splits on the first `=`, trims whitespace and strips one pair of matching surrounding quotes, so a base64 value with `=` padding is safe unquoted. No config/admin.yaml is needed. The front-end user JWT secret belongs to the golem15.user plugin, which summer new does not add, so it is not written. +- The goldens: `go test ./internal/build -run 'TestNewGolden' -update` rewrites them (internal/build/new_golden_test.go:13). `goldenAppKey` is the base64 of 0x00..0x1f. TestNewGolden pins .env, .env.example, CLAUDE.md and next-steps.txt for the default app; TestNewGoldenNoPlugin pins CLAUDE.md and next-steps.txt. scripts/check-phase14.3.sh greps `^SUMMER_APP__KEY=.\{44\}$` and the next steps for `summer build`, `summer migrate` and `summer serve`, and needs at least 23 tracked goldens. All of that stays true; do not edit the script. +- The docs checker verifies `pkg.Ident` code spans, and `summer …` and `./bin/ …` commands in `sh` fences (application commands such as admin:create are accepted). It also checks links and anchors, and rejects consuming-application names. Use `acme`, `demo` and `you@example.com`. A new identifier named in a README or docs page (`surf.Router.AdminPrefix`) must exist in the package. The generated CLAUDE.md must keep exactly one mention of `https://summercms.io/docs/llms.txt`; do not add another, and do not invent other summercms.io page URLs. +- A Bash hook blocks commands whose text contains the literal dot-env file pattern. Golden file names use `dot-env` and are not affected. Read secrets-bearing files with the Read tool or through the Go tests, not with `cat`. + + + + + + Task 1: serve and route:list report the URL and the admin status (surf.Router.AdminPrefix) + modules/surf/router.go, modules/surf/routetable.go, modules/surf/serve.go, modules/surf/routelist_command.go, modules/surf/serve_test.go, modules/surf/admin_prefix_test.go, modules/surf/README.md, modules/cabana/README.md, docs/console/setup-and-maintenance.md, docs/services/routing.md + `git status --porcelain -- docs/console/setup-and-maintenance.md docs/services/routing.md modules/surf modules/cabana` is empty before the first edit (no uncommitted hunks from another task would land in this commit). + + - serveURL(":8123") = "http://127.0.0.1:8123/"; serveURL("127.0.0.1:9000") = "http://127.0.0.1:9000/"; serveURL("[::1]:8080") = "http://[::1]:8080/"; serveURL("0.0.0.0:80") = "http://127.0.0.1/"; serveURL("[::]:8080") = "http://127.0.0.1:8080/"; serveURL("localhost:3000") = "http://localhost:3000/"; serveURL("8080") = "" (no port separator); serveURL("") = "". + - selfCommand("/srv/app/bin/demo", "/srv/app") = "./bin/demo"; selfCommand("./bin/demo", "/srv/app") = "./bin/demo"; selfCommand("bin/demo", "/srv/app") = "./bin/demo"; selfCommand("demo", "/srv/app") = "demo"; selfCommand("/opt/x/bin/demo", "/srv/app") = "/opt/x/bin/demo"; selfCommand("", "/srv/app") = "./bin/". + - serveBanner(":8123", "/backend", true, adminsPresent, "./bin/demo") = [info "listening on :8123, open http://127.0.0.1:8123/", info "admin: http://127.0.0.1:8123/backend"]. + - Same with adminsNone adds a warning line "no administrators yet; create one with ./bin/demo admin:create --email you@example.com --superuser". + - Same with adminsMissingTable adds a warning line "the admin tables do not exist yet; run summer migrate, then ./bin/demo admin:create --email you@example.com --superuser". + - Same with adminsUnknown adds an info line "sign in with an administrator; ./bin/demo admin:create --email you@example.com --superuser creates one". + - serveBanner(":8123", "", false, adminsUnknown, "./bin/demo") = [listen line, info "admin: off — no plugin registers an admin controller", info "to add one, run summer make:admin-controller , then summer build, summer migrate and ./bin/demo admin:create --email you@example.com --superuser; the admin needs SUMMER_ADMIN__JWT__SECRET, which summer new writes to .env"]. + - serveBanner("8080", "/backend", true, adminsPresent, "./bin/demo") = [info "listening on 8080", info "admin: mounted at /backend"] (no URL when the address does not parse). + - countAdmins(ctx, nil) = adminsUnknown. + - BuildRouter with adminPrefixApp + adminPrefixPlugin: AdminPrefix() = ("/acme-admin", true); with adminPrefixAppWith(t, "") + adminPrefixPlugin = ("/backend", true); with only prefixRoutePlugin = ("", false); (*Router)(nil).AdminPrefix() = ("", false). + - RouteListCommand output (bonfire.NewOutput into a buffer, Run with nil input) ends with "admin: mounted at /acme-admin" for the admin plugin set, and with "admin: off — no plugin registers an admin controller" for a set without admin controllers. + + +1. Write the tests first (RED). Put the serveURL, selfCommand, serveBanner and countAdmins(nil) table tests in modules/surf/serve_test.go as TestServeURL, TestSelfCommand and TestServeBanner, plus a check that countAdmins(t.Context(), nil) returns adminsUnknown. Put the AdminPrefix and route:list tests in modules/surf/admin_prefix_test.go as TestRouterAdminPrefix and TestRouteListAdminStatus, reusing that file's adminPrefixApp, adminPrefixAppWith, adminPrefixPlugin and prefixRoutePlugin fixtures. Run `go test ./modules/surf -short -run 'TestServeURL|TestSelfCommand|TestServeBanner|TestRouterAdminPrefix|TestRouteListAdminStatus'` and confirm it fails to compile or fails. + +2. Router API (additive). In modules/surf/router.go add an unexported field `adminPrefix string` to the Router struct. In BuildRouter, inside `if admin != nil`, set `r.adminPrefix = admin.Prefix` after checkAdminPrefix succeeds. In modules/surf/routetable.go, next to Routes, add the exported method `func (r *Router) AdminPrefix() (string, bool)`. It returns (r.adminPrefix, true) when the admin was mounted and ("", false) otherwise, nil receiver included. Doc comment: it reports the cabana admin mount path set by BuildRouter, and false means no plugin registers an admin controller, so no admin routes exist. Do not change Assemble, BuildRouter's signature, cabana.Activate or cabana.Routes. + +3. Pure helpers in modules/surf/serve.go: + - `serveURL(addr string) string`. Use net.SplitHostPort, returning "" on error or when the port is empty. A host that is empty or an unspecified IP (netip.ParseAddr(host).IsUnspecified(), which covers 0.0.0.0 and ::) becomes 127.0.0.1; any other host is kept as given. Port "80" is omitted. Otherwise join with net.JoinHostPort, which brackets IPv6. Return "http://" + hostport + "/". + - `selfCommand(argv0, cwd string) string`. Empty argv0 gives "./bin/". An argv0 without a path separator is returned unchanged (found on PATH). Otherwise make it absolute against cwd (filepath.Join when relative), take filepath.Rel(cwd, abs), and return "./" + filepath.ToSlash(rel) when Rel succeeds and rel neither is ".." nor starts with "../"; else return the absolute path. + - `type adminPresence int` with the constants adminsUnknown, adminsMissingTable, adminsNone and adminsPresent. + - `countAdmins(ctx context.Context, gdb *gorm.DB) adminPresence`. A nil gdb gives adminsUnknown. Otherwise derive a 2-second context.WithTimeout. If `gdb.WithContext(c).Migrator().HasTable(&cabana.BackendUser{})` is false, return adminsMissingTable. Then Count on `&cabana.BackendUser{}`: an error gives adminsUnknown, 0 gives adminsNone, more gives adminsPresent. It never returns an error and never logs one. HasTable queries information_schema, so a missing table does not reach the GORM error log. + - `type bannerLine struct { warn bool; text string }` and `serveBanner(addr, adminPrefix string, mounted bool, admins adminPresence, self string) []bannerLine`, producing exactly the lines in . The admin URL is strings.TrimSuffix(serveURL(addr), "/") + adminPrefix; when serveURL is "", the line is "admin: mounted at ". Keep the off text in one unexported constant `adminOffText` = "admin: off — no plugin registers an admin controller", because route:list reuses it. The em dash is the user's own wording. + - A small `printBanner(out bonfire.Output, lines []bannerLine)` that calls out.Warning for warn lines and out.Info for the rest. + +4. Wire serve (ServeCommand.Run). Replace `h, err := Assemble(app, plugins)` with `r, err := BuildRouter(app, plugins)` followed by `h, err := r.compile()`. After that, and before conga.StartServeWorker, bind with `ln, err := net.Listen("tcp", addr)` and return the error if it fails. If StartServeWorker fails, close ln before returning. Then compute `prefix, mounted := r.AdminPrefix()`. When mounted, set `admins = countAdmins(ctx, gdb)`, using the *gorm.DB already returned by lagoon.OpenFromApp; otherwise use adminsUnknown. Compute `cwd, _ := os.Getwd()`, then `printBanner(out, serveBanner(addr, prefix, mounted, admins, selfCommand(os.Args[0], cwd)))`. Print the banner synchronously, before the serving goroutine starts. Keep the "job worker started on queues" line where it is. The goroutine now runs `errCh <- srv.Serve(ln)`, and the shutdown and select logic stays unchanged (srv.Shutdown closes the listener Serve owns). Add imports as needed (net/netip, path/filepath, gorm.io/gorm and the cabana module; surf already depends on both through router.go). No new module requirement. + +5. route:list (modules/surf/routelist_command.go). After out.Table, call out.Info with "admin: mounted at " when r.AdminPrefix() reports mounted, else with adminOffText. route:list opens no database, so it prints no administrator hint. + +6. Docs in the same commit (project rule; surf is a framework module, so describe additive behaviour only): + - modules/surf/README.md. Add an API reference row `surf.Router.AdminPrefix`: "The admin mount path and true when `surf.BuildRouter` mounted the cabana admin; false when no plugin registers an admin controller." In the CLI commands table, extend the `serve` purpose: after binding it prints the listen address with a browser URL (an unspecified host shows as 127.0.0.1) and an admin status line. That line is the admin URL, with a warning and the `admin:create` command when no administrator exists yet, or `admin: off` with the steps to add an admin controller. Extend `route:list`: it ends with the admin status line. + - modules/cabana/README.md, in the paragraph near line 181 that says `cabana.Activate` returns nil when no plugin registers an admin controller, add one sentence: `surf.Router.AdminPrefix` reports which case applies, and the `serve` and `route:list` commands print it as an admin status line. + - docs/console/setup-and-maintenance.md, "The HTTP server" table: update the `serve` and `route:list` purposes the same way, and keep whatever 261008-kdf wrote about flags. In "Admin accounts" add one sentence: when the admin is mounted and backend_users is empty, `serve` prints a warning naming `admin:create`. + - docs/services/routing.md, after the `route:list` paragraph near line 186, add one sentence: the table is followed by a line saying whether the admin is mounted and under which prefix. + +7. Run the command and fix anything red. Make one commit with exactly the files of this task, for example `feat(surf): print the serve URL and admin status on startup`. The body names Router.AdminPrefix as additive, the listener-first bind and the route:list status line. Add no co-author or attribution trailer (user and project rule, which overrides any harness attribution default). + + + cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./modules/surf/ ./modules/cabana/ && go test ./modules/surf/ -short -count=1 && go test ./modules/surf/ -short -count=1 -run 'TestServeURL|TestSelfCommand|TestServeBanner|TestRouterAdminPrefix|TestRouteListAdminStatus' -v 2>&1 | grep -c '^--- PASS' && grep -q 'surf.Router.AdminPrefix' modules/surf/README.md && grep -q 'surf.Router.AdminPrefix' modules/cabana/README.md && go test ./cmd/summer -run '^TestDocsTree$' -count=1 && go run ./cmd/summer docs:build --check + + serve prints `listening on , open ` after binding, followed by the admin status line (and a warning or hint when applicable). route:list ends with the admin status. surf.Router.AdminPrefix exists and the rest of the API is unchanged. The five new tests pass with all of package surf. The surf and cabana READMEs and the two docs pages describe the output. TestDocsTree and docs:build --check pass. One commit holds exactly the ten files, with no co-author trailer. + + + + Task 2: summer new writes the admin secret and explains the admin in next steps and CLAUDE.md + internal/build/new.go, internal/build/stubs/new.tmpl, internal/build/new_golden_test.go, internal/build/new_test.go, internal/build/testdata/new/dot-env.golden, internal/build/testdata/new/dot-env.example.golden, internal/build/testdata/new/CLAUDE.md.golden, internal/build/testdata/new/next-steps.txt.golden, internal/build/testdata/new-no-plugin/CLAUDE.md.golden, internal/build/testdata/new-no-plugin/next-steps.txt.golden, docs/console/scaffolding.md, docs/setup/installation.md, docs/backend/users-and-permissions.md + `git status --porcelain -- docs/setup/installation.md docs/console/scaffolding.md docs/backend/users-and-permissions.md internal/build` is empty before the first edit (261008-k7s's installation.md change is committed). + + - NewApp with AdminSecret "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWY=" writes .env containing the line "SUMMER_ADMIN__JWT__SECRET=QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWY=" and .env keeps mode 0600. .env.example contains the line "SUMMER_ADMIN__JWT__SECRET=" with an empty value and does not contain the secret. The captured next-steps output does not contain the secret. + - NewApp with an empty AdminSecret writes a SUMMER_ADMIN__JWT__SECRET value that base64-decodes (standard encoding) to exactly 32 bytes. Two apps created in two parents get different values. + - NewApp with AdminSecret "short", a 40-character value containing a space, or a 40-character value containing a newline returns an error naming the admin secret and leaves no sm-demo-app directory behind. + - The goldens change only by the admin additions, and every other golden is byte-identical. + + +1. Tests first (RED). Add TestNewAdminSecret to internal/build/new_test.go covering the three behaviour groups. Use the existing helpers parentWithFramework, readAppFile and assertDirEntries, with NoGit and NoPlugin true to stay fast. In internal/build/new_golden_test.go add `goldenAdminSecret`, the base64 (standard) of the bytes 0x20..0x3f built like goldenAppKey, with a comment that it is a fixed, non-secret fixture. Pass `AdminSecret: goldenAdminSecret` in newGoldenApp's NewOptions. + +2. internal/build/new.go: + - Add `AdminSecret string` to NewOptions. Doc: 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. + - Add `AdminSecret string` to newData. + - Add `generateAdminSecret() (string, error)` (crypto/rand.Read into 32 bytes, base64.StdEncoding) and `validateAdminSecret(s string) error`. The value must be at least 32 characters and contain only `A-Za-z0-9+/=_-`, because it is written unquoted into .env, whose reader trims whitespace and strips quotes. The error text is "build: admin secret must be at least 32 characters of letters, digits, +, /, =, _ or -". + - In NewApp, after validateAppKey: when opts.AdminSecret is empty, fill it from generateAdminSecret (wrap the error as "build: generate admin secret: %w"), then validate it. It must be filled before planNewApp and before any directory is created. planNewApp also calls validateAdminSecret and sets `AdminSecret: opts.AdminSecret` in data. Do not touch cmd/summer: NewApp generates the secret itself, so the CLI needs no change. The next-steps render keeps passing only Name and NoPlugin, so the secret can never reach the printed output. + +3. internal/build/stubs/new.tmpl (keep every existing line byte-identical unless named here): + - new/env: change the header comment to name the admin token secret, for example "Local secrets for this application: the database DSN, the application key and the admin token secret." Re-wrap it to three comment lines if needed. After SUMMER_APP__KEY add `SUMMER_ADMIN__JWT__SECRET={{.AdminSecret}}`. + - new/env.example: after the SUMMER_APP__KEY= line add a two- or three-line comment: it 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. Then add the line `SUMMER_ADMIN__JWT__SECRET=`. + - new/CLAUDE.md, in "## Commands", add two fence lines in the same aligned-comment style: `summer make:admin-controller golem15. ` with a comment saying it scaffolds an admin controller with its YAML, and `./bin/{{.Name}} admin:create --email you@example.com --superuser` with a comment saying it creates a backend administrator (password prompted). In "## Layout", make the `.env` bullet say it holds the database DSN, the application key and the admin token secret. + - Add a new "## Admin" section after "## Layout". It says that the backend admin (the SPA and the JSON API) lives under `/backend`, at `http://127.0.0.1:8080/backend` with the default `summer serve` address, and is mounted only when a plugin registers an admin controller; until then `summer serve` prints `admin: off`. It then lists three numbered steps. (1) `summer make:admin-controller golem15. `; the generated controller requires the permission `.access_`, which must be declared in the plugin's `Permissions()` or the admin refuses to start, and its `NewRecord` must return the plugin's model. Word this from docs/backend/admin-controllers.md around line 150, and check the make:admin-controller hint (internal/build/artifact.go accessorHint) before writing any registration step. (2) `summer build` and `summer migrate`. (3) `./bin/{{.Name}} admin:create --email you@example.com --superuser`. The section ends with one sentence: `.env` already holds `SUMMER_ADMIN__JWT__SECRET`, which signs admin tokens. It must not mention llms.txt again. The text is the same for `--no-plugin`, because it uses `golem15.`. + - new/next-steps: keep everything up to and including the existing `{{if .NoPlugin}}…{{else}}…{{end}}` block byte-identical. Then, before the closing `{{end}}` of the define, append a blank line and this block: + - "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" + - " ./bin/{{.Name}} admin:create --email you@example.com --superuser" + The output must end with exactly one trailing newline; check the golden by eye. + +4. Regenerate the goldens with `go test ./internal/build -run 'TestNewGolden' -count=1 -update`, then rerun without -update. Review `git diff --stat -- internal/build/testdata`: exactly the six goldens in change, and each diff is only the additions above plus the reworded .env header and .env bullet. If any other golden changes, the template edit leaked; fix it instead of accepting it. Read the golden contents with the Read tool, not cat. + +5. Docs in the same commit: + - docs/console/scaffolding.md, files table: the `.env`, `.env.example` row becomes "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." In the `CLAUDE.md` row add "the admin". After the next-steps `sh` fence, add a short paragraph and an `sh` fence with the admin block for the `acme` example: `summer make:admin-controller golem15.acme Posts`, `summer build`, `summer migrate`, `./bin/acme admin:create --email you@example.com --superuser`. Say the admin opens at `/backend` once a plugin registers an admin controller, that `summer serve` prints `admin: off` with these steps until then, and link [Admin controllers](../backend/admin-controllers.md). + - docs/setup/installation.md, "## Run the application" section only: the `.env` bullet also names the random `SUMMER_ADMIN__JWT__SECRET`. After the curl/`docker compose down` paragraph, add a short paragraph. It says `summer serve` prints the URL and the admin status, and the admin lives at `http://127.0.0.1:8080/backend` once a plugin registers an admin controller. It gives the `./bin/demo admin:create --email you@example.com --superuser` step, and links [Admin controllers](../backend/admin-controllers.md) and [Users and permissions](../backend/users-and-permissions.md). Do not touch the PATH sections (261008-k7s). + - docs/backend/users-and-permissions.md, after the sentence near line 22 naming `SUMMER_ADMIN__JWT__SECRET`: add one sentence saying that an application created with `summer new` already has a random secret in `.env`. + +6. Run the command, fix anything red, then make one commit with exactly the files of this task, for example `feat(build): make summer new apps admin-ready`. The body mentions the generated secret, the .env.example placeholder and the Admin next steps. Add no co-author or attribution trailer. After the commit, `git status --porcelain -- internal/build/testdata` must be empty. + + + cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go test ./internal/build -count=1 -run 'TestNewGolden|TestNewGoldenNoPlugin|TestNewEnvFileMode|TestNewAdminSecret' && grep -q 'SUMMER_ADMIN__JWT__SECRET=' internal/build/testdata/new/dot-env.golden && grep -q 'admin:create --email you@example.com --superuser' internal/build/testdata/new/next-steps.txt.golden && grep -q 'admin:create --email you@example.com --superuser' internal/build/testdata/new-no-plugin/next-steps.txt.golden && grep -q '^## Admin' internal/build/testdata/new/CLAUDE.md.golden && test "$(grep -c 'https://summercms.io/docs/llms.txt' internal/build/testdata/new/CLAUDE.md.golden)" = 1 && go vet ./... && go test ./... -short -count=1 && go test ./cmd/summer -run '^TestDocsTree$' -count=1 && go run ./cmd/summer docs:build --check + + A fresh `summer new` app has a random 32-byte base64 SUMMER_ADMIN__JWT__SECRET in .env (still 0600) and an empty placeholder in .env.example. The next steps and CLAUDE.md explain /backend, make:admin-controller, build, migrate and `./bin/ admin:create … --superuser`. Exactly six goldens changed and were reviewed. TestNewAdminSecret covers the explicit, generated and invalid cases. go vet ./... and go test ./... -short are green; TestDocsTree and docs:build --check pass. One commit holds exactly the thirteen files, with no co-author trailer. Nothing is tagged, pushed or deployed. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| summer new → local filesystem | The generated admin token secret is written to .env on the developer's disk and must never reach git, the terminal or a template that is committed. | +| serve → operator console | The startup banner prints the URLs, the admin mount path and whether administrators exist to stdout. | +| serve → PostgreSQL at boot | One extra read (HasTable and Count on backend_users) runs during startup when the admin is mounted. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-kdh-01 | Information Disclosure | .env admin secret (internal/build/new.go, new.tmpl) | high | mitigate | The secret is written only to .env, which keeps mode 0600 (asserted in TestNewAdminSecret and TestNewEnvFileMode) and stays in .gitignore. .env.example gets an empty value. The next-steps render never receives the secret, and the test asserts the output does not contain it. Goldens use a fixed, documented non-secret fixture. | +| T-kdh-02 | Spoofing | admin JWT HMAC secret strength | high | mitigate | Generated from crypto/rand (32 bytes, base64). validateAdminSecret rejects anything shorter than 32 characters, and the two-apps-differ test catches a constant or reused value. | +| T-kdh-03 | Tampering | .env injection through AdminSecret | medium | mitigate | validateAdminSecret allows only `A-Za-z0-9+/=_-`, so a newline, space, quote or `#` cannot add or alter another .env variable. A test covers the newline and space cases. | +| T-kdh-04 | Information Disclosure | serve banner | low | accept | It prints only the listen URL, the admin path and whether any administrator exists (never a count, email or secret). The output goes to the operator's own console, and the admin path is already public by design at /backend. | +| T-kdh-05 | Denial of Service | countAdmins at boot | low | mitigate | Runs only when the admin is mounted, is bounded by a 2 s context timeout, uses information_schema for the existence check, and maps every failure to adminsUnknown, so it can never fail or block startup. | +| T-kdh-SC | Tampering | npm/pip/cargo/go module installs | low | accept | No packages are installed and go.mod gains no requirement (net/netip, crypto/rand and gorm/cabana are already in the build). The `--forbidden` stage of scripts/check-phase14.3.sh would flag a new require. | + + + +- `go vet ./...` and `go test ./... -short -count=1` are green after each commit. +- TestServeURL covers ":8123", "127.0.0.1:9000", "[::1]:8080", "0.0.0.0:80", "[::]:8080", "localhost:3000", "8080" and "". TestServeBanner covers mounted with each adminPresence, not mounted, and an unparsable address. TestRouterAdminPrefix and TestRouteListAdminStatus cover mounted, the default prefix, not mounted and a nil router. +- TestNewGolden, TestNewGoldenNoPlugin, TestNewEnvFileMode and TestNewAdminSecret pass, and exactly six goldens changed. +- `go test ./cmd/summer -run '^TestDocsTree$' -count=1` and `go run ./cmd/summer docs:build --check` pass. +- Manual smoke (optional, needs Docker): `summer new demo`, then compose up, `summer build`, `summer migrate` and `summer serve`. The console shows the listen URL and `admin: off — …`. + + + +A developer who runs `summer serve` in a fresh app sees a clickable URL and either the admin URL or an explicit "admin: off" line with the exact commands to enable it, and gets a warning when no administrator exists yet. A new app already has its admin token secret, so adding an admin controller is the only missing piece. The framework API change is additive (surf.Router.AdminPrefix), the READMEs and docs match, and the work lands as two commits with no tag, push or deploy. + + + +Create `.planning/quick/261008-kdh-serve-startup-and-admin-feedback-admin-r/261008-kdh-SUMMARY.md` when done + + +## Orchestrator override (2026-10-08, after 261008-kdf was planned) + +Quick task 261008-kdf, which executes before this one, adds `summer admin:create` and `summer admin:reset-password` as pass-through delegates of the summer CLI (plus `summer route:list`, `summer key:generate` and flag pass-through such as `summer serve --addr :8123`). The note at line ~129 that `summer admin:create` is not a command is therefore superseded. Wherever this plan writes `./bin/ admin:create --email you@example.com --superuser` (or ` admin:create`, `./bin/demo admin:create`, `./bin/acme admin:create`, `./bin/{{.Name}} admin:create`), use `summer admin:create --email you@example.com --superuser` instead: in the serve banner hints, the summer new next steps, the generated CLAUDE.md, the goldens, the docs pages and the test expectations. Verify first that 261008-kdf landed (`summer --help` lists admin:create); if it did not, fall back to the plan's binary form. diff --git a/.planning/quick/261008-kdh-serve-startup-and-admin-feedback-admin-r/261008-kdh-SUMMARY.md b/.planning/quick/261008-kdh-serve-startup-and-admin-feedback-admin-r/261008-kdh-SUMMARY.md new file mode 100644 index 0000000..ad4ebf2 --- /dev/null +++ b/.planning/quick/261008-kdh-serve-startup-and-admin-feedback-admin-r/261008-kdh-SUMMARY.md @@ -0,0 +1,119 @@ +--- +phase: quick-261008-kdh +plan: 01 +status: complete +subsystem: surf, build +tags: [serve, admin, cabana, summer-new, dx] +requires: + - "261008-kdf: summer admin:create delegate (used in every hint)" +provides: + - "surf.Router.AdminPrefix (additive)" + - "serve: listener-first bind, URL line and admin status banner" + - "route:list: admin status line after the table" + - "summer new: random SUMMER_ADMIN__JWT__SECRET in .env, Admin next steps and CLAUDE.md section" +affects: [modules/surf, modules/cabana (README only), internal/build, docs] +tech-stack: + added: [] + patterns: + - "pure banner builder (serveBanner) returning lines, printed by printBanner, so startup output is table-tested without a server" +key-files: + created: [] + modified: + - modules/surf/router.go + - modules/surf/routetable.go + - modules/surf/serve.go + - modules/surf/routelist_command.go + - modules/surf/serve_test.go + - modules/surf/admin_prefix_test.go + - modules/surf/README.md + - modules/cabana/README.md + - docs/console/setup-and-maintenance.md + - docs/services/routing.md + - internal/build/new.go + - internal/build/stubs/new.tmpl + - internal/build/new_golden_test.go + - internal/build/new_test.go + - internal/build/testdata/new/dot-env.golden + - internal/build/testdata/new/dot-env.example.golden + - internal/build/testdata/new/CLAUDE.md.golden + - internal/build/testdata/new/next-steps.txt.golden + - internal/build/testdata/new-no-plugin/CLAUDE.md.golden + - internal/build/testdata/new-no-plugin/next-steps.txt.golden + - docs/console/scaffolding.md + - docs/setup/installation.md + - docs/backend/users-and-permissions.md +decisions: + - "Every admin hint uses `summer admin:create --email you@example.com --superuser` (orchestrator override; 261008-kdf made it a delegate), so selfCommand was not added" + - "Router tracks mounting with an explicit adminMounted flag next to adminPrefix rather than inferring it from a non-empty prefix" + - "serve binds with net.Listen before printing and serves with srv.Serve(ln); the banner prints synchronously after the worker starts" +metrics: + duration: "~48 min" + completed: 2026-10-08 +actuals: + tokens: 12700 + tasks: 2 + commits: 2 +plan_head_before: 4b5ed7707e3b8845b23113658299a3197b6169bc +plan_head_after: f70d80ec7efd34ad2ccf9809913674d5fd243f95 +--- + +# Quick 261008-kdh: serve startup and admin feedback Summary + +`summer serve` now binds first and prints `listening on :8123, open http://127.0.0.1:8123/` followed by an admin status line: either the admin URL (plus a warning naming `summer admin:create` when `backend_users` is empty or missing), or `admin: off — no plugin registers an admin controller` with the exact steps. `route:list` ends with the same status. `summer new` writes a random `SUMMER_ADMIN__JWT__SECRET` to `.env`, so the only thing a fresh app is missing for the admin is an admin controller. + +## Commits + +| Task | Commit | Message | +|------|--------|---------| +| 1 | da21670 | feat(surf): print the serve URL and admin status on startup | +| 2 | f70d80e | feat(build): make summer new apps admin-ready | + +## What changed + +- **surf (additive):** `surf.Router.AdminPrefix() (string, bool)` is nil-safe and is set by `BuildRouter` after `checkAdminPrefix` succeeds. `Assemble`, `BuildRouter`, `cabana.Activate` and `cabana.Routes` are unchanged. +- **serve:** `BuildRouter` + `compile()`, then `net.Listen` (a busy port fails before any URL is printed; the listener is closed if the worker fails to start), then `countAdmins` (2 s timeout, `HasTable` + `Count` on `cabana.BackendUser`, every failure maps to `adminsUnknown`), then the banner, then `srv.Serve(ln)`. The shutdown and select logic is unchanged. +- **serveURL:** an empty or unspecified host (0.0.0.0, ::) becomes 127.0.0.1, IPv6 is bracketed, port 80 is dropped, an unparsable address gives no URL (the banner falls back to `listening on ` and `admin: mounted at `). +- **summer new:** `NewOptions.AdminSecret`; empty means 32 crypto/rand bytes as base64. `validateAdminSecret` requires 32+ characters of `A-Za-z0-9+/=_-` (no .env injection). `.env.example` gets an empty placeholder with a comment. The next-steps render still receives only Name and NoPlugin. The next steps and the generated CLAUDE.md gain an Admin block or section. +- **Docs:** surf README (API row, serve/route:list), cabana README (one sentence), console setup-and-maintenance, routing, scaffolding, installation (Run the application section only; k7s PATH sections untouched), users-and-permissions. + +## Verification + +- `go vet ./...` and `go test ./... -short -count=1`: green. +- New tests: TestServeURL, TestServeBanner (present, none, missing table, unknown, off, unparsable), TestPrintBanner, TestCountAdminsNilDB, TestRouterAdminPrefix (configured, default, none, nil), TestRouteListAdminStatus (mounted, off), TestNewAdminSecret (explicit, generated and distinct, three invalid values with cleanup). +- TestNewGolden, TestNewGoldenNoPlugin, TestNewEnvFileMode pass; exactly the six planned goldens changed, each reviewed (dot-env golden read with the Read tool). +- `go test ./cmd/summer -run '^TestDocsTree$'` and `go run ./cmd/summer docs:build --check`: no problems found. +- Tracer gate (Task 1): the full `` was re-run after implementation and passed before Task 2 started. +- Not run: the optional manual smoke (`summer new demo`, compose up, serve) needs Docker and a live database; countAdmins against a real Postgres is covered only by its nil path. + +## Deviations from Plan + +**1. [Orchestrator override] `summer admin:create` instead of ``/`./bin/ admin:create`** +- **Found during:** Task 1 (precheck: `cmd/summer/runtime.go` delegates `admin:create`, landed in 261008-kdf) +- **Change:** All hints, next steps, CLAUDE.md, goldens, docs and test expectations use `summer admin:create --email you@example.com --superuser`. Consequently `selfCommand` and TestSelfCommand were not added and `serveBanner` takes no `self` parameter (`serveBanner(addr, prefix, mounted, admins)`). The `.env.example` comment keeps the existing `./bin/ key:generate` form to match the line above it. +- **Commits:** da21670, f70d80e + +**2. [Rule 2 - Robustness] countAdmins treats a HasTable false caused by a timeout as unknown** +- **Issue:** `HasTable` returns false on a query error, which would otherwise report "tables do not exist" after a 2 s timeout. +- **Fix:** if the derived context has an error, return `adminsUnknown` instead of `adminsMissingTable`. +- **Commit:** da21670 + +**3. [Placement] users-and-permissions sentence placed after the YAML block** +- The sentence near line 22 ends in a colon introducing the `config/admin.yaml` fence, so the new sentence follows the fence instead of splitting it. + +**4. [Additional test] TestPrintBanner** checks info vs warning symbols; not in the plan's list, purely additive. + +Pre-existing, out of scope: `gofmt -l internal/build` lists `internal/build/registry.go` (untouched by this task). + +## Known Stubs + +None. + +## Threat Flags + +None. The banner prints only the URL, admin path and whether administrators exist (T-kdh-04, accepted). The secret stays in `.env` 0600 and never in output (T-kdh-01..03 mitigated and tested). + +## Self-Check: PASSED + +- FOUND commits: da21670, f70d80e (`git rev-list --count 4b5ed77..HEAD` = 2) +- FOUND: modules/surf/serve.go contains `func serveURL(addr string) string`; modules/surf/routetable.go contains `func (r *Router) AdminPrefix() (string, bool)`; internal/build/stubs/new.tmpl contains `SUMMER_ADMIN__JWT__SECRET` +- `git status --porcelain -- internal/build/testdata` is empty