230 Commits

Author SHA1 Message Date
6312929148 fix(17-06): singleton route retries a lost first-or-create race
Two first requests to GET .../singleton both missed the record; the
loser's insert hit the unique index and the route answered 500. A
unique violation from SingletonRecord now reruns the transaction once,
which finds the winner's record. TestSingleton fires six concurrent
first requests and requires 200 with the same id from each.
2026-10-12 19:31:01 +02:00
91593da1f7 test(17-06): framework and admin SPA unit tests for the blog companions
- signpost: activation refusals, host overrides, D-02 pattern layers
  incl. PatternFrom, ReferenceOptions depths
- seaglass: removal report, bluemonday bypass corpus, Check
- pact Option.Depth omitempty and the menu item type contracts; party
  activates menu item types after Register and before the first Boot
- cabana: repeater compile and decode, dependsOn compile and recheck,
  richeditor keys and notices, reorder and singleton boot checks
  (Docker-free); repeater save, dependent recheck, richeditor save,
  reorder and singleton routes on Postgres; conformance cases for the
  documented 404, 409 and 422 answers of the new routes
- admin: richeditor value contract, link popover, notices and chunk
  failure; repeater bounds, remove and reorder, collapsed rows;
  dependent dropdown states and last request wins; reorder
  announcements, partition switch and tree rules; SingletonForm spec
- gate: required names in the go, admin and postgres stages
2026-10-12 19:29:37 +02:00
490a4e3e6a test(17-06): phase 17 gate with one contract test per layer
- scripts/check-phase17.sh: stages go, plugins, admin, layers, docs and
  postgres across the framework, four plugin repositories, the host and
  both layers; go test -json, vitest JSON and TAP detectors, required
  layer test files, --self-test, --all with NOT RUN postgres and
  PHASE17_REQUIRE_POSTGRES
- TestRegistryDuplicateKey: a duplicate key fails Activate naming both
  plugin IDs, nothing is published
- TestSanitizeByteStable: clean editor HTML comes back byte for byte
2026-10-12 19:03:21 +02:00
01bcd3efa4 build(17-02): rebuild admin dist with the reorder page and singleton form 2026-10-12 16:18:32 +02:00
872fd6535f feat(17-02): tree reorder page and singleton form in the admin
- ReorderView: indented tree with drag handle, move up/down, outdent and
  indent (buttons and arrow keys on the handle), pointer drag before, after
  or inside a row and never into its own subtree; one POST with the whole
  node set 400 ms after the last move, flushed on leave; a failed save
  restores the saved tree, a 409 reloads; loading, empty, load-failure and
  403 states; partition selector
- ListView: outline Reorder button before New record, passing the filtered
  partition
- SettingsIndexView opens a singleton controller's record through
  GET …/singleton; FormView in singleton mode has no Delete and returns to
  the settings index
- reorder.* and messages.list.reorder phrase keys (en, pl), smoke spec, docs
2026-10-12 16:18:31 +02:00
204149ccd2 build(17-02): rebuild admin dist with the repeater and dependent dropdown 2026-10-12 16:08:23 +02:00
d3bf5d8f86 feat(17-02): repeater field and dependent dropdown states
- RepeaterField: rows with drag handle, index, titleFrom title, error chip,
  move up/down (buttons, ArrowUp/ArrowDown on the handle, native drag and
  drop), expand/collapse, remove (asks only for a row holding a value),
  add with nested defaults, maxItems counter, empty state, collapse all;
  every change edits form state only
- 422 row paths (content.3.answer) open the row, mark it and focus the
  nested control; the banner no longer repeats row messages
- DropdownField dependsOn: waiting, loading, not used, empty, loaded with
  depth indent, failed with retry; cached per sibling value, last request
  wins, a stale value is cleared after a sibling change and announced
- FormView provides form feedback (errors and notices by row path, the
  polite live region, its confirm dialog) and the options cache; FormGrid
  provides the sibling scope for repeater rows
- repeater.* and form.dependent_* phrase keys (en, pl), smoke specs, docs
2026-10-12 16:08:23 +02:00
4a5e896429 build(17-02): rebuild admin dist with the richeditor field
TipTap is its own lazily loaded chunk (assets/editor-*.js).
2026-10-12 15:57:03 +02:00
cb2987c146 feat(17-02): richeditor field on TipTap with the untouched rule
- RichEditorField: Reka toolbar, link popover with the http/https/mailto/tel
  and relative URL policy, Edit HTML view, lossy note, skeleton while the
  lazily imported TipTap chunk loads, HTML view fallback when it fails
- untouched values are submitted byte-identical; after an edit the value is
  the editor HTML (empty paragraph -> ""); a value from outside (the save's
  sanitized answer) reloads the editor
- read-only renders through a client display allowlist, never as stored
- TipTap 3.31.4 (core, pm, starter-kit) pinned exact after the package check
- richeditor.* phrase keys (en, pl), smoke specs, forms docs
2026-10-12 15:56:59 +02:00
23f33c3911 feat(17-01): tree reorder page and singleton admin controllers
- cabana: config_reorder.yaml (title, nameFrom, modelClass, parentColumn,
  sortColumn, maxDepth, partition) with fail-loud boot checks and the
  built-in reorder toolbar button; list schema carries reorder
- GET/POST {controller}/reorder: scoped set (ListExtendQuery plus the
  partition) locked FOR UPDATE, 409 reorder_stale on any set difference,
  422 for duplicates, foreign parents, cycles and maxDepth, both columns
  written without model hooks
- pact.AdminSingleton: GET {controller}/singleton (first or create, form
  scope checked), form schema singleton, list/create/bulk/delete 404,
  no config_list.yaml needed
- swag, inventories, token classes, CSRF walk, conformance cases, backend
  lang reorder.stale and load_failed, reorder and singleton smoke tests, docs
2026-10-12 15:35:43 +02:00
46835fd064 feat(17-01): repeater field type and the dependent dropdown save recheck
- cabana: type repeater with nested form.fields (text, textarea, number,
  checkbox, switch, dropdown, richeditor), prompt, minItems, maxItems,
  titleFrom; groups, style, nested repeaters and other types fail boot
- rows decoded before projection: only nested keys, coerced scalars,
  per-row required (field.index.nested), row bounds; ProjectWritableFields
  exempts repeater lists only, nestedValue unchanged
- nested richeditor values sanitized with notices under the row path
- dependent dropdowns rechecked on save, top level and per row; null when
  the sibling takes no choice, 422 form.dependent_invalid otherwise
- dependent-options accepts repeater.field; schema carries RepeaterForm
- backend lang repeater.max_items, repeater.min_items,
  form.dependent_invalid; TestCompanionsRepeaterSmoke; docs
2026-10-12 15:24:01 +02:00
71fa597830 feat(17-01): richeditor field type on the seaglass sanitizer
- seaglass (new module): bluemonday v1.0.27 policy ported from Journal
  without the Blog Editor rules, byte-stable token comparison,
  SanitizeReport, Sanitize, Check, Removals
- cabana: type richeditor (common keys plus size); every create and update
  value sanitized before Fill, non-string values are a 422, removals become
  meta.notices; settings and relation forms refuse the type
- backend lang richeditor.removed_* and markup_changed (en, pl plurals)
- TestSeaglassSmoke, TestCompanionsRichEditorSmoke, README and docs
2026-10-12 15:15:38 +02:00
0cdff4c258 feat(17-01): menu item type registry and dependent dropdown binding
- pact: HasMenuItemTypes, MenuItemType, MenuItemReference, MenuItemResolve,
  ResolvedMenuItem, MenuItemAlternate, DependentOptionsProvider,
  DependentOptionSet; Option gains Depth (omitempty)
- signpost (new module): boot-time registry with fail-loud key and pattern
  checks, layered URL patterns incl. PatternFrom for list types, host
  overrides at menu_items.patterns, Resolve and ReferenceOptions
- party: signpost.Activate after postcard, before Boot
- cabana: dependsOn on dropdowns (boot checks, controller-only), GET
  fields/{field}/dependent-options with swag, inventories, conformance case
- companions fixture, TestSignpostSmoke, TestCompanionsDependentSmoke, docs
2026-10-12 15:09:14 +02:00
01d2548ab3 fix(14.2): WR-04 validate storage.uploads.public_path_prefix at boot
The prefix was only trimmed. Without a leading slash ServeMux read its first
segment as a host pattern, so every upload silently answered 404, and a CDN
URL panicked in ServeMux and surfaced as a misleading route conflict. With
storage.uploads.serve on, BuildRouter now refuses a prefix that is not an
absolute URL path (scheme, host, query, fragment, whitespace, wildcard
braces, empty, . or .. segments, or a bare /) with an error naming the key.
2026-10-12 11:21:38 +02:00
41a03b9987 fix(14.2): WR-03 uploads route sandboxes files and downloads non-images
The route answered with the blob's stored Content-Type, which attach.Store
sniffs from the bytes, so a public text upload starting with <html> was
served as text/html on the application origin; nosniff does not help when
the declared type is already HTML. Every response now carries a sandboxing
Content-Security-Policy, and a successful response whose type is not a
raster image (JPEG, PNG, GIF, WebP, AVIF) gets Content-Disposition:
attachment, on GET and HEAD. Covers stay inline.
2026-10-12 11:20:43 +02:00
c30145ed25 fix(14.2): WR-02 499 only for a 5xx caused by the client going away
Every 5xx became 499 once the request context was cancelled, so a panic or
a real database error on a request the browser had abandoned passed the
phase gate's 5xx scan. Now a 5xx is logged as 499 only when the client had
already gone when the status was written and the handler did not panic;
recoverJSON and recoverBare mark the access log entry. Every record of an
abandoned request carries client_gone=true. The gate self-test plants a
5xx with client_gone=true and must refuse it.
2026-10-12 11:19:12 +02:00
fbcfaaf05f fix(14.2): WR-01 access log never records the URL path
An unmatched request logged its path. A token route reached with another
method (405) or with a trailing slash (404) is answered by ServeMux before
any route handler records the pattern, so the share or invitation token in
the path reached the log. The record now carries method, pattern, status
and duration only.
2026-10-12 11:17:51 +02:00
2c5c0583f2 test(14.2-04): uploads mount and access log unit tests; the uploads route owns its prefix
- TestUploadsMount: key off registers nothing; on, a public blob is served
  with its content type and nosniff, HEAD has no body, private, missing,
  partition and encoded traversal paths 404, an unpublished bucket or
  database 404s, a custom prefix is honoured
- mountUploads refuses a plugin route at or under the prefix, so
  BuildRouter fails instead of only an identical pattern failing at
  Assemble (README and storage docs updated)
- TestAccessLog: pattern and status for 200, handler 404 and recovered
  panic, path only for unmatched requests, locale-prefixed requests keep
  the pattern, integer duration_ms, no query, header or Authorization
  value, ResponseController flush still works
2026-10-11 23:23:38 +02:00
4660f95e5c fix(14.2-03): access log records a cancelled request's 5xx as 499
A client that goes away mid-request cancels the context; a handler then
fails on context.Canceled and writes a 500 nobody receives. The request
log now records it as 499 (client closed request) so a grep for 5xx shows
real server errors only.
2026-10-11 20:27:56 +02:00
2d68eab3c0 feat(14.2-01): opt-in http.access_log request log in surf
- one Info record "http request" per request with method, matched route
  pattern, status and duration_ms; path without the query only when no
  route matched
- the matched route records its pattern through a request-scoped entry,
  so locale prefix rewrites do not hide it
- logs through the published *slog.Logger or slog.Default; off by default
- surf README, logging and configuration docs describe the key
2026-10-11 17:42:51 +02:00
0b6d757b40 feat(14.2-01): opt-in storage.uploads.serve route for public uploads
- surf mounts GET {storage.uploads.public_path_prefix}/{path...} on
  attach.StaticHandlerPublic when storage.uploads.serve is true
- bucket and database resolved per request; X-Content-Type-Options: nosniff
- surf README, storage and configuration docs describe the key
2026-10-11 17:36:54 +02:00
b45af8ebbf fix(cabana): scope the token last_used_at throttle to the guard instance
- The process-global debounce map was keyed by token ID, so a stamp from one test database suppressed a later write in another and turned TestBackendTokenPostgres red depending on order
- Each compositeGuard now owns a stampThrottle; the backend guard and the ResolveBackend guard of one activation share it, so a single-app binary still writes last_used_at at most once a minute per token
- TestTokenLastUsedThrottlePerGuard pins the per-guard isolation
2026-10-11 15:19:55 +02:00
7ef2e4f6cb test: honour -short in Docker-backed TestMain and cabana list-service fixture
TestMain scanned os.Args for a literal -test.short, but go test passes
-test.short=true, so beachcomber, conga, lagoon and lighthouse started a
Postgres container even under -short. Parse the flags and use
testing.Short() instead; skip cabana's newListService fixture under -short
like adminGorm already does.
2026-10-11 14:43:40 +02:00
73074ee257 fix(admin): keep plugin rail and section panel sticky so Settings stays at the viewport bottom 2026-10-11 14:34:24 +02:00
a0bf8ece05 feat(sieve): add the remaining collection helpers and docs mentions
- Add TryMap, Filter, Reduce, KeyBy, GroupBy, Uniq, UniqBy, Partition
  and Find, plus the Set type built with SetOf (Has, Add, Slice).
- Add unit tests at 100% statement coverage and a runnable example for
  every function and Set method.
- Complete the module README: contracts, use cases, Pluck rationale,
  standard-library pointers and the full API reference.
- docs/architecture/introduction.md: add a Utilities row for sieve.
- docs/setup/coming-from-wintercms.md: map Laravel collection methods
  to sieve and narrow the not-provided row to the fluent collection
  object.
2026-10-11 14:27:13 +02:00
6933004b87 feat(sieve): add sieve module with Map 2026-10-11 14:22:56 +02:00
cb4b0a9c75 fix(16.3): disable Create token until a scope is selected
Client-side canSubmit now matches the 422 rule so Submit stays disabled
with an empty scope set (WR-02).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 13:53:32 +02:00
a955d2a3d4 fix(16.3): refresh CSRF coverage golden and blog scaffold layout
Account token POST/DELETE routes made TestPhase10Coverage expect 32 unsafe
routes; token_scopes.go is hand-authored and excluded from the scaffold pin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 13:53:31 +02:00
78124ab86f test(16.3-04): full Go coverage for tokens, guard, account API and CLI
Pin bouncer Scope serialization, party Activated order, lagoon migration,
compile/overlap/mint/expiry/validation, guard/debounce/resolver,
interactive-only, account token API and CLI on Postgres.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 13:32:11 +02:00
91830e506f test(16.3-04): pin deny-by-default contract at every layer
Scope match/Allows tables, route classification for every phase09Routes
entry, SPA plaintext lifetime backstops, and Postgres owner lifecycle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 13:17:41 +02:00
2030fd881a fix(16.3-03): expect 32 CSRF-gated admin routes after token API
Account token create/revoke added two state-changing mounts; update the
Phase 10 CSRF walk count so the release gate inventory stays green.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 02:20:55 +02:00
60873a3fea docs(16.3-03): ExampleResolveBackend for plugin token auth
- Add compiled ResolveBackend example with acme.blog cover
- Include src= fence in authentication docs (Bearer-only)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 02:17:43 +02:00
1c930b6ff7 feat(16.3-02): list states, show-revoked toggle, and revoke via ConfirmDialog
Add skeleton/empty/error coverage, revoked-row handling, ConfirmDialog
overflow wrap for long names, and rebuild boardwalk dist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 02:04:54 +02:00
5a6a190e7f feat(16.3-02): tracer — API tokens card on My account with show-once create
Wire ApiTokensPanel after the profile form, add the two-step create dialog,
phrase keys, smoke tracer, and rebuilt boardwalk dist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 02:02:44 +02:00
e24e03c47d feat(16.3-01): admin:token CLI, party.Activated, READMEs and docs
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 01:55:07 +02:00
ffdb5d00ca feat(16.3-01): account token API with OpenAPI and phrase keys
- Add list/create/revoke/scopes handlers, validation, and soft revoke
- Regenerate admin OpenAPI and TS types; inventory and conformance cases
- Add server-side api_tokens phrase keys (en/pl)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 01:51:11 +02:00
183583e0e9 feat(16.3-01): interactive-only gates, ResolveBackend, last_used stamp
- Refuse g15_ tokens on account/avatar/refresh/logout; gate field-types and markdown
- Publish cookie-less ResolveBackend; debounce last_used_at once per minute
- Reserve account vendor segment; TestTokenGatesSmoke covers the deny path

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 01:47:52 +02:00
b9273c1da7 feat(16.3-01): tracer — g15_ token scopes through composite backend guard
- Add pact.TokenScope/HasTokenScopes and bouncer.ScopeLimit on Principal
- Append backend_api_tokens migration; Allows evaluates scope before superuser
- Composite guard routes Bearer g15_… to token lookup; smoke tests cover path

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 01:46:32 +02:00
602e0412f3 docs: add a Role in the framework section to every module README
Each module README now says where the module sits in the dependency
graph, why it is its own module, and lists use cases for application
developers.
2026-10-11 00:58:17 +02:00
ca1443e9c8 docs: add a name mnemonic to every module README and the root modules table 2026-10-11 00:26:20 +02:00
11be08d680 test(16.2-07): conformance cases for the field-types and field upload routes
- conformCases() lists every case at package level; checkConformInventory
  reports every missing, extra or duplicate key in one sorted failure
- TestPhase10ConformanceInventory runs it without a database, and
  TestPhase10OpenAPIConformance runs it before the testcontainers setup
- GET /field-types decodes into cabana.FieldTypesResponse and serves the
  fixture type's script; POST .../fields/{field}/upload answers 201
  cabana.Envelope[cabana.FieldUploadResponse] for a multipart part file
- the fixture plugin registers conformnote and the gadget form uses it,
  so create, update and show run its Decode and Load (G-16.2-1, D-13, D-18)
2026-10-10 18:33:03 +02:00
f388d27604 fix(16.2): WR-06 hand a translatable Decode only strings
liftMLValues validated only object-shaped submissions, so a scalar
submitted for a translatable plugin field reached Decode as nil, a
float64 or a bool although pact promises a string; a type that asserts
a string panicked into an opaque 500. decodePluginFields now passes a
string through, turns null into the empty string and refuses any other
value with a 422 on the field before Decode runs.
2026-10-10 16:45:05 +02:00
c532529d18 fix(16.2): WR-02 check the whole upload body before Upload stores it
The route called the type's Upload on the streamed part and only then
checked that no second part followed, so a refused request left a
public, unreferenced object in the bucket. It now reads the multipart
body whole (ReadForm, spooling past 8 MiB to a temporary file), checks
it holds exactly one file part named file, and only then calls Upload.
The remaining window (a failed record transaction after Upload, an
element that stopped waiting) is documented.
2026-10-10 16:43:59 +02:00
3158bb5b57 fix(16.2): WR-01 check the declared save before a field upload
The field upload route ran only protect, so the create form of a
controller without the create toolbar button could still write files to
the public bucket. It now refuses with 403 where the save route would:
create for {id} 0, update otherwise (operationDeclared).
2026-10-10 16:42:28 +02:00
3404a4b3ad test(16.2-06): full unit coverage of the plugin field type framework
- pact: FormFieldError message joining, nil pointer, funcs never serialized
- cabana: registry and option boot rules, compile (option kinds, general
  and core keys, CompileForm vs CompileFormWithTypes, settings and relation
  refusal), decode 422/500 split, localized and capped notices, Load,
  ListValue with the request ctx, field writes, field-types and upload
  routes
- TestFieldTypeSavePostgres (Docker host): create, show, untouched and
  changed update, refused update, upload in and out of the record scope
2026-10-10 15:49:35 +02:00
08a20021e3 test(16.2-06): pin the untouched-value skip in cabana and PluginField
- TestFieldTypeUntouched: served values skip Decode, writes and notices;
  one changed locale writes and reports only that locale; canonical JSON
  comparison; a create never skips
- PluginField: an element that echoes its value on connect is never
  written a second time
2026-10-10 15:42:31 +02:00
85fe541b03 feat(admin): View site link next to the user menu
Opens the admin origin's root in a new tab, like the WinterCMS backend.
2026-10-10 14:01:44 +02:00
4adb48a76e feat(16.2-01): translatable plugin fields, element uploads and save notices in the admin
- MLPluginField wraps PluginField in the MLField locale switcher; the
  element gets the active content locale and that locale's string
- isMLField treats a translatable plugin field as a locale map in the form
  state, FormView adopt and the relation child modal
- summer-upload posts the file with the admin session to the field upload
  route and answers summer-upload-result {id, url} or {id, error}
- noticeLines shows meta.notices under each field as a warning status line
  (text only) and in the saved toast; the stored value is reloaded
- docs/backend/field-types.md documents the full element contract;
  modules/boardwalk/dist rebuilt
2026-10-10 11:25:26 +02:00
8fa92db96d feat(16.2-01): translatable plugin field types, untouched skip, writes, list values and uploads
- translatable types lift, store and hydrate through the ML path
  (isMLField); Decode and Load run per locale and notices carry the locale
- on update a value equal to the served value (Load of the stored host
  value or exact translation) is neither decoded nor written
- FieldWrite, FieldWritesFromContext and WithFieldWrites expose what a save
  writes to the Form hooks
- list columns may use a plugin alias and show the type's ListValue
- POST .../{id}/fields/{field}/upload hands the file to the type's Upload
  (guard, X-Requested-With, permissions, field context, record scope, body
  cap); OpenAPI, route inventories, README and docs updated
2026-10-10 11:19:24 +02:00
83fa2f7b2c feat(16.2-01): host plugin field type elements in the admin SPA
- loadFieldTypes fetches GET /field-types after sign-in, loads each script
  once and adds the styles as global links activateStyles never disables
- PluginField creates the type's element, sets the value property and the
  contract attributes, takes summer-change as the form value and never
  writes back a value the element just emitted
- rendererForField and isRegisteredField route any field with an element
  to PluginField in forms, previews and the save payload
- rebuilt modules/boardwalk/dist
2026-10-10 11:10:41 +02:00