Negotiation table (Sec-Fetch-Mode, X-Requested-With, Accept q values, the
32-range cap, /api, /_ and admin paths), byte equality with the
http.error_pages.enabled false router for curl, fetch, XHR and JSON
clients, page content and headers for every covered status, details
gating and escaping, renderer override and fallbacks, the admin prefix
and the uploads route.
A browser navigation (Sec-Fetch-Mode navigate, or GET/HEAD without it; no
X-Requested-With; Accept naming text/html above every JSON type; not /api,
/_<name> or the admin prefix) that hits a framework-generated 404, 405 or
500 gets a self-contained SummerCMS page: unmatched routes and wrong
methods at the ServeMux, failed Where/WhereIn constraints, overlap-family
misses, the unpublished uploads route and recovered panics. Every other
request keeps the exact stdlib text, wire's opaque JSON 500 or the bare
raw-group 500.
Details (method, path, X-Request-Id) only with app.debug or outside
production. Applications replace the page with a published
surf.ErrorPageRenderer; http.error_pages.enabled false turns it off.
The record and relation-child thumb routes now also serve the thumbnail
of a public JPEG, PNG, GIF or WebP file, under the same record or
pending-session scope and D-10 headers as a protected one, so the admin
can preview a public image whose public URL does not load. The download
routes still answer 404 for every public file, and a public non-image
thumb stays 404.
TestPublicImageThumb covers owner, foreign and hidden records, image
types, a public row under a protected field, another admin's pending
upload and the relation-child route.
- a code is refused for another client or redirect URI and burned
- authorize refuses plain and malformed challenges; verifier rules; 60 s code TTL
- rejected token exchanges log client id, IP and reason only; key parse errors quote no key
- a public sub is a random UUIDv4
- capture rule from: response.form reads the value of the <input> with
the rule's name (a server-rendered form's one-shot token), so a parity
flow can post a consent form back
- README and parity docs list the capture sources
- DefaultOptions().Resource is empty; comments name the host application,
no consuming application (folded todo)
- Tests set an explicit neutral resource; TestDefaultOptionsResourceEmpty
- wristband README: new summary, two flavours, OIDC usage, API reference,
OIDC defaults, dependencies, testing; root README row matches
- docs/services/oauth-server.md: OpenID Connect provider section;
Resource described as required with an empty default
- ExampleNewOIDCServer and the newOIDCServer docs region
- Full validation chain with PHP order and messages; repeated params last-wins
- is_dev loopback port matching (matchOIDCRedirectURI); PHP http_build_query redirects, no iss
- One-shot consent token burned under row lock; deny; SwitchAccount; JSON-mode
ConsentRequest and DecideConsent
- ClientRecord.Kind with ClientKindMCP/ClientKindOIDC; each flavour refuses the
other kind at authorize, token and userinfo (D-08)
- Smoke tests: start, resume/decide/switch, kind isolation, PHP encoding
- NewOIDCServer/SetOIDCBackend build a second Server on the same engine;
Authorize and Token dispatch by flavour, Metadata/Register 404 on OIDC
- OIDCBackend/OIDCTx, KeyStore and OIDCHost seams; MCP Backend untouched
- Discovery (PHP field order), JWKS (stdlib JWK encoding), authorize with
resume and one-shot consent token, token with an RS256 id_token signed
through golang-jwt map claims, userinfo
- TestOIDCTracerSmoke verifies the id_token against the served JWKS
- One shared localizer for ValidationError and ForbiddenError, tested in
writeCRUDError's order; replaces the ForbiddenError-only one.
- Applied on the CRUD and relation service transactions, the detached bulk
branch, runAction, the record read (ShowRecord), and the reorder,
singleton and relation-options routes, which localized neither type.
- Only strings containing "::" are looked up, so translated text passes
through unchanged and logs no missing-key warning.
- Copy-on-write: the plugin's error value is never modified.
- README and backend docs describe the localized ValidationError.
- modules/breakwater exports Render(markdown) and Purify(html): goldmark GFM
in its default safe mode (raw HTML stripped, unsafe links dropped), then a
named bluemonday policy built from NewPolicy, not the UGC preset
- the policy is the PHP golem15/forum HTMLPurifier config 1:1: the fixed
element list, a[href|rel|target], http/https/mailto only, rel=nofollow on
every link, target=_blank plus noopener on fully-qualified links
- tests cover the PHP ForumMarkdownRenderTest corpus, links per scheme,
blank input, the Purify allow-list and concurrent use; testable examples
feed the docs page snippets
- module README, root module table row, introduction.md Utilities row and
docs/services/markdown.md (which gate serves user content vs admin fields)
- go.mod and go.sum unchanged: bluemonday v1.0.27 and goldmark v1.8.6 were
already pinned
- pact.NavigationItem gains Visible func(ctx) bool (json:"-"); nil keeps the
entry always listed
- cabana.Registry.Metadata runs it after the permission check, drops a main
item (with its side menu) or a side item that answers false, and relinks a
parent whose controller is a hidden child's to its first visible openable
child
- TestNavigationHidesInvisibleItemsAndRepointsTarget covers the rules
- pact and cabana READMEs, users-and-permissions and admin-controllers docs
describe the hook
pact.ListExtendRecords fills unstored fields of one list page before its
columns are read (WinterCMS listExtendRecords); such columns compile
unsortable and refuse sortable/searchable. pact.FormRecordTitle names a
record; the show, create and update responses send it as meta.title.
- a detached Run sees no transaction and holds no row lock (FOR UPDATE
NOWAIT from another connection succeeds), with the empty, absent and
partial-selection rules, a ValidationError as 422 and earlier writes kept
- the same action without Detached still holds the transaction and locks
- test-only actions on the roster fixture through its wrap hook; README
Testing names the test
- pact.AdminBulkAction.Detached (json:"-"): opt-in; records loaded through the list scope without row locks, no write transaction in the context
- cabana BulkAction: detached branch with the same empty, partial-selection, failure and message rules; lockScoped is loadScoped with lock, the locked path unchanged
- docs: admin-controllers bulk route list, pact and cabana READMEs
- principalFrom sets PreferredLocale from metadata admin_locale; personal
API token principals clear it, so token clients keep the request locale
- the guarded admin group runs locale.from-principal after the backend
guard, overriding the LocaleResolver or Accept-Language locale
- GET /lang honours a supported ?locale= and reports meta.locales
(cabana.LangEnvelope, cabana.LangMeta)
- Docker test for the override, refusals and /lang; docs and OpenAPI
- GET and PUT /account/preferences (own row only, interactive sessions,
CSRF header on cookie writes) store admin_theme and admin_locale in
backend_users.metadata, keeping every other key
- GET /auth/me and the My account answers carry preferences
- phrasebook.Translator.Locales lists the locales defining a prefix
- route tables, conformance cases, OpenAPI document, TS types and docs
- caching.md: an https purge_url on another host goes through the site's
proxy; set NUXT_SITE_CACHE_ALLOW_PROXIED=true there, the token is then the
only gate
- cooler README: the purge_url row points at it
- PurgeSiteCacheAfterCommit queues the purge on a per-purger worker after
commit: one request in flight, saves committed meanwhile collapse into
one follow-up, so the admin request never waits for the site
- new cooler.SiteCachePurger.Wait drains the queue; Handle.Close waits for
it at shutdown (timeout plus one second)
- Purge stays the synchronous send; exported names and signatures unchanged
- the purge client's CheckRedirect returns http.ErrUseLastResponse: a 3xx
is logged as a failed purge with its status and the bearer token never
reaches the Location
- BuildRouter logs one warning with the route count and the production
flag when routes name turnstile:open or turnstile:closed and the
verifier has no secret; the secret is never logged
- attach.Store returns attach.ErrProtectedNeedsPrivateBucket for an
is_public false upload while storage.uploads.public_url is set, before
any blob is written
- OpenBucket's public_url line becomes a warning naming the refusal
- LoadCookieConfig refuses a Secure that resolves to false in production,
also when inferred from an http or unset app.url, naming
auth.cookie.secure and app.url
- with supports_credentials, a * origin must be the whole leftmost host
label, patterns must be anchored with ^ and $, and no origin rule may
match the probe origins outside any configured domain
- globally and per profile; non-credentialed configs load as before
- Router.wrap applies the route's profile outside recovery, body limit and
locale, whatever its position in the list or the group nesting
- bufferedResponse.commit merges Vary instead of replacing it
- TestCORSProfileHeadersOnRefusals, TestCORSProfileVaryKeptWithHandlerVary
required by name in the gate
- miniredis v2.40.0 as a test-only dependency (approved at the Task 4 check)
- memory and Redis cache suites, Flush limited to <prefix>cache:
- one parity table through surf.MemoryStore, miniredis and real Redis
- Install, FromApp and site_cache validation; Postgres after-commit purge
- gate: cooler names in the go and docker stages
- manifest_tags_test.go: build.tags load, trim, dedupe and validation
errors naming build.tags; SaveManifest keeps tags and plugin order;
goBuildArgs puts one -tags before -o
- s3_test.go: s3:// refused without summercms_s3 naming the tag, opens
with it (both build modes, any scheme case)
- diskext_test.go: RESEARCH probe rows and table consistency
- url_test.go: public_url base joining, File.URL and thumbnails,
malformed values refused naming the key
- store_test.go: Postgres regression table through Store
- gate: attach names in both tag modes, Store regression in docker
- cors_profile_test.go: credentials with * refused (global and profile),
pattern errors, anchored wildcard origins, Vary: Origin on dynamic
answers, api/www credentialed case, unchanged * answers, profile
preflight routes and boot errors, turnstile factory params
- apidocs_test.go: off by default, pinned 5.33.1 page with the RESEARCH
SRI hashes and nonce CSP, ordered compact JSON, provider count, path
validation, escaped title
- store_lookup_test.go: MemoryStore fallback, published Store, no dial
- reorder_hook_test.go (Postgres): hook sees the new order in the tx,
its error rolls back, no hook answers unchanged
- gate: surf names in the go stage, cabana names in the docker stage
- scripts/check-phase19.3.sh: fail-closed gate (go stage, self-test, --all
with NOT RUN docker); a required name that fails, skips or is missing
fails its stage
- cookie_test.go: LoadCookieConfig defaults and errors, TokenCookie
attributes and refresh-window Max-Age, forget cookie, refresh and logout
handlers with a real Refresh and MemoryBlacklist
- turnstile_test.go: open/closed verdict matrix, timeout, form fields,
token sources, body restore, statuses and secret-free logs
- optional ReorderAfterSave(ctx, partition) runs after the new order is
written; an error rolls the reorder back through lifecycleFailure
- reorderPost runs in lagoon.Transaction so lagoon.AfterCommit work a hook
registers (a site-cache purge) runs only after the commit
- pact and cabana READMEs, admin hook table, tree reorder docs
- cooler.SiteCachePurger moved from the Journal plugin: loopback-only http,
32-character token, 2 s detached request, no token in logs or errors
- NewSiteCachePurgerFor names caller keys (for plugin alias keys)
- PurgeSiteCacheAfterCommit through lagoon.AfterCommit, one POST per save
- SiteCacheFromApp for hooks outside serve; Install publishes the purger
unless a plugin already did, and fails boot on an invalid config
- caching docs section, cooler README, configuration row
- OpenBucket validates public_url (absolute http/https, host, no user info, query
or fragment; the error names the key) and records it next to public_path_prefix
- a set public_url logs one start-up line: the bucket behind it must hold only
public files, since it bypasses the is_public check
- attach.PublicURLBase; PublicURL, File.URL and thumbnail URLs use the base when
set and keep the public_path_prefix proxy URLs otherwise
- TestPublicURLSmoke; storage Public URL base section, attachments, configuration
and lagoon README
- DiskExtension: a fixed table of specific sniffed types keeps a matching client
extension or uses the canonical one; generic sniffs keep the client extension
unless it is script-capable (then txt or bin); never asks package mime
- Store names the blob with DiskExtension(sniffed, ext); file_name, the allow-list
and the stored content_type are unchanged
- TestStoreDiskExtensionSmoke: a PNG uploaded as x.html is stored and served as .png,
report.docx keeps .docx, an HTML body named note.txt keeps .txt
- attachments docs on disk names, storage deploy note on nosniff and allow-lists,
lagoon README
- Manifest.Tags from build.tags (^[a-z0-9_]+$, duplicates dropped), round-tripped by SaveManifest
- summer build passes -tags=<list> through goBuildArgs and prints the build tags line
- attach: s3blob imported only by s3_on.go (//go:build summercms_s3); an untagged binary
refuses an s3:// bucket_url naming the tag, build.tags and -tags
- go mod tidy adds the AWS SDK v2 modules as indirect requirements
- storage, modules, scaffolding, configuration and scaling docs plus the lagoon README
- surf: APIDocsProvider (one plugin) supplies a YAML or JSON OpenAPI document,
converted once at boot to compact JSON in source key order; zero or several
providers or a spec that does not convert fail boot
- http.api_docs.enabled/path/title mount GET /api-docs and /api-docs.json
(summercms.apidocs, public, refusing to replace a plugin route)
- the page loads swagger-ui-dist 5.33.1 from jsDelivr with sha384 SRI and
crossorigin on all three assets, a per-request CSP nonce for its inline
script, nosniff and no-referrer
- docs: API documentation page section, README rows, ExampleAPIDocsProvider
- bouncer: Turnstile.Verify posts secret/response/remoteip to siteverify with
a 5 s bound and PHP's open and closed modes (503 unconfigured in production,
422 on a missing, rejected or overlong token, an error or a timeout); errors
never carry the secret or the token
- bouncer: Turnstile.Middleware reads the token from the query, a JSON body or
a form body, restores the body and writes refusals through a published
TurnstileWriter or neutral JSON
- surf: factory turnstile:open|closed from turnstile.secret/turnstile.field
(a published *bouncer.Turnstile wins), client IP with trusted proxies; any
other param fails boot
- docs: Turnstile section, middleware table, README rows
- surf: LoadCORSProfiles reads http.cors_profiles.<name> (http.cors keys
without paths, validated the same way); factory cors:<profile> sets the
profile's headers; an unknown profile fails boot naming plugin and route
- BuildRouter registers OPTIONS <path> (owner surf, recovery only) for every
path whose routes name cors:<profile>; two profiles on one path, an existing
OPTIONS route or overlap with http.cors.paths fail boot
- docs: CORS profiles section, middleware table and README rows
- bouncer: CookieConfig from auth.cookie.* (LoadCookieConfig validates domain,
same_site none without Secure, secure false in production), TokenCookie with
Max-Age = refresh window, ForgetTokenCookie, CookieRefreshHandler and
CookieLogoutHandler with an app-supplied writer
- surf: http.cors refuses credentials with a * origin and bad or PHP-delimited
patterns at boot, turns wildcard origins into anchored patterns, echoes the
exact origin and sends Vary: Origin on every dynamic CORS answer; the
non-credentialed * answer is unchanged
- smoke tests for the www -> api refresh and the refused wildcard config
- Cache: Get, Set, Has, Forget, Remember (degrades on cache errors), Increment, Flush
with per-entry TTLs under <prefix>cache:
- memory driver on otter v2.3.0 bounded by cache.memory.max_entries; Redis driver
with SET PX, INCRBY and a Flush limited to SCAN MATCH <prefix>cache:* plus UNLINK
- Install publishes the Cache (sharing one Redis client with the rate limiter);
FromApp serves jobs and commands without a dial
- new docs/services/caching.md, configuration rows, cooler README
- new module cooler: Install, Handle, OpenRedis (no URL in errors), RateLimitStore
(atomic fixed-window Lua script with MemoryStore semantics, per-instance fallback)
- serve installs cooler and publishes the Redis surf.Store before BuildRouter;
BuildRouter looks up a published Store, route:list keeps the memory store
- config keys http.rate_limit.store and redis.url; go-redis v9.23.0
- cooler README, root table row, surf README and rate-limiting, configuration
and scaling docs