69 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, assumption_delta_decision, specless_probe_fallback, user_setup, estimate, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | assumption_delta_decision | specless_probe_fallback | user_setup | estimate | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 11.2-ready-to-share-summercms-io-website-and-newsletter-plugin | 02 | execute | 2 |
|
|
false | no-change | skipped: phase has no requirement IDs to probe (visible skip); ROADMAP SC1-SC5 and the D-IDs are the acceptance contract |
|
|
Purpose: SC2, SC3 and SC4. Plan 11.2-03 adds the full unit-test coverage on top of the interfaces fixed here.
Output: framework commits in summercms.go (two, then the tag), and two new repositories, sm-summercmsio-app and its submodule sm-summercmsio-plugin (the plan 11.2-01 repository becomes the second submodule).
Task count: five tasks including the tag checkpoint, above the usual three, because the user fixed this phase at three plans (CLAUDE.md lean rule) and placed all of this scope in plan 02.
<execution_context>
@/.claude/gsd-core/workflows/execute-plan.md
@/.claude/gsd-core/templates/summary.md
</execution_context>
Paths. Commands run from the summercms.go root (FW). APP = ../sm-summercmsio-app (absolute /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app), PLUG = APP/plugins/golem15/summercms, SITE = APP/vue-summercmsio-app (created by plan 11.2-01; this plan only reads it). Each task names the repository it commits to. Never git add in the meta repository summercms/.
Commits. One logical change per commit, conventional messages, never a co-author tag. In summercms.go, stage only the files a task lists (never git add -A); planning docs are committed separately by the orchestrator.
Tools. A summer binary for the app is installed into a temporary GOBIN, for example GOBIN=$(mktemp -d) go -C <framework tree> install ./cmd/summer; never rely on a summer already on PATH.
//go:embed all:publicintovar publicFS embed.FS(theall:prefix is required: Nuxt writes_nuxt/,_fonts/,_i18n/and_payload.json).type Plugin struct { fsys fs.FS }; a nilfsysmeansfs.Sub(publicFS, "public"). MethodsID() string(returns the literal"golem15.summercms"),Requires() []string(nil),Register(*backpack.App) error,Boot(*backpack.App) error,Routes(r pact.Router) error. Compile-time assertionvar _ pact.HasRoutes = (*Plugin)(nil).func init() { party.Register(&Plugin{}) }.func newHandlers(public fs.FS) (site, docs http.Handler, err error): subtreessiteanddocsofpublic; error textsummercms: public/site/index.html missing; run scripts/build.sh(orpublic/docs/...) when a tree has noindex.html.type tree structwith fieldsfiles map[string]*file,mount string("/"or"/docs/"),immutable func(name string) bool(nil means never),htmlRedirect bool(true for docs);type file struct { body []byte; etag, ctype string }.func newTree(fsys fs.FS, mount string, immutable func(string) bool, htmlRedirect bool) (*tree, error): loads every regular file whose path has no dot-segment, precomputesctypeand a strong ETag"+ first 16 hex chars of sha256 +"; returnserrMissingIndexwhenindex.htmlis absent.func (t *tree) ServeHTTP(w http.ResponseWriter, r *http.Request): rel =r.URL.Pathwithoutt.mount; name =path.Clean("/"+rel)without the leading/(empty meansindex.html); any segment starting with.→ not found; exact file → serve;name + "/index.html"→ serve;htmlRedirectandname + ".html"exists → 301 tot.mount + name + ".html"; otherwise not found = the tree's404.htmlwith status 404 (orhttp.NotFoundwithout one). Serving setsContent-Type(fromctype),Cache-Control(cacheImmutablewhent.immutable(name), elsecacheNoCache),ETag,X-Content-Type-Options: nosniff,Referrer-Policy: strict-origin-when-cross-origin, then callshttp.ServeContent(w, r, name, time.Time{}, bytes.NewReader(body)).const cacheImmutable = "public, max-age=31536000, immutable",const cacheNoCache = "no-cache".func siteImmutable(name string) bool: true for_nuxt/paths except_nuxt/builds/, and for_fonts/paths.var contentTypes map[string]stringandfunc contentType(name string) string: own table first (.htmltext/html; charset=utf-8,.csstext/css; charset=utf-8,.jsand.mjstext/javascript; charset=utf-8,.jsonapplication/json,.mdtext/markdown; charset=utf-8,.txttext/plain; charset=utf-8,.xmland.xslapplication/xml; charset=utf-8,.svgimage/svg+xml,.pngimage/png,.webpimage/webp,.icoimage/x-icon,.woff2font/woff2,.wofffont/woff,.webmanifestapplication/manifest+json), thenmime.TypeByExtension, thenapplication/octet-stream.func redirectTo(location string) http.HandlerFunc: 301 to a constant location.- Test helpers in
links_test.go:func requireBuild(t *testing.T)(skips unlessSUMMERCMS_REQUIRE_BUILD=1; when set, fails if either tree has no index.html),func pageLinks(html []byte) []string,func resolve(t *testing.T, h http.Handler, target string) (status int, final string).
Framework, package internal/docsite and cmd/summer:
Site.SiteURL string(yaml:"site_url") andSite.SiteLabel string(yaml:"site_label");Options.SiteURLandOptions.SiteLabeloverride them when non-empty, asOptions.BaseURLoverridesbase_url.func checkSiteURL(raw string) error: acceptshttp://orhttps://URLs with a host and no user info, or a path starting with exactly one/; rejects every other value (javascript:,data:,//host, relative paths, whitespace or control characters).func siteLabel(siteURL, label string) string:label(trimmed) when set, else the host of an absolute URL (the parsed URL'sHostfield, port included when present), elseHome.- Unexported
sitefieldssiteURL,siteLabel;pageView.SiteURL,pageView.SiteLabelfilled inbaseView(so the 404 page has the link too), never passed throughs.url(). - CLI flags
site-urlandsite-labelondocs:buildanddocs:serve, read indocsOptions.
App, package main in sm-summercmsio-app (test file only, never shipped in the binary):
type terminalGroup struct { Comment string \json:"comment"`; Commands []string `json:"commands"` },func loadTerminal(path string) ([]terminalGroup, error),const terminalCloneURL = "https://git.golem15.com/golem15/summercms",func terminalScript(groups []terminalGroup, cloneURL string) string(commands joined by\n; a non-empty cloneURL replaces onlyterminalCloneURLinside thegit clonecommand),func terminalEnv(base []string, gobin string) []string(drops everySUMMER_*andGOWORKentry, setsGOBIN=gobin, prefixesPATH` with gobin).
<assumption_delta_decision>
Detector signal: pluralization ("a SummerCMS binary that also serves the Phase 11.1 docs"). Primary noun: the docs site stays a self-contained static tree addressed by base_url. Decision: no-change. The site plugin serves two independent embedded trees by path prefix, and site_url is an optional outbound link, not a second identity for the docs; no data model, primary key or contract changes.
</assumption_delta_decision>
- Plugin repository (writes to sm-summercmsio-plugin):
mkdir -pPLUG andgit init -b masterinside it.go.mod:module git.golem15.com/golem15/sm-summercmsio-plugin,go 1.27.0,require git.golem15.com/golem15/summercms v0.1.0(D-42; the directory replace resolves it before the tag exists),replace git.golem15.com/golem15/summercms => ../../../../summercms.go(same depth as fonoteka's plugins)..gitignore:/public/site/and/public/docs/.public/README.md: build.sh fillssite/(Nuxt output) anddocs/(docs build); nothing else may be placed inpublic/docs, because docs:build refuses to clean a directory without its marker file; the file exists so the embed pattern always matches. plugin.goexactly as the interfaces block defines it. The plugin implements only the party lifecycle andpact.HasRoutes: it must not implement the pact admin-controllers capability or any other capability, so cabana never activates and no/backendroute exists (D-29, T-11.2-07).Routesbuilds both handlers vianewHandlers(returning its error, soservefails closed withsummercms: public/site/index.html missing; run scripts/build.shon an unbuilt tree) and registers, insider.GroupRaw("", nil, …):GET /docs→redirectTo("/docs/")(a permanent 301 instead of ServeMux's 307),GET /docs/{path...}→ docs tree,GET /→ site tree (the least specific pattern, verified conflict-free alongside cabana's patterns).static.goexactly as the interfaces block defines it (D-07, D-47; Pitfalls 8-11). Copy the ideas of the admin SPA embed handler (fs.FS, own content-type table, cache by path, ServeContent) and of the docsite preview handler (dot refusal, dir index, 404 page), but add no robots-blocking header, no CSP, no frame-deny header, no index-token rewrite and no SPA fallback: the public site wants indexable pages and real 404s. Build the 301 Location only fromt.mountplus the cleaned name plus.html, and only when that file is in the map (T-11.2-05). Stdlib only.smoke_test.go(smoke level; coverage is plan 11.2-03):TestStaticSmokebuilds handlers from afstest.MapFSwithsite/index.html,site/404.html,docs/index.html,docs/404.html,docs/setup/installation.htmland assertsGET /200text/html; charset=utf-8, docsGET /docs/setup/installation301 to/docs/setup/installation.html, andGET /nope404 with the 404 body. Rungo -C PLUG mod tidy, then commit in PLUG asfeat: serve the embedded site at / and the docs at /docs.- App repository (writes to sm-summercmsio-app):
git init -b masterin APP. Register the two existing repositories as submodules without cloning (git reports "Adding existing repo"):git submodule add git@git.golem15.com:golem15/vue-summercmsio-app.git vue-summercmsio-appandgit submodule add git@git.golem15.com:golem15/sm-summercmsio-plugin.git plugins/golem15/summercms(D-43; the remotes are created by the user later, see DEPLOY.md)..gitignore:/bin/,/tmp/,*.exe,go.work.sum,.env,/storage/. go.mod:module git.golem15.com/golem15/sm-summercmsio-app,go 1.27.0,toolchain go1.27.0,replace git.golem15.com/golem15/summercms => ../summercms.go,require git.golem15.com/golem15/summercms v0.1.0.summer.yaml:module: git.golem15.com/golem15/sm-summercmsio-app,binary: summercms-io,plugins:withid: golem15.summercms,module: git.golem15.com/golem15/sm-summercmsio-plugin. Then, with a summer binary installed from summercms.go into a temp GOBIN, runsummer plugin:add plugins/golem15/summercmsin APP (it adds the plugin require andreplace … => ./plugins/golem15/summercms, and writesgo.workwithuse ( . ./plugins/golem15/summercms ); confirmgo 1.27.0andtoolchain go1.27.0as in fonoteka.go/go.work, and that go.work does notusethe framework), thengo -C APP mod tidy.config/(no secrets; Pitfall 12, D-24, D-27):app.yaml(name: summercms-io,debug: false,locale: en,fallback_locale: en,key: ""with the comment "Set SUMMER_APP__KEY to a 32-byte base64 value (key:generate)");http.yaml(body_limits.default_bytes: 1048576,body_limits.upload_bytes: 1048576,trusted_proxiesholding127.0.0.1/32because nginx proxies on loopback, using the CIDR list format clientip.go reads);storage.yaml(uploads.bucket_url: "file://./storage/app/uploads",uploads.public_path_prefix: "/storage/uploads");queue.yaml(work_in_serve: falsewith a comment that the site has no jobs, plus fonoteka'smax_attempts,job_timeoutandqueues.default: 1);database.yaml(dsn: ""with the comment "Set SUMMER_DATABASE__DSN").scripts/build.sh(bash,set -euo pipefail,ROOTfromBASH_SOURCEas in fonoteka.go/scripts/check-openapi.sh, explicit guards that printbuild: …to stderr and exit 1). This task implements thedevmode; Task 4 addsrelease, which needs the tag, so until then any argument other thandevexits 2 with a usage line. Steps: checkpnpm,go,rsync,git,tarexist;FW="${SUMMERCMS_FRAMEWORK:-$ROOT/../summercms.go}"; (a) in SITEpnpm install --frozen-lockfileandpnpm run generate, require.output/public/index.html, thenrsync -a --delete "$SITE/.output/public/" "$PLUG/public/site/"(never thedistsymlink, which embed refuses); (b)git -C "$FW" archive HEAD | tar -x -C "$TMP/fw"(TMP from mktemp with an EXIT trap), install summer from the export into$TMP/bin, rundocs:build --base-url /docs --out "$PLUG/public/docs"inside the export, requirepublic/docs/index.htmlandpublic/docs/.summer-docs; (c)SUMMERCMS_REQUIRE_BUILD=1 go -C "$PLUG" test ./...; (d) in APP run"$TMP/bin/summer" build(writes main.go and plugins.gen.go), thenCGO_ENABLED=0 GOOS=linux GOARCH=amd64 go -C "$ROOT" build -trimpath -o bin/summercms-io .; printbuild: bin/summercms-io (dev) ready.scripts/smoke.sh(bash,set -euo pipefail, trap cleanup of the container, the serve process and the temp dir; binary path argument defaulting tobin/summercms-io): startpostgres:15withdocker run -d --rm(usersummercms, passwordsmoke, databasesummercms_io, port127.0.0.1::5432, read back withdocker port), wait forpg_isready; copyconfig/into a temp work dir and write a.envthere withSUMMER_DATABASE__DSNandSUMMER_APP__KEY(32 random bytes, base64); from the work dir run the binary'smigrate, thenserve --addr 127.0.0.1:${SMOKE_PORT:-18095}in the background and wait for it. Assert with curl:/200,Content-Typestartingtext/html,Cache-Control: no-cache, anETag, and neither anX-Robots-Tagnor aContent-Security-Policyheader; the same/withIf-None-Match: <etag>is 304;/docs301 withLocation: /docs/;/docs/200 and noX-Robots-Tag;/docs/setup/installation301 to/docs/setup/installation.html, which is 200; the first/_nuxt/*.jsreferenced by index.html hasimmutableandtext/javascript; the first file underpublic/site/_fonts/isfont/woff2andimmutable;/missing-page404;/backend404;/docs/.summer-docs404;POST /405. Printsmoke: ok.- Run
scripts/build.sh devandscripts/smoke.sh, then commit in APP (including.gitmodulesand both gitlinks) asfeat: wire the summercms.io app with build and smoke scripts. ../sm-summercmsio-app/scripts/build.sh dev && ../sm-summercmsio-app/scripts/smoke.sh <fails_when>non-zero exit, a line starting "build:" on stderr, or no "smoke: ok" line</fails_when> go -C ../sm-summercmsio-app vet ./... && go -C ../sm-summercmsio-app/plugins/golem15/summercms vet ./... && go -C ../sm-summercmsio-app/plugins/golem15/summercms test ./... -run '^TestStaticSmoke$' -count=1 -v <fails_when>non-zero exit, a "--- FAIL" line, or no "--- PASS: TestStaticSmoke" line</fails_when> <acceptance_criteria>go -C ../sm-summercmsio-app list ./...prints exactlygit.golem15.com/golem15/sm-summercmsio-app(pnpm's dot-directory layout keeps node_modules out of the module).git -C ../sm-summercmsio-app submodule statuslistsplugins/golem15/summercmsandvue-summercmsio-app, andgit -C ../sm-summercmsio-app config -f .gitmodules --get-regexp urlprintsgit@git.golem15.com:golem15/sm-summercmsio-plugin.gitandgit@git.golem15.com:golem15/vue-summercmsio-app.git.grep -n 'go:embed all:public' ../sm-summercmsio-app/plugins/golem15/summercms/plugin.goandgrep -n 'return "golem15.summercms"' ../sm-summercmsio-app/plugins/golem15/summercms/plugin.goboth find a match.grep -c 'HasAdminControllers' ../sm-summercmsio-app/plugins/golem15/summercms/plugin.goprints 0.- Every import path printed by
go -C ../sm-summercmsio-app/plugins/golem15/summercms list -f '{{join .Imports "\n"}}' .is either a standard-library path or starts withgit.golem15.com/golem15/summercms/modules/(no new third-party dependency). grep -n 'work_in_serve: false' ../sm-summercmsio-app/config/queue.yamlfinds a match, andgrep -n 'key: ""' ../sm-summercmsio-app/config/app.yamlfinds a match.file ../sm-summercmsio-app/bin/summercms-ioreports an ELF 64-bit x86-64 executable. </acceptance_criteria> The app and plugin repositories exist with the submodule layout, the binary embeds both trees and boots on postgres:15 through the stock serve command, and the smoke script proves status codes, content types, cache headers, 301s, 404s and the absence of robots-blocking and CSP headers.
A. D-25, PostgreSQL 15 (no repository edits until the run passes). Export HEAD to a scratch directory, retarget the test image and run the database suites exactly as RESEARCH "How to run on 15 without permanent edits" does: S=$(mktemp -d), git archive HEAD | tar -x -C "$S", replace postgres:16-alpine with postgres:15 in every *.go file under $S with sed, then go -C "$S" test -count=1 -p 4 ./modules/lagoon/... ./modules/cabana/... ./modules/beachcomber/... ./modules/lighthouse/... ./modules/bouncer/... ./modules/conga/... ./docs/examples/blog/..., and confirm with -v output or the testcontainers log that the image was postgres:15. Record the package result lines in the SUMMARY. If any package fails on 15, stop here: do not edit the docs, do not continue to the tag, and return a checkpoint:decision (gate="blocking-human") to the user with the failing output (D-25 says stop and ask; do not work around it and do not upgrade rome). When every package passes, change README.md line 15 to "PostgreSQL 15 or newer for any application that uses the data layer (lagoon)." and line 35 to "Create a database on PostgreSQL 15 or newer:", and docs/setup/installation.md line 14 to "PostgreSQL 15 or newer for any application that uses the data layer." Leave docs/plugins/testing.md and the lagoon and conga READMEs unchanged: they name the test image, not a requirement. Commit as docs: require PostgreSQL 15 or newer (verified on postgres:15).
B. D-41/D-46, the link back to the main site. Before editing, build the real tree from a HEAD export into a scratch directory pre (for the byte-identical check).
load.go: addSiteURL(yaml:"site_url") andSiteLabel(yaml:"site_label") toSitewith doc comments (strict decoding needs the fields). InParseSite, whensite_urlis set validate it withcheckSiteURLand reportdocsite: site config: site_url must be an http(s) URL with a host or a path starting with a single /; whensite_labelis set withoutsite_urlreportdocsite: site config: site_label needs site_url; a label that is blank after trimming or holds a line break isdocsite: site config: site_label must be one non-empty line. AddcheckSiteURLandsiteLabelexactly as the interfaces block defines them (net/urlfor absolute URLs; T-11.2-09).docsite.go: addOptions.SiteURL("overrides site.yaml site_url when non-empty") andOptions.SiteLabel("overrides site.yaml site_label when non-empty"). Inload, next to the base_url override: start from the config values, apply the option overrides, validate an overriding URL withcheckSiteURL(errordocsite: --site-url: must be an http(s) URL with a host or a path starting with a single /), refuse a label without any site URL (docsite: --site-label needs --site-url or site.yaml site_url), then stores.siteURLands.siteLabel = siteLabel(s.siteURL, label).emit.go: addSiteURLandSiteLabeltopageViewand set them inbaseViewfrom the site fields, not throughs.url()(they point at another site).header.html: append to the end of the wordmark anchor's line a conditional anchor{{if .SiteURL}}<a class="site-link" href="{{.SiteURL}}" aria-label="{{.SiteLabel}}">{{template "icon-chevron-left"}}<span class="site-link-label">{{.SiteLabel}}</span></a>{{end}}, on the same line so the unset output keeps the exact bytes it has today. html/template escapes the attribute.site.css: a.site-linkrule next to.wordmark(inline-flex, centered, small gap, a left margin, the theme's muted text color and the existing accent on hover, 14px), and in the theme's existing narrow-screen media query hide.site-link-labelso the search trigger keeps its room (the aria-label keeps the link named).cmd/summer/docs.go: add{Name: "site-url", Description: "Main site URL linked from the docs header (overrides site.yaml site_url)"}and{Name: "site-label", Description: "Label of the main site link (overrides site.yaml site_label; default: the URL host, or Home)"}to bothdocs:buildanddocs:serve, and read both indocsOptions.docs/console/utilities.md: add--site-urland--site-labelto the Flags cells of thedocs:buildanddocs:serverows, and below the table a short paragraph:docs/site.yamlaccepts two optional keys,site_urlandsite_label; withsite_url: https://acme.example/every page header links back to the main site as "acme.example"; withoutsite_labelthe label is the URL's host, or Home for a path such as/; the two flags override the keys the way--base-urloverridesbase_url. Use only the neutralacme.exampleexample (CLAUDE.md: framework docs never name a consuming application).docs/site.yamlitself stays unchanged (D-46).- Smoke tests (coverage is plan 11.2-03):
TestParseSiterows for an acceptedhttps://acme.example/, an accepted/, a rejectedjavascript:alert(1), a rejected//acme.example, and a rejectedsite_labelwithoutsite_url;TestSiteLinkintheme_test.gobuildsthemeTreethree ways and checksindex.htmland404.html: no options → nosite-link;Options{SiteURL: "/", SiteLabel: "acme.example"}→<a class="site-link" href="/"andacme.example;Options{SiteURL: "https://acme.example/docs"}→ labelacme.example;Options{SiteURL: "/"}→ labelHome.TestDocsBuildSiteFlagsincmd/summer/docs_test.go:docs:build --root ../.. --out <tmp> --site-url / --site-label example.orgwritesclass="site-link" href="/"intoindex.html, and--site-url javascript:alert(1)returns an error containing--site-url. - Byte-identical check: build the working tree with no site flags into a scratch directory
postand requirediff -r pre postto print nothing; record that in the SUMMARY. Commit asfeat(docsite): optional site_url and site_label link back to the main site. go vet ./internal/docsite/... ./cmd/summer && go test ./internal/docsite ./cmd/summer -run '^(TestDocsTree|TestDocsBuildRealTree|TestToolCommandNames|TestParseSite|TestSiteLink|TestDocsBuildSiteFlags)$' -count=1 -v <fails_when>non-zero exit, a "--- FAIL" line, "no tests to run", or fewer than six "--- PASS" lines for the named tests</fails_when> go vet ./... && go test ./internal/docsite ./cmd/summer -count=1 <fails_when>non-zero exit or a "FAIL" line</fails_when> scripts/check-phase11.1.sh --docs && scripts/check-phase11.1.sh --forbidden <fails_when>non-zero exit or a line starting "refuse:"</fails_when> <acceptance_criteria>- The SUMMARY lists an
okline for each of lagoon, lagoon/attach, cabana, beachcomber, lighthouse, bouncer, conga and docs/examples/blog run againstpostgres:15, and noFAILline. grep -c 'PostgreSQL 15 or newer' README.mdprints 2 andgrep -c 'PostgreSQL 15 or newer' docs/setup/installation.mdprints 1.grep -n 'yaml:"site_url"' internal/docsite/load.goandgrep -n 'yaml:"site_label"' internal/docsite/load.goboth find a match.grep -c 'site-url\|site-label' cmd/summer/docs.goprints at least 6 (two flags on two commands plus two reads).grep -n 'acme.example' docs/console/utilities.mdfinds a match, andgit diff --stat "$D25_SHA^..$SITE_SHA" -- docs/site.yamlprints nothing, whereD25_SHAandSITE_SHAhold the two commit shas of this task recorded in the SUMMARY (the framework's own site.yaml is unchanged, D-46).diff -rof the pre-change and post-change real-tree builds without site flags prints nothing (recorded in the SUMMARY).git log --format=%s "$D25_SHA^..$SITE_SHA"in summercms.go prints exactly the two commit subjects above, andgit log --format='%(trailers:key=Co-authored-by,valueonly)' "$D25_SHA^..$SITE_SHA"prints only empty lines. </acceptance_criteria> The database suites are proven on postgres:15 and the docs say 15 or newer; site_url and site_label (keys and flags) add a validated link back to the main site in every docs page header, leaving the framework's own output unchanged; the docs checker stays green.
- The SUMMARY lists an
deploy/nginx/summercms.io.conf(D-29, D-30, D-31): a port-80 server forsummercms.ioandwww.summercms.ioanswering301 https://summercms.io$request_uri; a 443 server forwww.summercms.ioanswering 301 to the apex (rome has no 443 www block today, so https://www.summercms.io fails its TLS handshake); the 443 apex server withlisten 443 ssl http2,ssl_certificate /etc/letsencrypt/live/summercms.io/fullchain.pem,ssl_certificate_key /etc/letsencrypt/live/summercms.io/privkey.pem,include /etc/letsencrypt/options-ssl-nginx.conf,ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem(comment: copy the lineage lines from the existing block if they differ),gzip on; gzip_vary on; gzip_proxied any;with typestext/css text/plain text/xml application/xml application/json text/javascript application/javascript image/svg+xml text/markdown,location ^~ /backend { return 404; }, andlocation /withlimit_except GET HEAD { deny all; },proxy_pass http://127.0.0.1:8095,proxy_http_version 1.1and theHost,X-Real-IP,X-Forwarded-ForandX-Forwarded-Protoheaders.deploy/supervisor/summercms-io.conf(D-32):[program:summercms-io]withcommand=/srv/summercms-io/bin/summercms-io serve --addr 127.0.0.1:8095,directory=/srv/summercms-io(config is read relative to the working directory),user=summercms,environment=SUMMER_ENV="production",autostart=true,autorestart=true,startsecs=3,stopsignal=TERM,stopwaitsecs=15,redirect_stderr=true,stdout_logfile=/var/log/supervisor/summercms-io.logwith size and backup limits.deploy/env.example: the two variable names with no secret values (SUMMER_DATABASE__DSN=postgres://summercms:<password>@127.0.0.1:5432/summercms_io?sslmode=disable,SUMMER_APP__KEY=) and a comment that the real file lives at/srv/summercms-io/.env, owner summercms, mode 0600, never in git (T-11.2-08).- Rollback copy of today's site (D-31): download
https://summercms.io/andhttps://summercms.io/logo.pngwithcurl -fsSLintodeploy/rollback/under-construction/index.htmlandlogo.png(the live "Under construction" docroot is one HTML file plus the logo). DEPLOY.mdwith these sections:- Overview: what runs where (nginx :443 → 127.0.0.1:8095 summercms-io → Postgres 15
summercms_io), the user, port and paths (summercms,127.0.0.1:8095,/srv/summercms-io/{bin,config,storage,.env}), marked "check on rome" where assumed. - Launch checklist (once, before cutover, done by the user): create the three repositories
golem15/sm-summercmsio-app,golem15/vue-summercmsio-appandgolem15/sm-summercmsio-pluginon git.golem15.com, then in each local repositorygit remote add origin git@git.golem15.com:golem15/<name>.gitandgit push -u origin master, submodules first (ssucan push submodules that are ahead) (D-43); makegolem15/summercmspublic (D-38); confirmgit ls-remote --tags origin v0.1.0in summercms.go, or create and push the tag now if it was deferred (D-42); runSUMMERCMS_CHECK_EXTERNAL=1 go -C plugins/golem15/summercms test -run TestExternalLinks -count=1 -v ./...andSUMMERCMS_TERMINAL_CHECK=1 go test -run TestTerminalCommands -count=1 -v .with no clone override; on rome check that port 8095 is free withss -ltnp. - One-time server setup:
adduser --system --group --home /srv/summercms-io --shell /usr/sbin/nologin summercms;sudo -u postgres createuser --pwprompt summercms;sudo -u postgres createdb -O summercms -E UTF8 summercms_io(D-27); create the directories; write/srv/summercms-io/.envfromdeploy/env.examplewith the DSN and a key printed by./bin/summercms-io key:generate,chown summercms:summercmsandchmod 0600; install the supervisor program and runsupervisorctl reread && supervisorctl update. - Build (local, D-28):
scripts/build.sh(release from v0.1.0;scripts/build.sh devbuilds from the framework working tree for previews); the server needs neither Go nor Node. - Upload:
rsync -av --chmod=F644,D755 config/ rome:/srv/summercms-io/config/andrsync -av bin/summercms-io rome:/srv/summercms-io/bin/summercms-io.new;.envis never rsynced. - Release (every deploy):
cd /srv/summercms-io && sudo -u summercms ./bin/summercms-io.new migrate(D-27, before restart; cwd matters), keep the old binary asbin/summercms-io.prev, move.newinto place,supervisorctl restart summercms-io,curl -sI http://127.0.0.1:8095/. - Cutover (D-31): first save the existing summercms.io server block from rome into
deploy/rollback/nginx-under-construction.confin this repository and commit it (it cannot be read from the build machine), keep the old docroot, then replace the block in place withdeploy/nginx/summercms.io.confand runnginx -t && systemctl reload nginx. - Verify after cutover: curl checks for
https://summercms.io/200 withCache-Control: no-cacheand no robots-blocking header, a/_nuxt/asset withimmutable,/docs/200,/docs/setup/installation301 to.html,/backend404,POST /403,http://summercms.io/301 to https,https://www.summercms.io/301 to the apex; then the external link check against the live site. - Rollback: app (
mv bin/summercms-io.prev bin/summercms-io && supervisorctl restart summercms-io; 11.2 adds no plugin migrations) and cutover (restoredeploy/rollback/nginx-under-construction.confand the docroot fromdeploy/rollback/under-construction/, thennginx -t && systemctl reload nginx).
- Overview: what runs where (nginx :443 → 127.0.0.1:8095 summercms-io → Postgres 15
scripts/check-deploy.sh(bash,set -euo pipefail): in a temp prefix, generate a self-signed certificate with openssl, copy the nginx site config with the certbot paths, the options include and the dhparam line replaced by temp equivalents, wrap it in a minimalnginx.conf(events block, an http block with temp*_temp_pathentries andpidin the prefix), and runnginx -t -p "$TMP" -c "$TMP/nginx.conf" -e "$TMP/error.log"; parse the supervisor file withpython3configparser and require[program:summercms-io]withcommand,directory,user,autostart,autorestart,stopsignal; printcheck-deploy: ok.README.mdin APP: what the app is, the submodule layout andgit submodule update --init,scripts/build.shanddev, the test commands and their environment gates (SUMMERCMS_REQUIRE_BUILD,SUMMERCMS_TERMINAL_CHECK,SUMMERCMS_CLONE_URL,SUMMERCMS_CHECK_EXTERNAL), and a pointer to DEPLOY.md.README.mdin PLUG: what it serves (/,/docs,/docs/{path...}), the cache and content-type rules, the 301 for extension-less docs URLs, why it declares no admin controllers, and howpublic/is filled. Commit in PLUG asdocs: describe the site plugin, then in APP (with the updated plugin gitlink) asdocs: add DEPLOY.md with nginx, supervisor and rollback configs. ../sm-summercmsio-app/scripts/check-deploy.sh <fails_when>non-zero exit, "test failed" or "[emerg]" in the nginx output, or no "check-deploy: ok" line</fails_when> At cutover, follow DEPLOY.md on rome from the launch checklist to "Verify after cutover": the site answers at https://summercms.io with the landing page, /docs serves the docs with the "summercms.io" header link, /backend is 404, and https://www.summercms.io redirects to the apex. <acceptance_criteria>grep -n 'location ^~ /backend' ../sm-summercmsio-app/deploy/nginx/summercms.io.confandgrep -n 'limit_except GET HEAD' ../sm-summercmsio-app/deploy/nginx/summercms.io.confboth find a match.grep -n 'user=summercms' ../sm-summercmsio-app/deploy/supervisor/summercms-io.confandgrep -n '127.0.0.1:8095' ../sm-summercmsio-app/deploy/supervisor/summercms-io.confboth find a match.grep -c 'migrate' ../sm-summercmsio-app/DEPLOY.mdprints at least 1, andgrep -n 'createdb -O summercms' ../sm-summercmsio-app/DEPLOY.md,grep -n 'chmod 0600' ../sm-summercmsio-app/DEPLOY.mdandgrep -n 'git remote add origin' ../sm-summercmsio-app/DEPLOY.mdeach find a match.grep -n 'Rollback' ../sm-summercmsio-app/DEPLOY.mdfinds a match andtest -s ../sm-summercmsio-app/deploy/rollback/under-construction/index.htmlsucceeds.git -C ../sm-summercmsio-app ls-files -- .env deploy/.envprints nothing (secrets are never committed). </acceptance_criteria> DEPLOY.md, the nginx and supervisor configs, the env template and the rollback copy cover build, upload, release, cutover, verification and rollback on rome; the nginx config passesnginx -t; the user's launch steps (remotes, public repository, tag, external checks) are listed.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| internet → nginx (rome) | Untrusted HTTP requests; TLS terminates here |
| nginx → summercms-io on 127.0.0.1:8095 | Proxied requests; path and method filtered by nginx |
| request path → embedded file map | Untrusted path selects a file and may trigger a redirect |
| site.yaml / CLI flags → docs header HTML | Configured URL is written into every docs page |
| operator machine → rome | Binary and config upload; secrets stay on the server |
| summercms.go → module proxy | A pushed version tag becomes immutable public state |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-11.2-04 | Information disclosure | PLUG static.go path resolution | high | mitigate | path.Clean on a fixed root, lookups only in the in-memory map built from the embedded tree, dot-segment paths refused (hides .summer-docs); smoke asserts /docs/.summer-docs 404 |
| T-11.2-05 | Spoofing (open redirect) | /docs and extension-less docs redirects |
medium | mitigate | Location is a constant (/docs/) or t.mount + cleaned name + .html, only when that file exists; never from the raw URL |
| T-11.2-06 | Tampering (MIME sniffing) | static.go headers | low | mitigate | Own content-type table before mime, X-Content-Type-Options: nosniff; only build output is served |
| T-11.2-07 | Elevation of privilege | admin exposure | high | mitigate | The plugin declares no admin controllers, so cabana never mounts /backend; nginx location ^~ /backend { return 404; }; smoke asserts /backend 404 |
| T-11.2-08 | Information disclosure | DB password and app key | high | mitigate | Config YAML keeps key and dsn empty; secrets only in /srv/summercms-io/.env (0600, owner summercms), gitignored and never rsynced; deploy/env.example holds names only |
| T-11.2-09 | Tampering (XSS) | docs header site_url |
medium | mitigate | checkSiteURL allow-list (http/https with host, or a single-slash path) for both the key and the flag; html/template escaping; TestParseSite rejects javascript: and //host |
| T-11.2-10 | Tampering (clickjacking) | public pages | low | accept | Static marketing and docs pages with no state-changing actions; the admin CSP is deliberately not reused because it blocks Nuxt's inline scripts (D-07) |
| T-11.2-11 | Information disclosure (transport) | nginx | high | mitigate | HTTPS only with certbot certificates, HTTP→HTTPS 301, a 443 www→apex block; the binary listens on loopback only |
| T-11.2-12 | Denial of service | nginx and binary | medium | mitigate | limit_except GET HEAD at nginx, GET-only routes (405 otherwise) and in-memory static files in the binary; supervisor restarts on exit |
| T-11.2-13 | Tampering / Repudiation | v0.1.0 tag | medium | mitigate | Blocking-human checkpoint before creation and push; annotated tag at a clean, green commit whose sha is shown to the user |
| T-11.2-14 | Elevation of privilege | runtime account | medium | mitigate | Dedicated nologin system user, dedicated Postgres role owning only summercms_io (D-27), app on 127.0.0.1 |
| T-11.2-SC | Tampering | dependency installs | high | mitigate | No new Go module (plugin and app import only stdlib and framework modules, checked by an acceptance criterion); build.sh installs npm packages only with --frozen-lockfile from plan 11.2-01's lockfile |
| </threat_model> |
<success_criteria>
- SC2: one binary embeds and serves
/and/docswith indexable responses and the cache rules (smoke and plugin tests). - SC3: every link on the page resolves against the built docs (TestLandingLinks); external links are checked at cutover.
- SC4: scripted build and DEPLOY.md with supervisor and nginx configs (TLS, proxy, gzip); the clean-server bring-up is the cutover UAT item.
- D-25, D-41, D-42, D-46 framework changes landed before the tag; the tag is created only after confirmation. </success_criteria>
User steps at cutover (D-38, D-42, D-43)
Creating the three remotes and pushing, making golem15/summercms public, pushing v0.1.0 if it was deferred, running the external-link and verbatim terminal checks, saving rome's current server block, and the rome deploy itself. DEPLOY.md's launch checklist lists them; the SUMMARY repeats them.
Artifacts this phase produces
- Repositories:
sm-summercmsio-app(modulegit.golem15.com/golem15/sm-summercmsio-app, binarysummercms-io) andsm-summercmsio-plugin(modulegit.golem15.com/golem15/sm-summercmsio-plugin, packagesummercms, plugin IDgolem15.summercms) at/media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-appand itsplugins/golem15/summercms; submodule URLsgit@git.golem15.com:golem15/vue-summercmsio-app.gitandgit@git.golem15.com:golem15/sm-summercmsio-plugin.git. - Plugin symbols:
Plugin(fieldfsys; methodsID,Requires,Register,Boot,Routes),publicFS,newHandlers,tree(fieldsfiles,mount,immutable,htmlRedirect; methodServeHTTP),file(fieldsbody,etag,ctype),newTree,errMissingIndex,siteImmutable,contentTypes,contentType,redirectTo,cacheImmutable,cacheNoCache. RoutesGET /docs,GET /docs/{path...},GET /. - Plugin tests:
TestStaticSmoke,TestLandingLinks,TestTerminalCommandsInPage,TestDocsHeaderSiteLink,TestExternalLinks; helpersrequireBuild,pageLinks,resolve. - App test symbols:
terminalGroup,loadTerminal,terminalCloneURL,terminalScript,terminalEnv,TestTerminalCommands. - Environment variables:
SUMMERCMS_FRAMEWORK,SUMMERCMS_REQUIRE_BUILD,SUMMERCMS_TERMINAL_CHECK,SUMMERCMS_CLONE_URL,SUMMERCMS_CHECK_EXTERNAL,SUMMERCMS_SITE_DIR,SMOKE_PORT; runtimeSUMMER_DATABASE__DSN,SUMMER_APP__KEY,SUMMER_ENV. - App config keys:
app.key,http.body_limits.default_bytes,http.body_limits.upload_bytes,http.trusted_proxies,storage.uploads.bucket_url,storage.uploads.public_path_prefix,queue.work_in_serve,database.dsn. - Scripts and deploy files:
scripts/build.sh(release,dev),scripts/smoke.sh,scripts/check-deploy.sh,DEPLOY.md,deploy/nginx/summercms.io.conf,deploy/supervisor/summercms-io.conf,deploy/env.example,deploy/rollback/under-construction/. - Framework:
docsite.Site.SiteURL,docsite.Site.SiteLabel,docsite.Options.SiteURL,docsite.Options.SiteLabel, unexportedcheckSiteURL,siteLabel,pageView.SiteURL,pageView.SiteLabel;site.yamlkeyssite_url,site_label; CLI flags--site-url,--site-labelonsummer docs:buildandsummer docs:serve; CSS classes.site-link,.site-link-label; testsTestSiteLink,TestDocsBuildSiteFlagsand newTestParseSiterows; git tagv0.1.0(after confirmation).