diff --git a/modules/boardwalk/README.md b/modules/boardwalk/README.md index 9804461..f1efca7 100644 --- a/modules/boardwalk/README.md +++ b/modules/boardwalk/README.md @@ -1,3 +1,60 @@ # boardwalk -`boardwalk` serves the embedded admin SPA, including its configured path prefix, client-route fallback, cache headers, and API miss handoff. `cabana` imports it while activating admin routes; use `boardwalk.Handler` from `boardwalk.go`. +HTTP handler that serves the embedded admin SPA build under a configurable path prefix. + +`import "git.golem15.com/golem15/summercms/modules/boardwalk"` + +## Overview + +`boardwalk` embeds the compiled admin SPA (`dist/`, produced by `npm --prefix admin run build`) into the binary and serves it. The build is path-agnostic: `index.html` carries a placeholder token that the handler replaces once, at construction, with the prefix the admin is mounted under, so one build works at any backend URI. [cabana](../cabana/README.md) mounts it when it activates the admin routes. It stands in for the server-rendered backend layouts of WinterCMS, which the Go port replaces with a single-page app. + +## Features + +- Serves the embedded build under any prefix, rewriting relative asset URLs and the admin base meta in `index.html` for that prefix (`boardwalk.RewriteIndex`). +- Fails at boot when `index.html` lacks the `boardwalk.BaseToken` placeholder, which catches a stale or hand-edited build. +- Falls back to `index.html` for client-side routes and directories; missing files with an extension get a plain 404. +- Hands every request whose path under the prefix is `api` or starts with `api/` to a caller-supplied handler, so admin API misses stay JSON instead of returning the SPA. +- Long-lived immutable caching for hashed files under `assets/`, `no-cache` for other files and `no-store` for `index.html`. +- Security headers on every response: a restrictive Content-Security-Policy, frame denial, `nosniff`, a same-origin referrer policy and `noindex, nofollow`. +- Explicit content types for scripts, styles, fonts, SVG and JSON, with a MIME lookup fallback. + +## Usage + +```go +notFoundAPI := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"not found"}`)) +}) + +spa, err := boardwalk.Handler("/backend", notFoundAPI) +if err != nil { + return err +} +mux.Handle("/backend/", spa) +``` + +## API reference + +| Identifier | Description | +|------------|-------------| +| `boardwalk.Handler` | Builds the SPA handler for a prefix; the second argument answers unmatched `api/` paths. | +| `boardwalk.Dist` | Returns the embedded build as an `fs.FS` rooted at `dist/`. | +| `boardwalk.RewriteIndex` | Rewrites raw `index.html` bytes for a prefix; errors when the placeholder token is missing. | +| `boardwalk.BaseToken` | The placeholder in `dist/index.html` that is replaced by the prefix. | + +## Dependencies + +- SummerCMS modules: none. +- Third-party: none. +- Standard library: `bytes`, `embed`, `errors`, `fmt`, `html`, `io/fs`, `mime`, `net/http`, `path`, `strings`, `time`. + +The embedded `dist/` tree is generated from the `admin/` Vite project, whose build writes to `modules/boardwalk/dist`. + +## Testing + +```sh +go test ./modules/boardwalk/... +``` + +The tests run the handler through `net/http/httptest` against the embedded build and in-memory file systems; they need no external services. diff --git a/modules/fetchguard/README.md b/modules/fetchguard/README.md index a661786..d170afb 100644 --- a/modules/fetchguard/README.md +++ b/modules/fetchguard/README.md @@ -1,3 +1,86 @@ # fetchguard -`fetchguard` performs policy-controlled outbound HTTP fetches with size, timeout, and address safety checks. No current in-repository package imports it directly; its public entry point is `fetchguard.Fetch` in `fetch.go`. +Guarded outbound HTTPS fetcher that blocks private and reserved addresses and enforces host, size and timeout limits. + +`import "git.golem15.com/golem15/summercms/modules/fetchguard"` + +## Overview + +`fetchguard` is the framework's server-side request forgery guard for fetching URLs that come from users or third parties, such as a remote image address. Every call takes a `fetchguard.Policy` that either restricts the target to an allow list of hosts or permits any public host; in both modes the dial-time check refuses private, loopback, link-local, carrier-grade NAT, documentation, multicast and other reserved IPv4 and IPv6 ranges, including IPv4 embedded in NAT64 and 6to4 addresses. Failures come back as a `fetchguard.Error` carrying one `fetchguard.Reason` from a closed set, so callers can map them onto stable API error codes. WinterCMS has no dedicated counterpart; plugins there typically used Guzzle with hand-written checks. + +## Features + +- HTTPS only: any other scheme fails with `fetchguard.ReasonScheme`. +- Two modes: `fetchguard.AllowHostsMode` (exact or dotted-suffix host match against `fetchguard.Policy.AllowHosts`) and `fetchguard.PublicOnlyMode` (any public host). +- The private and reserved address check runs on the resolved IP at dial time, so DNS answers that point inside the network are refused (`fetchguard.ReasonPrivateIP`); environment proxies are ignored so the check sees the real target. +- Redirects are never followed: a 3xx response is returned as a successful `fetchguard.Result`, and a caller that wants to follow the Location header calls `fetchguard.Fetch` again, which re-runs the guard. +- The response body is capped at the policy's byte limit (`fetchguard.ReasonTooLarge` when exceeded), with a per-call timeout. +- Limits left at zero in the policy fall back to config keys, then to framework defaults of 10 MiB and 10 seconds (`fetchguard.Defaults`, `fetchguard.DefaultsFromConfig`). +- Typed failure reasons: `fetchguard.ReasonInvalidURL`, `fetchguard.ReasonScheme`, `fetchguard.ReasonUnresolvable`, `fetchguard.ReasonPrivateIP`, `fetchguard.ReasonNetworkError`, `fetchguard.ReasonTooLarge`. + +## Usage + +```go +policy := fetchguard.Policy{ + Mode: fetchguard.AllowHostsMode, + AllowHosts: []string{"images.example.com"}, + MaxBytes: 5 << 20, + Timeout: 5 * time.Second, +} + +res, err := fetchguard.Fetch(ctx, imageURL, policy, app.Config) +if err != nil { + var fe *fetchguard.Error + if errors.As(err, &fe) && fe.Reason == fetchguard.ReasonPrivateIP { + return errRejectedURL + } + return err +} +if res.StatusCode != http.StatusOK { + return fmt.Errorf("image fetch: status %d", res.StatusCode) +} +image := res.Body +``` + +## API reference + +| Identifier | Description | +|------------|-------------| +| `fetchguard.Fetch` | Validates the URL against the policy and performs the guarded HTTPS GET; a non-nil error is always a `fetchguard.Error`. | +| `fetchguard.Policy` | Per-call settings: mode, allowed hosts, byte limit and timeout (zero means use the configured default). | +| `fetchguard.Mode` | Selects `fetchguard.AllowHostsMode` or `fetchguard.PublicOnlyMode`. | +| `fetchguard.Result` | Response body, Content-Type header value and status code of any completed response, including 3xx and non-2xx. | +| `fetchguard.Error` | Failure carrying a `fetchguard.Reason` and the underlying error for logging. | +| `fetchguard.Reason` | Closed set of failure reasons (`invalid_url`, `scheme`, `unresolvable`, `private_ip`, `network_error`, `too_large`). | +| `fetchguard.Defaults` | Framework fallback limits: 10 MiB and 10 seconds. | +| `fetchguard.DefaultsFromConfig` | Reads the limits from a `compass.Config`, falling back to `fetchguard.Defaults` for absent keys. | + +## Configuration + +`fetchguard.Fetch` and `fetchguard.DefaultsFromConfig` read these keys from the `compass.Config` passed to them. They apply only when the policy leaves the matching limit at zero, and an explicitly configured zero or negative value is an error. + +| Key | Default | Controls | +|-----|---------|----------| +| `http.fetch.max_bytes` | `10485760` (10 MiB) | Maximum response body size in bytes. | +| `http.fetch.timeout_seconds` | `10` | Dial and overall request timeout, in seconds. | + +```yaml +http: + fetch: + max_bytes: 5242880 + timeout_seconds: 5 +``` + +## Dependencies + +- SummerCMS modules: [compass](../compass/README.md) (config lookup). +- Third-party: none. +- Standard library: `context`, `crypto/tls`, `errors`, `fmt`, `io`, `math`, `net`, `net/http`, `net/netip`, `net/url`, `strings`, `syscall`, `time`. + +## Testing + +```sh +go test ./modules/fetchguard/... +``` + +The tests run against local `net/http/httptest` TLS servers and cover the address classifier directly; they need no external services. diff --git a/modules/lagoon/README.md b/modules/lagoon/README.md index 0749e1d..7be2b71 100644 --- a/modules/lagoon/README.md +++ b/modules/lagoon/README.md @@ -1,3 +1,167 @@ # lagoon -`lagoon` contains the GORM/Postgres data primitives: connection setup, migrations, validation, pagination, encryption, lifecycle hooks, and attachment support. Framework runtime code and Fonoteka plugins import it; open the shared database handles with `lagoon.Open` in `connection.go`. +Postgres data layer: the shared GORM connection, per-plugin migrations, model helpers and file attachments. + +`import "git.golem15.com/golem15/summercms/modules/lagoon"` + +`import "git.golem15.com/golem15/summercms/modules/lagoon/attach"` + +## Overview + +`lagoon` is the WinterCMS models and migrations counterpart: it opens the one `*sql.DB` pool (pgx stdlib driver) that GORM and the rest of the application share, runs each plugin's gormigrate set in its own history table, and ports the Eloquent model conventions that WinterCMS plugins rely on, such as `$fillable`, `$hidden`, `$jsonable`, encrypted casts and Laravel-style validation rules. Its `attach` subpackage ports WinterCMS's `system_files` attachments, storing originals and lazily generated thumbnails in a gocloud.dev blob bucket. The application binary gets the migrate and key commands from `lagoon.RuntimeCommands`. + +## Features + +- One shared pool: `lagoon.Open`, `lagoon.Use` and `lagoon.OpenFromApp` return a `*sql.DB` and a `*gorm.DB` built on that same pool; `lagoon.Publish` makes both available on the `backpack.App`. +- Database check at connect time: `lagoon.CheckLocale` refuses a database whose default collation is not the ICU `pl-PL` locale, so ordering matches the database default without per-query `COLLATE`. +- Per-plugin migrations: `lagoon.Migrate` runs the framework's `system_files` set (`attach.Migrations`) and backend admin identity set (`lagoon.BackendAdminMigrations`), then every `pact.HasMigrations` set in plugin activation order, each in its own `summer_migrations_` history table (`lagoon.HistoryTableName`). `lagoon.RollbackLast` and `lagoon.Status` cover rollback and history. +- Mass assignment: `lagoon.Fill` copies only allow-listed keys onto a model by GORM column name and silently drops the rest, logging each dropped key once outside production. `lagoon.HasFillable` and `lagoon.HasHidden` are the Go forms of `$fillable` and `$hidden`. +- Validation: `lagoon.Validate` accepts Laravel-style rule strings (`required`, `nullable`, `integer`, `numeric`, `between`, `min`, `max`, `in`, `unique`, `boolean`, `email`, `confirmed`, `different`, `mimes`) and returns a field-to-messages map, translated through phrasebook when a translator is given. Unknown rule tokens are an error. +- Safe ordering: `lagoon.OrderBy` appends an ORDER BY only for an allow-listed column and an `asc` or `desc` direction. +- Pagination: `lagoon.Paginate` builds a `lagoon.Page` with `data` and `meta` (`current_page`, `last_page`, `per_page`, `total`). +- Column types: `lagoon.Encrypted` stores AES-256-GCM ciphertext under a key derived from `app.key`, decrypts with previous keys during rotation, and always redacts itself in JSON and string output; `lagoon.Jsonable` stores JSON as TEXT and keeps SQL NULL distinct from an empty value. +- Lifecycle and relations: hook interfaces matching GORM's native method names (`lagoon.HasBeforeCreate`, `lagoon.HasBeforeSave`, `lagoon.HasBeforeDelete`, `lagoon.HasAfterDelete`) plus `lagoon.HasBeforeValidate`; `lagoon.WithSoftDeleteCascade` runs a cascade inside the parent delete; `lagoon.RegisterJoinTable` wires pivot models with business columns. +- Imports from Laravel: `lagoon.DecryptLaravelPayload` decrypts Laravel `encrypted` payloads with the old application key, for one-off data imports. +- Attachments (`attach`): the `attach.File` model for `system_files` rows, WinterCMS-compatible partitioned storage keys (`attach.BlobKey`, `attach.PartitionDirectory`), on-demand thumbnails through `attach.File.Thumb`, static serving with an optional `is_public` gate (`attach.StaticHandlerPublic`), and a two-phase delete that removes blobs only after the database transaction commits (`attach.DeleteForOwner`, `attach.DeleteKeys`). + +## Usage + +Open the shared pool once at boot and publish it, so plugins and handlers reuse the same handles: + +```go +sqlDB, gdb, err := lagoon.OpenFromApp(ctx, app) +if err != nil { + return err +} +if err := lagoon.Publish(app, sqlDB, gdb); err != nil { + return err +} +``` + +A model uses the column types and helpers directly: + +```go +type Post struct { + ID uint `gorm:"column:id;primaryKey"` + Title string `gorm:"column:title"` + Tags lagoon.Jsonable[[]string] `gorm:"column:tags"` + APIToken lagoon.Encrypted `gorm:"column:api_token"` +} + +func (Post) Fillable() []string { return []string{"title"} } + +func createPost(ctx context.Context, gdb *gorm.DB, tr *phrasebook.Translator, input map[string]any) (map[string][]string, error) { + var post Post + rules := map[string]string{"title": "required|max:255|unique:posts"} + if errs, err := lagoon.Validate(ctx, gdb, &post, rules, input, tr); err != nil || errs != nil { + return errs, err + } + if err := lagoon.Fill(&post, post.Fillable(), input, false); err != nil { + return nil, err + } + post.APIToken = lagoon.NewEncrypted("change-me") + return nil, gdb.Create(&post).Error +} +``` + +A plugin ships its schema as an ordered gormigrate set; `migrate` runs it after the framework sets: + +```go +func (p *Plugin) Migrations() []*gormigrate.Migration { + return []*gormigrate.Migration{{ + ID: "202601010001_create_posts", + Migrate: func(tx *gorm.DB) error { + return tx.Exec(`CREATE TABLE posts (id SERIAL PRIMARY KEY, title TEXT NOT NULL, tags TEXT, api_token TEXT)`).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec(`DROP TABLE IF EXISTS posts`).Error + }, + }} +} +``` + +## API reference + +| Identifier | Description | +|------------|-------------| +| `lagoon.OpenFromApp` | Opens the shared pool from `database.dsn` and publishes the `app.key` encryption keys. | +| `lagoon.Open` | Opens and pings a DSN, checks the locale and returns the pool plus a GORM handle on it. | +| `lagoon.Use` | Returns a GORM handle on an existing pool after the same checks. | +| `lagoon.Publish` | Stores the pool and GORM handle on the `backpack.App`. | +| `lagoon.DSN` | Reads `database.dsn` from config. | +| `lagoon.CheckLocale` | Fails unless the database default locale is ICU `pl-PL`. | +| `lagoon.Migrate` | Runs framework and plugin migrations in order. | +| `lagoon.RollbackLast` | Rolls back the last migration of one plugin. | +| `lagoon.Status` | Lists applied migration IDs per plugin as `lagoon.StatusRow` values. | +| `lagoon.HistoryTableName` | Returns the gormigrate history table for a plugin ID. | +| `lagoon.BackendAdminMigrations` | Creates the backend user, role and admin token blacklist tables and seeds the system roles. | +| `lagoon.RuntimeCommands` | Returns the migrate, migrate:rollback, migrate:status and key:generate commands. | +| `lagoon.KeyGenerateCommand` | Returns the key:generate command on its own. | +| `lagoon.LoadAppKey` | Decodes `app.key` and `app.previous_keys`. | +| `lagoon.PublishEncryptionKeys` | Installs the keys used by `lagoon.Encrypted` columns. | +| `lagoon.Encrypted` | Encrypted text column; `lagoon.Encrypted.Reveal` is the only plaintext accessor. | +| `lagoon.Jsonable` | Generic JSON-as-TEXT column with NULL tracking. | +| `lagoon.Fill` | Allow-listed mass assignment by column name. | +| `lagoon.Validate` | Laravel-style rule validation with a `unique` database check. | +| `lagoon.OrderBy` | Allow-listed ORDER BY. | +| `lagoon.Paginate` | Builds a `lagoon.Page` with `lagoon.PageMeta`. | +| `lagoon.WithSoftDeleteCascade` | Runs a cascade inside the parent delete transaction. | +| `lagoon.RegisterJoinTable` | Registers a custom pivot model for a many-to-many field. | +| `lagoon.DecryptLaravelPayload` | Decrypts a Laravel AES-256-CBC payload for data imports. | +| `attach.File` | The `system_files` row model. | +| `attach.Owner` | Implemented by models that own attachments; returns the stored morph type name. | +| `attach.OpenBucket` | Opens the uploads bucket from config. | +| `attach.Publish` | Stores the bucket on the `backpack.App`. | +| `attach.StaticHandler` | Serves stored files and thumbnails under a URL prefix. | +| `attach.StaticHandlerPublic` | `attach.StaticHandler` plus a 404 for rows that are not public. | +| `attach.DeleteForOwner` | Deletes an owner's attachment rows in a transaction and reports their blob keys. | +| `attach.DeleteKeys` | Deletes blobs, including thumbnails, after the transaction commits. | +| `attach.Migrations` | Creates the `system_files` table. | + +## Configuration + +Keys are read from the compass config; the `SUMMER_` environment overlay maps a double underscore to a dot, for example `SUMMER_DATABASE__DSN` to `database.dsn`. + +| Key | Default | Controls | +|-----|---------|----------| +| `database.dsn` | none (required) | Postgres connection string used by `lagoon.OpenFromApp` and every database command. | +| `app.key` | none (required) | Base64 encoding of 32 random bytes; the source of the `lagoon.Encrypted` column key. Generate one with `key:generate`. | +| `app.previous_keys` | empty | List of earlier base64 keys still accepted when decrypting, for key rotation. | +| `storage.uploads.bucket_url` | none (required by `attach.OpenBucket`) | Uploads bucket URL, `file://` or `mem://`. | +| `storage.uploads.public_path_prefix` | `/storage/uploads` | URL prefix used when building public file and thumbnail URLs. | + +```yaml +database: + dsn: postgres://acme:@127.0.0.1:5432/acme?sslmode=disable +app: + key: +storage: + uploads: + bucket_url: file:///var/lib/acme/uploads +``` + +## CLI commands + +`lagoon.RuntimeCommands` adds these commands to the application binary. All of them except key:generate open the database through `lagoon.OpenFromApp`, so they need `database.dsn` and `app.key`. + +| Command | Flags | Description | +|---------|-------|-------------| +| `migrate` | none | Runs the framework migrations, then each plugin's migrations in dependency order. | +| `migrate:rollback` | `--plugin ` | Rolls back the last migration of the given plugin; without the flag, of the last activated plugin that has migrations. | +| `migrate:status` | none | Prints a table of plugin, history table and applied migration IDs. | +| `key:generate` | none | Prints a fresh base64 32-byte key for `app.key`; writes nothing. | + +## Dependencies + +- SummerCMS modules: [backpack](../backpack/README.md), [bonfire](../bonfire/README.md), [compass](../compass/README.md), [pact](../pact/README.md), [party](../party/README.md), [phrasebook](../phrasebook/README.md) (validation messages). +- Third-party: `gorm.io/gorm`, `gorm.io/driver/postgres`, `github.com/jackc/pgx/v5` (stdlib driver), `github.com/go-gormigrate/gormigrate/v2`, `github.com/go-playground/validator/v10`. +- Third-party, `attach` only: `gocloud.dev/blob` (file and memory drivers), `github.com/disintegration/imaging` (thumbnails). +- Standard library: `database/sql`, `crypto/aes`, `crypto/cipher`, `crypto/hkdf`, `log/slog`, `image`, among others. + +## Testing + +```sh +go test ./modules/lagoon/... +``` + +The database tests in `lagoon` and `lagoon/attach` start a `postgres:16-alpine` container, initialised with the ICU `pl-PL` locale, through testcontainers-go, so they need a running Docker daemon. They are skipped by `go test -short ./modules/lagoon/...`, which runs only the unit tests. diff --git a/modules/pact/README.md b/modules/pact/README.md index fefdfc1..e861d90 100644 --- a/modules/pact/README.md +++ b/modules/pact/README.md @@ -1,3 +1,101 @@ # pact -`pact` defines the compiled-plugin capability contracts for routes, configuration, migrations, middleware, admin controllers, permissions, and jobs. `party`, `surf`, `cabana`, and Fonoteka plugins import these interfaces; see `pact.AdminController` in `capabilities.go`. +Capability interfaces that compiled plugins implement to contribute routes, config, migrations, middleware, commands, admin screens, translations, mail templates and jobs. + +`import "git.golem15.com/golem15/summercms/modules/pact"` + +## Overview + +`pact` is the contract layer between plugins and the framework. It holds interfaces and plain data types, with no behaviour of its own. A plugin opts into a capability by implementing one of the `Has*` interfaces, and the framework package that owns the capability discovers it with a type assertion ([party](../party/README.md) for config, [surf](../surf/README.md) for routes and middleware, [lagoon](../lagoon/README.md) for migrations, [cabana](../cabana/README.md) for admin controllers). It replaces the `register*()` methods of a WinterCMS PluginBase (`registerPermissions`, `registerNavigation`, `registerSettings` and so on) with small, separately implementable interfaces. + +## Features + +- Plugin capability interfaces: `pact.HasRoutes`, `pact.HasConfig`, `pact.HasMigrations`, `pact.HasCommands`, `pact.HasModels`, `pact.HasJobs`, `pact.HasLang`, `pact.HasLangOverrides` and `pact.HasMailTemplates`. +- HTTP contracts: the `pact.Router` group builder (implemented by surf), the `pact.Middleware` type, and named, parameterized (`name:param`) and house-envelope middleware through `pact.HasMiddleware`, `pact.HasMiddlewareFactories` and `pact.HasHouseMiddleware`. +- Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`. +- Admin controller contracts: `pact.AdminController`, `pact.HasAdminControllers`, `pact.AdminAssets` (embedded Winter-shaped admin YAML), `pact.AdminPermissioned` and `pact.AdminRecordSource`. +- Optional admin hooks a controller or model can implement: list and form query scoping (`pact.ListExtendQuery`, `pact.FormExtendQuery`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), relation hooks (`pact.RelationExtendManageQuery`, `pact.RelationExtendOptionsQuery`, `pact.RelationBeforeLink`), filter scopes (`pact.FilterScope`, `pact.FilterOptions`) and dropdown options (`pact.DropdownOptionsProvider`). +- A background job contract (`pact.Job`, `pact.JobArgs`) that does not depend on any queue library. +- `pact.OptionalMessage`, a service an optional plugin can publish so others integrate with it without importing its package. +- `pact.HasModels`, `pact.HasJobs` and `pact.OptionalMessage` are declared for plugins to implement, but no framework package consumes them yet. + +## Usage + +A plugin declares its capabilities by implementing the interfaces and asserting them at compile time: + +```go +package blog + +import ( + "net/http" + + "git.golem15.com/golem15/summercms/modules/pact" +) + +var ( + _ pact.HasRoutes = (*Plugin)(nil) + _ pact.HasPermissions = (*Plugin)(nil) +) + +type Plugin struct{} + +func (p *Plugin) Routes(r pact.Router) error { + r.Group("/api/blog", []string{"auth"}, func(r pact.Router) { + r.Get("/posts", listPosts) + r.Get("/posts/{id}", showPost) + r.Where("id", "[0-9]+") + }) + return nil +} + +func (p *Plugin) Permissions() []pact.Permission { + return []pact.Permission{ + {Code: "acme.blog.access_posts", Tab: "Blog", Label: "Manage posts"}, + } +} + +func listPosts(w http.ResponseWriter, r *http.Request) {} +func showPost(w http.ResponseWriter, r *http.Request) {} +``` + +## API reference + +| Identifier | Description | +|------------|-------------| +| `pact.Router` | Laravel-style route group builder (`pact.Router.Group`, `pact.Router.GroupRaw`, one method per HTTP verb, `pact.Router.Where`, `pact.Router.WhereIn`); implemented by surf. | +| `pact.HasRoutes` | Declares HTTP routes on a `pact.Router`. | +| `pact.Middleware` | A named `func(http.Handler) http.Handler` wrapper. | +| `pact.HasMiddleware` | Registers named middleware. | +| `pact.HasMiddlewareFactories` | Registers parameterized middleware resolved from `name:param` at wrap time. | +| `pact.HasHouseMiddleware` | Registers middleware tagged as envelope and error handling, which raw groups refuse. | +| `pact.HasConfig` | Ships default YAML config, merged under the plugin ID. | +| `pact.HasMigrations` | Ships an ordered gormigrate set, run with a per-plugin history table. | +| `pact.HasCommands` | Contributes bonfire console commands to the application binary. | +| `pact.HasModels` | Exposes GORM models. | +| `pact.Job` | Background unit of work that receives `pact.JobArgs`. | +| `pact.HasJobs` | Registers background jobs. | +| `pact.HasLang` | Ships translation YAML under `lang//.yaml`. | +| `pact.HasLangOverrides` | Replaces or adds translations of any loaded namespace, including the framework's own. | +| `pact.HasMailTemplates` | Ships mail templates and layout aliases. | +| `pact.Permission` | One backend permission entry (code, tab, label, roles). | +| `pact.NavigationItem` | One backend navigation entry, with an optional side menu. | +| `pact.SettingsItem` | One settings screen entry. | +| `pact.AdminController` | Admin controller identity: ID, model name and YAML config directory. | +| `pact.AdminAssets` | Embedded tree of the plugin's admin YAML. | +| `pact.AdminRecordSource` | Supplies a new model record for the generic admin handlers. | +| `pact.FilterScope` | Model scopes a list filter may call, limited to an exact allow list. | +| `pact.Option` | One dropdown choice (value and label). | + +## Dependencies + +- SummerCMS modules: [bonfire](../bonfire/README.md) (the command type in `pact.HasCommands`). +- Third-party: `github.com/go-gormigrate/gormigrate/v2`, `gorm.io/gorm`. +- Standard library: `context`, `io/fs`, `net/http`. + +## Testing + +```sh +go test ./modules/pact/... +``` + +The tests are compile-time interface checks and need no external services. diff --git a/modules/party/README.md b/modules/party/README.md index cc4b63e..64406ea 100644 --- a/modules/party/README.md +++ b/modules/party/README.md @@ -1,3 +1,77 @@ # party -`party` registers compiled plugins and coordinates their framework-facing capabilities, translations, mail drivers, and metadata. Implement `party.Plugin`, call `party.Register` during plugin initialization, and use `party.Activate` to select and start registered plugins; these entry points live in `registry.go`. +Compiled plugin registry that orders plugins by their dependencies and runs their Register and Boot lifecycle. + +`import "git.golem15.com/golem15/summercms/modules/party"` + +## Overview + +`party` is the WinterCMS PluginBase and PluginManager counterpart for plugins compiled into the binary. Each plugin package calls `party.Register` from its `init` function, and the application's generated `main` calls `party.Activate` with the plugin IDs listed in its manifest. Activation validates the selection, sorts it so every plugin comes after the plugins it requires, merges plugin config, runs every Register before any Boot, and wires translations and mail templates in between. Capabilities beyond the lifecycle are declared through the interfaces in [pact](../pact/README.md). + +## Features + +- `party.Plugin`, the descriptor every plugin implements: `party.Plugin.ID`, `party.Plugin.Requires`, `party.Plugin.Register` and `party.Plugin.Boot`. +- A process-wide, concurrency-safe registry filled by `party.Register` (nil plugins are ignored). +- `party.Activate` selects plugins by manifest ID and fails on an empty ID, a duplicate ID, an unregistered plugin, a missing requirement or a dependency cycle. +- Stable topological ordering: plugins without a dependency relation keep their manifest order. +- Activation sequence: records the ordered IDs on the `backpack.App`, merges each `pact.HasConfig` tree into the app config under the plugin ID, runs every Register, publishes the translator ([phrasebook](../phrasebook/README.md)) and mailer ([postcard](../postcard/README.md)), then registers each plugin's mail templates and runs its Boot. + +## Usage + +A plugin registers itself when its package is imported: + +```go +package blog + +import ( + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/party" +) + +type Plugin struct{} + +func (p *Plugin) ID() string { return "acme.blog" } +func (p *Plugin) Requires() []string { return []string{"acme.user"} } +func (p *Plugin) Register(app *backpack.App) error { return nil } +func (p *Plugin) Boot(app *backpack.App) error { return nil } + +func init() { + party.Register(&Plugin{}) +} +``` + +The application activates the plugins it lists, in dependency order: + +```go +cfg, err := compass.Load("config") +if err != nil { + return err +} +app := backpack.New(cfg) +plugins, err := party.Activate(app, []string{"acme.user", "acme.blog"}) +if err != nil { + return err +} +``` + +## API reference + +| Identifier | Description | +|------------|-------------| +| `party.Plugin` | Interface every compiled plugin implements: ID, required plugin IDs, Register and Boot. | +| `party.Register` | Adds a plugin to the process-wide registry; called from the plugin's `init`. | +| `party.Activate` | Selects registered plugins by ID, orders them by `party.Plugin.Requires` and runs the config, Register, translation, mail and Boot steps; returns the ordered plugins. | + +## Dependencies + +- SummerCMS modules: [backpack](../backpack/README.md), [pact](../pact/README.md), [phrasebook](../phrasebook/README.md), [postcard](../postcard/README.md). +- Third-party: none. +- Standard library: `fmt`, `strings`, `sync`. + +## Testing + +```sh +go test ./modules/party/... +``` + +The tests use in-memory plugins and `testing/fstest` file systems and need no external services. diff --git a/modules/tide/README.md b/modules/tide/README.md index 1234853..c012a25 100644 --- a/modules/tide/README.md +++ b/modules/tide/README.md @@ -1,3 +1,88 @@ # tide -`tide` records, replays, normalizes, and compares HTTP parity fixtures for the PHP-to-Go migration. The Summer parity CLI and Fonoteka parity tests import it; a fixture flow is represented by `tide.Flow` in `flow.go`. +HTTP parity toolkit that records request and response fixtures from a reference backend, replays them against a new one and reports normalized differences. + +`import "git.golem15.com/golem15/summercms/modules/tide"` + +## Overview + +`tide` is the acceptance-test engine for porting an existing WinterCMS or other PHP backend to SummerCMS: the reference backend's real responses define the contract, and the Go port must reproduce them. It records YAML fixtures (flows of request and response steps) either by driving a spec against a target or by sitting as a loopback reverse proxy in front of the reference backend while a real client uses it, then replays those fixtures against the port and diffs the responses after masking values that legitimately differ, such as IDs and timestamps. The `summer` CLI's parity:proxy, parity:record and parity:replay commands are thin wrappers around this package. It has no WinterCMS counterpart. + +## Features + +- Flow fixtures: `tide.Flow` is a versioned, ordered list of `tide.Step` values, loaded strictly (unknown fields rejected) with `tide.LoadFlow` and `tide.ParseFlow`, and written atomically with `tide.SaveFlow` or `tide.SaveFlowExclusive`. Response bodies can live in sidecar files, confined to the fixture directory and checked against an optional SHA-256 digest. +- Recording: `tide.RecordFlow` executes a spec flow against a target URL and fills in the responses, with a bounded body size (`tide.DefaultMaxBody`, 8 MiB). +- Recording proxy: `tide.NewProxy` builds a reverse proxy that only binds to and forwards to loopback addresses, groups traffic into named sessions (from the `tide.SessionHeader` request header or a default session) and writes one fixture per complete session on `tide.Proxy.Flush`. +- Capture rules: `tide.Rules` (loaded with `tide.LoadRules`) decide which request and response headers are kept per route and which values are captured into variables, from response JSON paths, headers, redirect query strings or form fields. +- Variables: `tide.Store` holds captured values such as tokens and IDs in a mode-0600 file, `tide.Store.Expand` substitutes `{{name}}` placeholders before a request is sent, and `tide.ScrubStep` puts placeholders back into fixtures. Scrubbing fails when a step still holds an unclassified token- or password-shaped value, so credentials do not leak into committed fixtures. +- Replay and diff: `tide.ReplayFlow` re-sends each step, compares status, a fixed set of contract headers and the body, and returns `tide.Result` with per-step `tide.Diff` entries. JSON bodies are compared structurally after masking `id`, `*_id` and `*_ids` values and `*_at` timestamps; other bodies are compared byte for byte. +- Manifests: `tide.Manifest` lists routes with auth groups, a pending or ported status, cases and fixture paths; `tide.RecordManifest` records missing cases in batches of at most `tide.MaxBatch`, and `tide.ReplayManifest` replays every recorded case into a `tide.Coverage` table. + +## Usage + +Record a flow once against the reference backend, then replay it against the port: + +```go +spec, err := tide.LoadFlow("testdata/parity/posts.spec.yaml") +if err != nil { + return err +} +flow, err := tide.RecordFlow(ctx, spec, tide.RecordConfig{Target: "http://127.0.0.1:8000"}) +if err != nil { + return err +} +if err := tide.SaveFlow("testdata/parity/posts.yaml", flow); err != nil { + return err +} + +res, err := tide.ReplayFlow(ctx, flow, tide.ReplayConfig{ + Target: "http://127.0.0.1:8080", + BaseDir: "testdata/parity", +}) +if err != nil { + return err +} +for _, step := range res.Steps { + for _, d := range step.Diffs { + fmt.Printf("%s %s: want %s, got %s\n", step.ID, d.Path, d.Expected, d.Actual) + } +} +``` + +## API reference + +| Identifier | Description | +|------------|-------------| +| `tide.Flow` | Versioned, ordered list of request and response steps; the fixture format. | +| `tide.Step` | One request and response pair, with capture and normalizer overrides. | +| `tide.LoadFlow` | Reads and validates a flow file. | +| `tide.SaveFlow` | Writes a validated flow atomically. | +| `tide.RecordFlow` | Executes a spec flow against a target and returns the recorded flow. | +| `tide.ReplayFlow` | Replays a recorded flow against a target and diffs every step. | +| `tide.Result` | Replay outcome: overall status and per-step `tide.StepResult` values. | +| `tide.Diff` | One structural JSON or byte-level mismatch. | +| `tide.MismatchError` | Error that carries a failing `tide.Result`. | +| `tide.NewProxy` | Builds the loopback recording reverse proxy from a `tide.ProxyConfig`. | +| `tide.Proxy` | The recording proxy: `tide.Proxy.Handler`, `tide.Proxy.ListenAndServe`, `tide.Proxy.Flush`. | +| `tide.Rules` | Header keep lists and capture rules for proxy sessions. | +| `tide.Store` | Named capture variables, optionally persisted to a private file. | +| `tide.OpenStore` | Opens a file-backed store, or a memory-only store for an empty path. | +| `tide.Manifest` | Route list with auth groups, status, cases and fixture paths. | +| `tide.ValidateManifest` | Checks a manifest in `tide.ModeAllowIncomplete` or `tide.ModeRequireRecorded` mode. | +| `tide.RecordManifest` | Records a manifest's seed flow and missing route cases in batches. | +| `tide.ReplayManifest` | Replays every recorded route case and builds a coverage table. | +| `tide.Coverage` | Recorded, passing, failing and unrecorded counts, with table rows and a summary line. | + +## Dependencies + +- SummerCMS modules: none. +- Third-party: `github.com/goccy/go-yaml` (fixture, rules and manifest parsing). +- Standard library: `net/http`, `net/http/httputil`, `encoding/json`, `crypto/sha256`, among others. + +## Testing + +```sh +go test ./modules/tide/... +``` + +The tests run recording, the proxy and replay against local `net/http/httptest` servers and use the sample spec in `modules/tide/testdata/`; they need no external services.