diff --git a/CHANGELOG.md b/CHANGELOG.md index c9212a5..7af7069 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ # Changelog +## v0.1.0-preview.27 — 2026-09-10 + +- Add independent `cms` values and a `cmssqlite` adapter for named taxonomies, + stable terms, immutable revision associations and bidirectional editorial + links. Applications retain typed content, templates, permissions and commerce. +- Join caller-owned SQLite transactions so content, publication pointers and + application audits commit together. Schema installation is explicit and + independent of authentication schema 11, which remains unchanged. +- Preserve term URL aliases and historical associations on rename/retirement. + Query only published snapshots, with namespace isolation and bounded keyset + pagination. The application still checks each target's current availability. +- Include executable integration guidance and regressions for conflicts, + rollback, draft isolation, reverse discovery, aliases and retirement. The + consumer exercised native editing and publication behind trusted local TLS. + ## v0.1.0-preview.26 — 2026-09-05 - Add optional self-profile readers and revision-checked username/display-name diff --git a/README.md b/README.md index 118a567..7816f5c 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ router, handlers, HTML, authorization decisions, cache behavior, and deployment. Adopt one boundary at a time; Go compiles and links only the packages you import. -> **Public preview:** `v0.1.0-preview.26`. APIs may change before a stable +> **Public preview:** `v0.1.0-preview.27`. APIs may change before a stable > release. Linux is the maintained release platform. ## Why Web Foundations? @@ -44,6 +44,7 @@ packages you import. | Recovery codes and owner-assisted recovery | [`authrecovery`](authrecovery) | | Private SQLite persistence | [`authsqlite`](authsqlite) | | Bounded media and private local blobs | [`media`](media) + [`medialocal`](medialocal) | +| Typed editorial categories and related-content discovery | [`cms`](cms) + [`cmssqlite`](cmssqlite); [integration guide](docs/CMS.md) | | Organizations, teams, and invitations | [`organizations`](organizations) | | Organization-scoped roles and temporary access | [`access`](access) | | Application-classified request abuse | [`abuse`](abuse) | @@ -57,14 +58,14 @@ owns—and, just as importantly, what remains application policy. Pin the preview in an application module: ```bash -go get gamertan.com/web@v0.1.0-preview.26 +go get gamertan.com/web@v0.1.0-preview.27 go mod verify ``` An application may name the first package it intends to adopt: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.26 +go get gamertan.com/web/requestmeta@v0.1.0-preview.27 ``` The version belongs to the `gamertan.com/web` module. See the diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..0e2455e --- /dev/null +++ b/TODO.md @@ -0,0 +1,29 @@ + + +# Current work + +## CMS classification and relationships + +Implement `cms` and `cmssqlite` primitives for Gamertan's existing typed content: +named flat taxonomies, stable terms with rename history, immutable revision +associations, explicit publication and bidirectional related-content queries. +Keep authorization, audits, application schemas, templates and commerce policy +with their callers. Writes join caller-owned SQLite transactions. + +Implemented: validated values, explicit schema, caller-owned transactions, +immutable associations, publication pointers, term aliases and public readers. +Verified: focused race tests for scope isolation, conflicts, draft/public +transitions, retirement, rename collisions, pagination and rollback. Gamertan's +consumer HTTP regressions now exercise real content/catalog/case-study routing, +draft isolation, restore, role/CSRF checks and unchanged catalog data. + +Consolidated evidence: the full package race suite passed; association fuzzing +passed 698,275 executions. Dependency/module and license checks passed. Vet has +only the existing vendored COSE diagnostic documented by `scripts/verify.sh`. +The consumer's full Go suite and native trusted-TLS editor checks passed. + +Next: public-export verification, then preview27 publication and an immutable +consumer pin. No unrelated auth changes in this slice. + +Status: implemented and consolidated Go/browser verification complete; export, +upstream push and consumer deployment pending. Release history stays in CHANGELOG. diff --git a/cms/cms.go b/cms/cms.go new file mode 100644 index 0000000..2ad069f --- /dev/null +++ b/cms/cms.go @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MPL-2.0 + +// Package cms supplies bounded classification and editorial relationship values. +// It does not define content types, layouts, authorization, or commerce policy. +// Applications own those decisions and publish exact immutable revisions. +package cms + +import ( + "errors" + "regexp" + "strings" + "unicode" + "unicode/utf8" +) + +var ( + ErrInvalid = errors.New("cms: invalid value") + ErrConflict = errors.New("cms: revision or slug conflict") + ErrNotFound = errors.New("cms: not found") + identifier = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$`) + slug = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) + kind = regexp.MustCompile(`^[a-z][a-z0-9-]{0,39}$`) +) + +const MaxTerms = 24 +const MaxLinks = 16 + +// Reference names an application-owned resource, never a mutable URL or title. +type Reference struct { + Kind string `json:"kind"` + ID string `json:"id"` +} + +func ValidID(value string) bool { return identifier.MatchString(value) } +func ValidSlug(value string) bool { return len(value) <= 80 && slug.MatchString(value) } +func ValidText(value string, max int) bool { + return utf8.ValidString(value) && len(value) <= max && strings.TrimSpace(value) == value && !strings.ContainsFunc(value, unicode.IsControl) +} +func (r Reference) Validate() error { + if !kind.MatchString(r.Kind) || !ValidID(r.ID) { + return ErrInvalid + } + return nil +} + +// Associations are an immutable revision's term memberships and explicit links. +// Reverse discovery reads the same links; callers must not store a second edge. +type Associations struct { + Terms []string `json:"terms,omitempty"` + Links []Reference `json:"links,omitempty"` +} + +func (a Associations) Validate(source Reference) error { + if source.Validate() != nil || len(a.Terms) > MaxTerms || len(a.Links) > MaxLinks { + return ErrInvalid + } + terms := map[string]bool{} + for _, id := range a.Terms { + if !ValidID(id) || terms[id] { + return ErrInvalid + } + terms[id] = true + } + links := map[Reference]bool{} + for _, ref := range a.Links { + if ref.Validate() != nil || ref == source || links[ref] { + return ErrInvalid + } + links[ref] = true + } + return nil +} + +// Taxonomy is an editor-named vocabulary, not a database-defined content type. +// Slug is fixed after creation; Name/Description and availability may change. +type Taxonomy struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Revision int64 `json:"revision"` + Active bool `json:"active"` +} + +func (v Taxonomy) Validate() error { + if !ValidID(v.ID) || !ValidSlug(v.Slug) || v.Name == "" || !ValidText(v.Name, 120) || !ValidText(v.Description, 500) || v.Revision < 1 { + return ErrInvalid + } + return nil +} + +// Term identity survives renaming and retirement. Retirement hides discovery; +// it does not rewrite old associations or imply removal of related resources. +type Term struct { + ID string `json:"id"` + TaxonomyID string `json:"taxonomy_id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Revision int64 `json:"revision"` + Active bool `json:"active"` +} + +func (v Term) Validate() error { + if !ValidID(v.ID) || !ValidID(v.TaxonomyID) || !ValidSlug(v.Slug) || v.Name == "" || !ValidText(v.Name, 120) || !ValidText(v.Description, 500) || v.Revision < 1 { + return ErrInvalid + } + return nil +} + +type ResourceVersion struct { + Reference + Revision int64 `json:"revision"` +} +type Page struct { + Items []ResourceVersion `json:"items"` + Next *Reference `json:"next,omitempty"` +} diff --git a/cms/cms_test.go b/cms/cms_test.go new file mode 100644 index 0000000..61bffbb --- /dev/null +++ b/cms/cms_test.go @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MPL-2.0 +package cms + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestAssociationsBoundsAndIdentity(t *testing.T) { + source := Reference{Kind: "project", ID: "project-one"} + valid := Associations{Terms: []string{"go"}, Links: []Reference{{Kind: "news", ID: "launch"}}} + if valid.Validate(source) != nil { + t.Fatal("valid association rejected") + } + for _, value := range []Associations{ + {Terms: []string{"go", "go"}}, {Terms: []string{"../private"}}, + {Links: []Reference{source}}, {Links: []Reference{{Kind: "news", ID: "launch"}, {Kind: "news", ID: "launch"}}}, + {Links: []Reference{{Kind: "