From d8eafcaf38461f0764bba53e6b22243ea365ebed Mon Sep 17 00:00:00 2001 From: cssbruno Date: Mon, 27 Jul 2026 23:46:04 -0300 Subject: [PATCH] fix: resolve runtime compiler and tooling regressions --- SECURITY.md | 12 +- addons/observability/observability.go | 10 + csrf_test.go | 58 +++ docs/compiler/generated-output.md | 5 + docs/engineering/README.md | 4 + docs/engineering/architecture.md | 6 +- docs/engineering/csrf-secret-rotation-plan.md | 84 ++++ docs/engineering/csrf-secret-rotation-spec.md | 103 +++++ ...006-gowdk-compiler-and-runtime-boundary.md | 3 +- docs/engineering/operations.md | 4 +- docs/engineering/security-threat-model.md | 4 +- docs/engineering/security.md | 7 +- docs/language/actions.md | 55 ++- docs/language/audit.md | 5 + docs/language/data.md | 16 +- docs/language/forms.md | 32 +- docs/language/grammar.md | 17 +- docs/language/hybrid.md | 5 +- docs/language/partials.md | 41 +- docs/product/hybrid-lifecycle-spec.md | 5 +- docs/product/language-server.md | 23 +- docs/product/requirements.md | 6 +- docs/reference/addons.md | 47 ++- docs/reference/cli.md | 13 +- docs/reference/config.md | 47 ++- docs/reference/contracts.md | 4 +- docs/reference/deployment.md | 48 ++- docs/reference/diagnostic-codes.md | 2 + docs/reference/observability.md | 18 +- docs/reference/routing.md | 14 + docs/reference/tracing.md | 23 ++ editors/vscode/README.md | 4 +- examples/components/wasm/README.md | 6 +- examples/components/wasm/abi-counter.cmp.gwdk | 2 +- examples/endpoints/README.md | 19 +- examples/flagship/README.md | 14 +- gowdk.go | 104 ++++- internal/appgen/appgen.go | 3 + internal/appgen/appgen_test.go | 100 ++++- internal/appgen/audit_tests.go | 12 +- internal/appgen/scripts.go | 4 +- internal/appgen/source.go | 60 ++- internal/appgen/source_actions.go | 14 +- internal/appgen/source_auth.go | 4 +- internal/appgen/source_backend_app.go | 10 +- .../generated_go_golden/app.go.golden | 4 +- internal/buildgen/css.go | 4 +- internal/buildgen/islands_test.go | 3 +- internal/buildgen/runtime_wasm_assets.go | 26 +- internal/buildgen/seo.go | 6 +- internal/compiler/validate_scripts.go | 6 +- internal/compiler/validate_test.go | 2 +- internal/diagnostics/registry.go | 2 + internal/discover/configured.go | 260 +++++++++++++ internal/discover/configured_test.go | 209 ++++++++++ internal/gowdkcmd/audit.go | 8 +- internal/gowdkcmd/build.go | 34 +- internal/gowdkcmd/clean.go | 7 +- internal/gowdkcmd/dev.go | 4 +- internal/gowdkcmd/flags.go | 31 +- internal/gowdkcmd/flags_test.go | 129 +++++- internal/gowdkcmd/lsp.go | 61 ++- internal/gowdkcmd/main.go | 7 +- internal/gowdkcmd/main_test.go | 49 +++ internal/gowdkcmd/preview.go | 5 +- internal/gowdkcmd/project_inputs.go | 131 ++----- internal/gowdkcmd/serve.go | 4 +- internal/gowdkcmd/test.go | 18 +- internal/lang/tools_test.go | 50 +++ internal/lsp/cache.go | 11 +- internal/lsp/completion_hover.go | 28 +- internal/lsp/components.go | 180 ++++----- internal/lsp/components_discovery_test.go | 364 +++++++++++++++++ internal/lsp/notifications.go | 2 +- internal/lsp/server.go | 21 +- internal/parser/audit.go | 17 +- internal/parser/diagnostic.go | 1 + internal/parser/line_scanner.go | 56 +++ internal/parser/line_scanner_test.go | 92 +++++ internal/parser/syntax.go | 19 +- internal/project/config.go | 5 + internal/project/config_exec.go | 228 ++++++----- internal/project/config_test.go | 367 ++++++++++++++++-- internal/project/config_validation.go | 3 + internal/publicapi/gowdk_test.go | 84 +++- runtime/actions/actions_test.go | 100 +++++ runtime/actions/csrf.go | 91 +++-- runtime/app/app_test.go | 286 ++++++++++++++ runtime/app/backend.go | 124 +++++- runtime/envfile/envfile.go | 49 ++- runtime/envfile/envfile_test.go | 59 +++ runtime/response/response.go | 10 +- runtime/response/response_test.go | 17 + runtime/trace/export_queue.go | 155 ++++++++ runtime/trace/export_queue_test.go | 196 ++++++++++ runtime/trace/span.go | 32 +- runtime/trace/tracer.go | 61 ++- 97 files changed, 4084 insertions(+), 711 deletions(-) create mode 100644 csrf_test.go create mode 100644 docs/engineering/csrf-secret-rotation-plan.md create mode 100644 docs/engineering/csrf-secret-rotation-spec.md create mode 100644 internal/discover/configured.go create mode 100644 internal/discover/configured_test.go create mode 100644 internal/lsp/components_discovery_test.go create mode 100644 internal/parser/line_scanner.go create mode 100644 internal/parser/line_scanner_test.go create mode 100644 runtime/trace/export_queue.go create mode 100644 runtime/trace/export_queue_test.go diff --git a/SECURITY.md b/SECURITY.md index d90eed1d..f267b74c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,11 +4,12 @@ GOWDK is an experimental 0.x compiler/runtime. Do not treat generated apps as production-ready security enforcement. First slices exist for generated action decoding, unexpected-field rejection, -direct literal request-shape validation, opt-in CSRF, configurable action/API -request body caps, generated `http.Server` read/header/write/idle timeout -defaults, `MaxHeaderBytes`, safe local redirects, guard execution, SSR panic -boundaries, log redaction, and no-store request-time responses. These are not a -complete production security model. +direct literal request-shape validation, default CSRF with staged multi-key +secret rotation, configurable action/API request body caps, generated +`http.Server` read/header/write/idle timeout defaults, `MaxHeaderBytes`, safe +local redirects, guard execution, SSR panic boundaries, log redaction, and +no-store request-time responses. These are not a complete production security +model. ## Reporting Vulnerabilities @@ -47,7 +48,6 @@ Known incomplete production areas include: - Authentication and session policy. - Full guard contract coverage. -- Multi-key CSRF secret rotation. - Full redirect policy. - Per-route request body/header limit policy beyond the current generated body caps and server header cap. diff --git a/addons/observability/observability.go b/addons/observability/observability.go index 4a60342a..f2d3c223 100644 --- a/addons/observability/observability.go +++ b/addons/observability/observability.go @@ -51,6 +51,16 @@ func WithSink(sink gowdktrace.Sink) gowdktrace.TracerOption { return gowdktrace.WithSink(sink) } +// WithExportQueueSize configures the bounded completed-span queue. +func WithExportQueueSize(size int) gowdktrace.TracerOption { + return gowdktrace.WithExportQueueSize(size) +} + +// WithExportTimeout configures the deadline for one sink export. +func WithExportTimeout(timeout time.Duration) gowdktrace.TracerOption { + return gowdktrace.WithExportTimeout(timeout) +} + // AlwaysOn samples every span. func AlwaysOn() gowdktrace.Sampler { return gowdktrace.AlwaysOn() diff --git a/csrf_test.go b/csrf_test.go new file mode 100644 index 00000000..d15b8a17 --- /dev/null +++ b/csrf_test.go @@ -0,0 +1,58 @@ +package gowdk + +import ( + "strings" + "testing" +) + +func TestCSRFConfigSecretEnvNames(t *testing.T) { + config := CSRFConfig{ + SecretEnv: " PRIMARY_CSRF_SECRET ", + VerificationSecretEnvs: []string{" NEXT_CSRF_SECRET ", "PREVIOUS_CSRF_SECRET"}, + } + + if got := config.SecretEnvName(); got != "PRIMARY_CSRF_SECRET" { + t.Fatalf("primary CSRF secret env = %q", got) + } + if got := strings.Join(config.SecretEnvNames(), ","); got != "PRIMARY_CSRF_SECRET,NEXT_CSRF_SECRET,PREVIOUS_CSRF_SECRET" { + t.Fatalf("CSRF secret env names = %q", got) + } +} + +func TestCSRFConfigValidateRejectsBlankAndDuplicateVerificationSecretEnvs(t *testing.T) { + tests := []struct { + name string + config CSRFConfig + want string + }{ + { + name: "blank", + config: CSRFConfig{VerificationSecretEnvs: []string{" "}}, + want: "VerificationSecretEnvs[0]", + }, + { + name: "duplicates primary", + config: CSRFConfig{ + SecretEnv: "PRIMARY_CSRF_SECRET", + VerificationSecretEnvs: []string{"PRIMARY_CSRF_SECRET"}, + }, + want: "declared more than once", + }, + { + name: "duplicates verification key", + config: CSRFConfig{ + VerificationSecretEnvs: []string{"OLD_CSRF_SECRET", " OLD_CSRF_SECRET "}, + }, + want: "declared more than once", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.config.Validate() + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Validate() error = %v, want %q", err, test.want) + } + }) + } +} diff --git a/docs/compiler/generated-output.md b/docs/compiler/generated-output.md index cee54e45..953b9a85 100644 --- a/docs/compiler/generated-output.md +++ b/docs/compiler/generated-output.md @@ -115,6 +115,11 @@ Action, API, fragment, command, query, SSR, hybrid, realtime, guard, rate-limit, CSRF, CORS, and tracing behavior is included only when declared, enabled, and validated for the selected build. +Generated CSRF startup reads the primary signing secret named by +`Build.CSRF.SecretEnv` and every verification-only secret named by +`Build.CSRF.VerificationSecretEnvs`. Generated source contains environment +variable names, never secret values. + Worker and cron role outputs follow the same generated-app rule: they are normal Go modules downstream of contract metadata, and `--worker-bin` / `--cron-bin` compile their generated commands with `go build`. diff --git a/docs/engineering/README.md b/docs/engineering/README.md index 90aab49f..541cecf8 100644 --- a/docs/engineering/README.md +++ b/docs/engineering/README.md @@ -27,6 +27,10 @@ current product, reference, compiler, and engineering docs. Keep new plans short and scoped to active work. Delete or fold them into current contracts when they stop being useful. +Current secure-runtime slice: +[CSRF secret rotation spec](csrf-secret-rotation-spec.md) and +[implementation plan](csrf-secret-rotation-plan.md). + Use an ADR instead of a plan when the durable decision is more important than the execution checklist. diff --git a/docs/engineering/architecture.md b/docs/engineering/architecture.md index 92c67c8b..0370602f 100644 --- a/docs/engineering/architecture.md +++ b/docs/engineering/architecture.md @@ -1,8 +1,8 @@ # Architecture GOWDK is a compile-first Go web compiler and runtime. `.gwdk` files declare web -surface contracts; normal Go packages own application behavior; generated Go is -inspectable adapter glue. +surface contracts and markup; normal Go packages own application behavior and +data; generated Go is inspectable adapter glue. ## System Shape @@ -24,7 +24,7 @@ request-time lane selected with `server {}` or `go server {}`. | Layer | Owns | Does not own | | --- | --- | --- | -| GOWDK source | Page, component, layout, route, endpoint, asset, guard, cache, and bounded browser declarations | Business logic, storage, authorization policy, production operations | +| GOWDK source | Page, component, layout, and fragment markup plus route, endpoint, asset, guard, cache, and bounded browser declarations | Business logic, storage, authorization policy, production operations | | Compiler internals | Parsing, typed AST, analysis, IR, diagnostics, validation, build reports, manifests, and generated-output planning | Request serving and app-owned runtime state | | Generated app | Adapter glue, route registration, decoding, response writing, guard/rate-limit/CSRF ordering, embedded assets, lifecycle hooks | Domain behavior and external infrastructure | | Runtime packages | `net/http` helpers, request context, response envelopes, assets, guards, contracts, tracing, and addon helpers | Application schemas, migrations, secrets, auth policy, backups, incidents | diff --git a/docs/engineering/csrf-secret-rotation-plan.md b/docs/engineering/csrf-secret-rotation-plan.md new file mode 100644 index 00000000..e0b8c040 --- /dev/null +++ b/docs/engineering/csrf-secret-rotation-plan.md @@ -0,0 +1,84 @@ +# Implementation Plan: CSRF Secret Rotation + +## Context + +The feature contract is +[CSRF Secret Rotation](csrf-secret-rotation-spec.md). It closes the multi-key +rotation gap recorded in `SECURITY.md` and the deployment reference. + +## Assumptions + +- The existing HMAC token format remains secure and does not need a key ID. +- Secret environment names are compile-time config; values are runtime-only. +- A three-phase deployment is acceptable because it permits rolling deploys + and rollback without invalidating tokens. + +## Proposed Changes + +- Add verification-secret environment names to `gowdk.CSRFConfig`, its + structural parser, and config validation. +- Extend `runtime/actions.CSRF` with a primary signer and multiple verification + keys. +- Generate AST-backed startup code that loads all configured keys and passes + them to `actions.NewCSRF`. +- Seed configured verification secrets in generated audit tests. +- Update security, config, action, deployment, operations, generated-output, + and product-status documentation. + +## Files Expected To Change + +- `gowdk.go` +- `internal/project/config.go` and config validation/tests +- `runtime/actions/csrf.go` and tests +- `internal/appgen/source.go`, audit test generation, and generator tests +- `SECURITY.md` +- `docs/compiler/generated-output.md` +- `docs/reference/config.md` and `docs/reference/deployment.md` +- `docs/language/actions.md` +- `docs/engineering/security*.md` and `operations.md` +- `docs/product/requirements.md` + +## Data And API Impact + +- Additive public Go config field: + `CSRFConfig.VerificationSecretEnvs []string`. +- Additive public runtime option: + `CSRFOptions.VerificationSecrets [][]byte`. +- No `.gwdk` syntax, manifest schema, token format, or persisted-data change. +- Existing binaries/configs with only `SecretEnv` behave as before. + +## Tests + +- Unit: config validation; primary/verification signing, validation, refresh, + retirement, short-key rejection, and principal binding. +- Integration: generated source contains all environment reads and compiles. +- End-to-end: generated binary accepts a token minted by an overlapping key. +- Manual: follow the documented three-phase rollout against a generated app. + +## Verification Commands + +```sh +gofmt -w gowdk.go internal/project/config.go internal/project/config_validation.go internal/project/config_test.go internal/gowdkcmd/project_inputs.go runtime/actions/csrf.go runtime/actions/actions_test.go internal/appgen/source.go internal/appgen/audit_tests.go internal/appgen/appgen_test.go +go test ./runtime/actions ./internal/project ./internal/appgen +go build ./cmd/gowdk +scripts/check-docs-links.sh +scripts/check-docs-style.sh +scripts/test-go-modules.sh +``` + +## Rollback Plan + +- Remove verification keys from config and redeploy with the current primary. +- Because the token format is unchanged, reverting the code keeps primary-key + tokens valid. +- During phase two, rollback to phase one; phase-one instances already verify + the promoted key. + +## Risks + +- Operators can remove an old key before the overlap window ends, invalidating + open forms. The deployment guide makes the sequencing explicit. +- Large key lists add HMAC work per validation. The list is trusted config and + expected to contain only the staged/current/retiring keys. +- Generator changes overlap active app-generation work; edits must remain + confined to the CSRF AST builder and focused tests. diff --git a/docs/engineering/csrf-secret-rotation-spec.md b/docs/engineering/csrf-secret-rotation-spec.md new file mode 100644 index 00000000..b6513f6e --- /dev/null +++ b/docs/engineering/csrf-secret-rotation-spec.md @@ -0,0 +1,103 @@ +# Feature Spec: CSRF Secret Rotation + +## Problem + +Generated apps currently sign and validate CSRF tokens with one runtime secret. +Changing that secret invalidates open forms and makes a rolling deployment +unsafe: old instances reject tokens minted by new instances, and new instances +reject tokens minted by old instances. + +## Goals + +- Let generated apps sign with one primary secret and validate with additional + verification-only secrets. +- Keep the existing single-secret configuration fully compatible. +- Refresh a valid token signed by a verification key to the primary key when a + generated page next injects a CSRF token. +- Fail generated-app startup when any configured secret is absent or shorter + than 32 bytes. +- Document a rollback-safe staged rotation procedure. + +## Non-Goals + +- Owning deployment-platform secret storage or distribution. +- Replacing app-owned authentication, sessions, or resource authorization. +- Adding key identifiers or changing the existing token wire format. +- Adding token expiration or cross-instance server-side CSRF state. + +## Users And Permissions + +- Primary users: operators of generated GOWDK applications with actions, + commands, or state-changing APIs. +- Roles or permissions: deployment operators control secret environment + variables and generated app rollout order. +- Data visibility rules: config stores environment-variable names only; secret + values remain runtime-only and must not appear in generated source, reports, + diagnostics, or logs. + +## User Flow + +1. Deploy every instance with the current key as primary and the next key as a + verification key. +2. Deploy every instance with the next key as primary and the current key as a + verification key. +3. After the overlap window, deploy without the retired key. + +## Requirements + +### Functional + +- `gowdk.CSRFConfig` exposes `VerificationSecretEnvs []string`. +- `actions.CSRFOptions` exposes `VerificationSecrets [][]byte`. +- `CSRF.Token` signs new tokens only with `Secret`. +- `CSRF.Validate` accepts signatures from the primary or any verification key. +- `CSRF.Token` replaces a request cookie signed only by a verification key with + a newly generated primary-key token. +- Config validation rejects blank or duplicate verification environment names, + including duplication of the primary environment name. +- Generated frontend, backend-only, and split app outputs read every configured + CSRF secret and fail closed when a value is missing. + +### Non-Functional + +- Performance: verification is linear in the small operator-configured key + list; all configured keys are checked without an early return. +- Reliability: primary-only behavior and the existing token format remain + unchanged. +- Accessibility: no user-interface contract changes. +- Security/privacy: secret bytes are copied into runtime-owned memory and never + emitted into generated source or reports. +- Observability: startup errors identify the missing environment-variable name + without printing a value. + +## Acceptance Criteria + +- [x] A token minted with the old primary validates after that key moves to the + verification list. +- [x] A token minted with the new primary validates on an instance that + pre-staged the new key as verification-only. +- [x] Rendering a form with an old valid cookie emits a new primary-key cookie. +- [x] Removing a verification key makes its tokens fail validation. +- [x] Generated source reads the configured primary and verification + environment variables and compiles. +- [x] A generated binary accepts an overlapping-key token end to end. +- [x] Existing primary-only runtime and generated-app tests remain green. + +## Edge Cases + +- Missing, blank, short, or duplicate configured keys. +- Principal-bound CSRF tokens during rotation. +- Repeated verification keys supplied directly through the runtime API. +- A request containing a cookie and submitted token that match each other but + were signed by a retired key. + +## Dependencies + +- Internal: `gowdk.CSRFConfig`, structural/native config loading, + `runtime/actions`, and generated app source under `internal/appgen`. +- External: none. + +## Open Questions + +- None for this slice. Key identifiers can be reconsidered only if measured + verification cost justifies a token-format migration. diff --git a/docs/engineering/decisions/0006-gowdk-compiler-and-runtime-boundary.md b/docs/engineering/decisions/0006-gowdk-compiler-and-runtime-boundary.md index 90e2856a..06544780 100644 --- a/docs/engineering/decisions/0006-gowdk-compiler-and-runtime-boundary.md +++ b/docs/engineering/decisions/0006-gowdk-compiler-and-runtime-boundary.md @@ -171,4 +171,5 @@ domain logic, handlers, stores, auth, validation policy, or storage code. - Keep `docs/product/roadmap.md` as the active product direction. - Keep `docs/engineering/architecture.md` as the implementation boundary source of truth. -- Keep server fragments in runtime responses, not old action body syntax. +- Keep server-fragment response decisions in runtime responses, keep markup in + `.gwdk`, and do not restore old action body syntax. diff --git a/docs/engineering/operations.md b/docs/engineering/operations.md index 7d771144..51b59848 100644 --- a/docs/engineering/operations.md +++ b/docs/engineering/operations.md @@ -124,7 +124,9 @@ readiness claim: - Do not set `Build.CSRF.Disabled` for production generated action, command, or state-changing API handlers unless another cross-site request strategy is in place. Provide a stable `GOWDK_CSRF_SECRET` or configured - `Build.CSRF.SecretEnv` value in each runtime environment. + `Build.CSRF.SecretEnv` value in each runtime environment. Use + `Build.CSRF.VerificationSecretEnvs` for the staged + [CSRF rotation procedure](../reference/deployment.md#csrf-secret-rotation). - Return explicit method-not-allowed responses for unsupported methods. - Serve app assets with deterministic cache headers. - Avoid public debug endpoints by default. diff --git a/docs/engineering/security-threat-model.md b/docs/engineering/security-threat-model.md index 2dde12b0..77f8c280 100644 --- a/docs/engineering/security-threat-model.md +++ b/docs/engineering/security-threat-model.md @@ -33,7 +33,7 @@ current controls, and open follow-up areas for review. | --- | --- | --- | --- | | `.gwdk` source to compiler diagnostics | Parser, analyzer, `gowdk check`, LSP diagnostics | Stable diagnostic registry, source spans where available, redaction policy for secret-like values | Broader exact spans and diagnostic expansion remain tracked outside M5. | | Generated logs and panic sinks | `runtime/app` panic boundaries, contract worker logs | Panic responses avoid stack traces; recovered panic logs pass through secret redaction | Broader app-owned logging guidance and redaction coverage remain planned. | -| Browser/client to action endpoints | POST forms, enhanced partial forms, multipart action forms, command form adapters | Expected-field decoding, direct literal validation, configurable action body cap defaulting to 1 MiB, explicit generated file count/size/MIME upload policy, default CSRF, 405 on wrong methods, no-store error responses | Per-route limits, content scanning, storage policy, and full production CSRF rotation remain app-owned or planned. | +| Browser/client to action endpoints | POST forms, enhanced partial forms, multipart action forms, command form adapters | Expected-field decoding, direct literal validation, configurable action body cap defaulting to 1 MiB, explicit generated file count/size/MIME upload policy, default CSRF with primary and verification-only keys, old-key cookie refresh, 405 on wrong methods, no-store error responses | Per-route limits, content scanning, storage policy, and deployment-owned CSRF key retirement remain app-owned. | | Browser/client to API endpoints | Generated API routes, contract query routes | Method dispatch, configurable API body cap defaulting to 1 MiB, generated CSRF for state-changing API methods, rate-limit hook when addon is enabled | Public API hardening, typed helper expansion, and per-route policy are tracked in #24. | | Browser/client to fragments | Standalone fragments, action fragment responses | Fragment routing through generated handlers, escaped render core, no-store request-time responses; standalone fragments are GET-only and action fragments share the action cap | Broader auth/session policy remains planned. | | Browser/client to SSR `server {}` | Request-time SSR routes, route-local error pages | SSR feature gate, guard execution, safe local redirect helpers, panic boundaries, no-store failures | Full guard contract, route-local auth/session policy, and richer request-time error policy remain planned. | @@ -50,7 +50,7 @@ current controls, and open follow-up areas for review. | --- | --- | --- | --- | | Submit unexpected action fields to overwrite handler input. | Integrity of action input. | Generated decoders reject unexpected fields and skip runtime fields such as CSRF. | Medium until broader typed helper contracts stabilize. | | Send large request bodies to exhaust memory or handler time. | Availability of generated servers. | Generated action/API adapters cap bodies with configurable app-level limits; generated server entrypoints set HTTP timeouts and max-header defaults. | Medium because per-route limits remain planned. | -| Reuse or forge generated CSRF tokens. | Cross-site action, command, or state-changing API execution. | Generated CSRF is enabled by default for generated action POSTs, command POSTs, and state-changing APIs, and validates before decoding or user handlers run. | Medium while secret rotation and deployment guidance continue to harden. | +| Reuse or forge generated CSRF tokens. | Cross-site action, command, or state-changing API execution. | Generated CSRF is enabled by default for generated action POSTs, command POSTs, and state-changing APIs, validates before decoding or user handlers run, and supports staged primary/verification-key rotation. | Medium because secret storage, overlap duration, and final key retirement remain deployment-owned. | | Trigger handler panics and read stack traces or secret values. | Secret exposure and debugging data leakage. | Runtime panic boundaries avoid stack traces in responses and redact recovered-panic logs. | Medium because app-owned logs are outside generated control. | | Use unsafe redirects to move users off-site. | Phishing or token leakage through redirects. | First slices require safe local redirects for generated action/SSR redirect paths. | Medium until full redirect allowlists and diagnostics are complete. | | Embed local secrets into generated output. | Secret exposure in release artifacts. | Docs require generated output to avoid local env/private files. | High until exclusion tests cover `.env`, source maps, private files, and temp artifacts. | diff --git a/docs/engineering/security.md b/docs/engineering/security.md index dc527fef..9b2a06a2 100644 --- a/docs/engineering/security.md +++ b/docs/engineering/security.md @@ -21,7 +21,9 @@ Do not treat current `act`, `api`, `partial`, `guard`, or SSR scaffolding as com - Generated actions, command endpoints, and state-changing API endpoints enable CSRF by default. Production configs must not set `Build.CSRF.Disabled` unless another cross-site request strategy is enforced, and every runtime environment - must provide a stable CSRF secret. + must provide a stable primary CSRF secret. Rolling deployments use + verification-only secrets so old and new instances accept each other's + tokens while only the primary key signs new tokens. - Generated form decoders must validate expected fields and avoid mass assignment. - Generated action forms must reject direct file inputs unless the enclosing `g:post` form is multipart and every file control declares explicit count, @@ -42,7 +44,8 @@ Do not treat current `act`, `api`, `partial`, `guard`, or SSR scaffolding as com Before generated app output is considered production-ready: - Generated action, command, and state-changing API CSRF must be enabled and - configured with a runtime secret. + configured with a primary runtime secret. Multi-key verification and + old-cookie refresh must support rollback-safe staged rotation. - Redirects must reject unsafe external destinations unless explicitly allowed. - Generated decoders must define how unknown, missing, repeated, and file fields are handled. - Guards must have a documented execution contract, failure behavior, and test coverage. diff --git a/docs/language/actions.md b/docs/language/actions.md index dc7efccc..bea9c563 100644 --- a/docs/language/actions.md +++ b/docs/language/actions.md @@ -1,7 +1,9 @@ # Actions Actions are endpoint declarations. A page declares the exported same-package Go -symbol, HTTP method, and endpoint path in `.gwdk`; normal Go owns the behavior. +symbol, HTTP method, and endpoint path in `.gwdk`. Page and fragment markup also +stays in GOWDK source; normal Go owns action data, domain behavior, and response +decisions. The supported declaration shape is: @@ -20,8 +22,8 @@ Current behavior: errors still follow normal `runtime/response.Response` behavior. - Old `act submit { ... }` blocks are rejected with a migration diagnostic. - Actions currently require `POST`. -- Redirects, fragments, validation, and business rules come from the Go handler - response, not from generated `.gwdk` action body code. +- Validation, business rules, state changes, and response outcomes come from + the Go handler. Page and fragment markup stays in `.gwdk` source. - `
` lowers to a standard POST form for a supported action. - `gowdk build --app --bin` generates POST handlers for non-dynamic page routes. @@ -86,15 +88,19 @@ Current behavior: - `runtime/actions.NewCSRF` provides signed double-submit CSRF tokens with an HttpOnly, Secure, SameSite=Lax cookie by default. Local HTTP `Insecure` mode uses a non-prefixed `gowdk-csrf` cookie because browsers reject `__Host-` - cookies without Secure. Normal builds do not expose a no-op CSRF validator; + cookies without Secure. `CSRFOptions.Secret` signs new tokens; + `CSRFOptions.VerificationSecrets` accepts old or pre-staged keys without + using them to sign. Normal builds do not expose a no-op CSRF validator; package tests keep their no-op helper in `_test.go`. - Generated action adapters wire CSRF token generation and validation by default. Generated apps read the signing secret from `Build.CSRF.SecretEnv` or - `GOWDK_CSRF_SECRET`, inject a hidden token field into served HTML POST forms, - validate action POSTs before generated decoding or user handlers run, and - return HTTP 403 with `invalid csrf token` plus `Cache-Control: no-store` for - missing or invalid tokens. Set `Build.CSRF.Disabled: true` only for an - intentional non-production/test opt-out. + `GOWDK_CSRF_SECRET`, read verification-only secrets from + `Build.CSRF.VerificationSecretEnvs`, inject a hidden token field into served + HTML POST forms, validate action POSTs before generated decoding or user + handlers run, and return HTTP 403 with `invalid csrf token` plus + `Cache-Control: no-store` for missing or invalid tokens. Set + `Build.CSRF.Disabled: true` only for an intentional non-production/test + opt-out. - Field inference currently reads direct `input`, `textarea`, `select`, and named submit controls with literal `name` attributes; fields hidden inside component calls are not inferred yet. @@ -132,25 +138,36 @@ Generated `pattern` checks use GOWDK's anchored form-pattern subset: literals, `.`, character classes/ranges, grouping, alternation, common escapes such as `\d`, `\w`, and `\s`, and `*`, `+`, `?`, `{n}`, `{n,}`, and `{n,m}` quantifiers. GOWDK does not run user-defined domain validation or generate -general fragment routes. Handlers can return redirects, fragments, HTML, or JSON -through `runtime/response.Response`. +general fragment routes. Handlers return `runtime/response.Response` to choose +status, headers, redirects, JSON, reload behavior, and partial target/swap +metadata. Page and fragment markup belongs in `.gwdk`. Body-bearing HTML and +fragment response helpers remain low-level compatibility APIs, not the +recommended markup authoring model. ## Examples -- `examples/endpoints/src/endpoints/contact.page.gwdk` declares redirect and validation - fragment actions backed by `examples/endpoints/src/endpoints/handlers.go`. -- `examples/endpoints/src/endpoints/settings.page.gwdk` declares save/reset actions that - return partial fragments for a settings result region. +- `examples/endpoints/src/endpoints/contact.page.gwdk` declares redirect and + validation action surfaces backed by + `examples/endpoints/src/endpoints/handlers.go`. +- `examples/endpoints/src/endpoints/settings.page.gwdk` declares save/reset + actions and their partial-update target. The example's external HTML + templates exercise the current low-level response-body compatibility path; + they are not the preferred markup ownership model. ## Production Notes - Do not set `Build.CSRF.Disabled` for production generated app deployments that accept action POSTs. Provide a stable runtime secret through - `Build.CSRF.SecretEnv` or `GOWDK_CSRF_SECRET`. + `Build.CSRF.SecretEnv` or `GOWDK_CSRF_SECRET`. Use + `Build.CSRF.VerificationSecretEnvs` and the + [three-phase rotation procedure](../reference/deployment.md#csrf-secret-rotation) + for rollback-safe key changes. - Keep authentication, backend authorization, business validation, persistence, - service calls, redirects, HTML, JSON, and fragment decisions in normal Go handlers. - Generated adapters decode the request and write the returned - `runtime/response.Response`; they do not generate application policy. + and service calls in normal Go handlers. Handlers also choose redirects, + status, headers, JSON, reload behavior, and partial target/swap metadata. + Keep page and fragment HTML in `.gwdk` source. Generated adapters decode the + request and write the returned `runtime/response.Response`; they do not + generate application policy. - Generated checks only cover direct literal `required`, `minlength`, `maxlength`, and supported `pattern` controls in the current `view {}` subset. Treat them as request-shape checks, not a substitute for domain diff --git a/docs/language/audit.md b/docs/language/audit.md index 4e9c28ae..3ab775f2 100644 --- a/docs/language/audit.md +++ b/docs/language/audit.md @@ -4,6 +4,11 @@ They are discovered with normal `.gwdk` inputs, lowered into IR, and consumed by `gowdk audit`; they do not generate pages, routes, or browser assets. +Audit files accept logical lines up to 1 MiB (1,048,576 bytes), excluding the +line ending. An oversized line reports `source_line_too_long` at its file and +line. Split long policies across declarations instead of placing generated +payloads on one line. + ```gwdk package security diff --git a/docs/language/data.md b/docs/language/data.md index 161bd179..c11656f5 100644 --- a/docs/language/data.md +++ b/docs/language/data.md @@ -12,7 +12,7 @@ Generated JavaScript does not own page loading policy. | `server {}` | request time | SSR page data | One same-package `Load` function returns `map[string]any` data or an exported typed result struct. | | `act` | request time | POST/action endpoint behavior | Same-package Go handler returns `runtime/response.Response`. | | `api` | request time | API endpoint behavior | Same-package Go handler returns `runtime/response.Response`. | -| `fragment` | request time | partial endpoint behavior | Same-package Go hook or static generated fragment body. | +| `fragment` | request time | partial endpoint behavior | `.gwdk` fragment markup plus an optional same-package Go hook for data and response decisions. | ## Current Rules @@ -50,11 +50,14 @@ func LoadDashboard(ssr.LoadContext) (DashboardData, error) ## Invalidation And Refresh - Full POST actions and enhanced POST actions share the same user Go handler - ownership. The handler response decides redirect, HTML, JSON, or fragment - behavior. + ownership. The handler response decides redirects, status, headers, JSON, + reload behavior, and partial target/swap metadata. `.gwdk` source owns page + and fragment markup. - GOWDK does not automatically rerun `server {}` after an action today. - Partial updates use explicit fragment responses or standalone fragment - endpoints. Fragments own their request-time data through the fragment Go hook. + endpoints. Fragment Go hooks own request-time data and response decisions; + fragment declarations own markup. Generated typed binding from hook data into + declared fragment markup remains planned. - Fragments do not declare compiler-tracked data dependencies today. - Generated client navigation does not prefetch or reuse `server {}` data today. Any future prefetch or reuse must be an explicit generated-client feature, @@ -62,8 +65,9 @@ func LoadDashboard(ssr.LoadContext) (DashboardData, error) ## Boundaries -- User Go owns auth, business validation, storage, service calls, and response - semantics. +- GOWDK source owns page, layout, component, and fragment markup. +- User Go owns auth, business validation, storage, service calls, data, and + response semantics. - Generated Go owns adapter glue: decode, dispatch, context metadata, response writing, guards, CSRF checks, panic boundaries, and cache defaults. - Generated JavaScript may enhance form submissions, fragments, islands, and diff --git a/docs/language/forms.md b/docs/language/forms.md index 99b3dae9..3ee252da 100644 --- a/docs/language/forms.md +++ b/docs/language/forms.md @@ -1,7 +1,8 @@ # Forms And Progressive Enhancement GOWDK forms start as normal HTML forms. JavaScript can enhance a form into a -fragment request, but Go handlers still own action behavior. +fragment request. GOWDK source owns page and fragment markup; Go handlers own +action data, domain behavior, and response decisions. ## Baseline Form Behavior @@ -20,10 +21,10 @@ request-shape constraints, runs guards and CSRF when configured, calls the same-package Go action handler, and writes the returned `runtime/response.Response`. -There is no generated page-level form state object today. The submitted form -data, handler response, redirected page, or returned fragment is the source of -truth. Component state and `g:bind` can improve client interaction, but they do -not replace server validation or action results. +There is no generated page-level form state object today. Submitted form data, +the handler's response decision, redirected pages, and source-declared +fragments remain authoritative. Component state and `g:bind` can improve client +interaction, but they do not replace server validation or action results. ## Action Results @@ -40,6 +41,12 @@ Full-page POST handlers return `runtime/response.Response`: - `response.ReloadPage()` for enhanced forms that should reload the current page after the action completes. +`response.HTMLBody`, `partial.Fragment`, and other body-bearing response helpers +remain low-level compatibility APIs. New page and fragment markup belongs in +`.gwdk`; handlers should prefer status, header, redirect, JSON, reload, target, +and swap decisions. Generated typed binding from action data into declared +fragment markup remains planned. + Generated request-shape validation is intentionally narrow. It covers direct literal form fields and literal constraints such as `required`, `minlength`, `maxlength`, and `pattern`. Domain validation belongs in the Go handler. @@ -75,9 +82,10 @@ focus where possible, and remounts generated islands around replaced DOM. Failed enhanced requests dispatch `gowdk:request-error` with `detail.status`, `detail.body`, and `detail.response` when an HTTP response exists. -Enhanced redirects are not a stable contract today. For enhanced requests, -return a fragment response for the target. Use normal full-page POST redirects -for the no-JavaScript path. +Enhanced redirects are not a stable contract today. For enhanced requests, the +handler chooses the target/swap outcome while source owns the fragment markup. +The current action compatibility path can still return an explicit fragment +body. Use normal full-page POST redirects for the no-JavaScript path. There is no nearest error-boundary lookup for enhanced actions today. Failed enhanced requests dispatch `gowdk:request-error`; generated validation @@ -93,7 +101,7 @@ handlers choose the lifecycle outcome explicitly. Use one of these explicit outcomes: - Redirect after full-page POST so the browser loads fresh page output. -- Return a fragment response for the changed region. +- Choose a partial target/swap response for the changed source-owned region. - Return `response.ReloadPage()` so enhanced forms reload the current page and rerun request-time `server {}` data. - Return JSON to a user-owned client integration. @@ -139,6 +147,12 @@ per-file policy, preserves CSRF behavior, and decodes uploads into `form.File` or `[]form.File` fields on typed action input structs. Low-level handlers can accept `form.Data`. +Multipart classification parses `Content-Type` as a structured media type. +Valid `multipart/form-data; boundary=...` requests use multipart parsing; +prefix-only values such as `multipart/form-dataevil` do not. Missing or +malformed multipart boundaries return HTTP 400. Typed request-limit failures +return HTTP 413, while other malformed form data returns HTTP 400. + Storage, content scanning, persistence, cleanup beyond parser temporary files, authorization, and domain validation remain user-owned Go behavior. Stream file content with `file.Open()` during the request. diff --git a/docs/language/grammar.md b/docs/language/grammar.md index 0c36ca71..413293ee 100644 --- a/docs/language/grammar.md +++ b/docs/language/grammar.md @@ -6,6 +6,13 @@ Accepted and rejected syntax is pinned by the machine-checked conformance corpus in [Conformance Corpus](conformance.md), which is the contract source of truth when this grammar drifts. +Each logical line may contain up to 1 MiB (1,048,576 bytes), excluding the +line ending. This explicit limit applies to metadata and inline `js {}`, +`style {}`, and `go {}` content, so lines larger than Go's former 64 KiB +scanner default remain valid. Larger lines report `source_line_too_long` with +the file and line; split generated/minified content or move it to an external +asset. + ```text file = line* line = blank | comment | packageDecl | metadataDecl | importDecl | useDecl | blockDecl | goDecl | actionDecl | apiDecl | unsupportedBlock | other @@ -28,7 +35,7 @@ blockName = letterOrUnderscore (letter | digit | "_" | "." | "-")* ``` Audit policy files use the `*.audit.gwdk` suffix and a separate top-level -grammar: +grammar. They use the same 1 MiB logical-line limit: ```text auditFile = (blank | comment | packageDecl | policyDecl | testDecl)* @@ -103,10 +110,10 @@ generated app Go files. Old `act name { ... }` and `api name { ... }` forms are rejected with migration diagnostics. -It validates first-slice action fragment targets, captures their body text, and -the generated embedded app can serve the first rendered action fragment response -for partial POSTs. It does not validate broader statement syntax, full markup -syntax, expressions, or most block body contents. +It validates first-slice standalone fragment targets and captures their +source-owned body text for generated render functions and fallback responses. +It does not validate broader statement syntax, full markup syntax, expressions, +or most block body contents. The canonical AST, recovery, and semantic-analysis model lives in the language docs in this directory; implementation remains incremental. diff --git a/docs/language/hybrid.md b/docs/language/hybrid.md index e2fd44bf..5b45b1fa 100644 --- a/docs/language/hybrid.md +++ b/docs/language/hybrid.md @@ -42,8 +42,9 @@ Hybrid refresh is explicit: - Actions decide their own redirect, fragment, JSON, or reload result. - Actions do not implicitly rerun page `server {}` data. -- Fragments own their own request-time data and return no-store fragment - responses. +- Fragment Go hooks own request-time data and response decisions, `.gwdk` + declarations own fragment markup, and generated fragment responses are + `no-store`. - `g:command` with a bound `g:query` region can return single-flight patches to the command caller. - Realtime query invalidation can use `/_gowdk/realtime/query-refresh` for diff --git a/docs/language/partials.md b/docs/language/partials.md index 4e3fca16..061821a0 100644 --- a/docs/language/partials.md +++ b/docs/language/partials.md @@ -1,8 +1,10 @@ # Partials -Partial updates use server fragments, not full-page SSR. The generated slice -supports action-driven fragment responses for SPA pages and standalone concrete -or dynamic fragment routes. +Partial updates use server fragments, not full-page SSR. GOWDK source owns the +fragment markup; Go handlers and fragment hooks own request-time data, +application behavior, and response decisions. The generated slice supports +action-driven fragment responses for SPA pages and standalone concrete or +dynamic fragment routes. Current support: @@ -16,13 +18,15 @@ Current support: a page uses partial form metadata with a fragment-producing action. - `g:target` must reference a SPA `id` in the same direct `view {}` markup subset. -- Action bodies parse `fragment "#id" { ... }` metadata and capture the raw - fragment body for generated render functions and first-slice generated action +- Standalone `fragment Name GET "/path" "#target" { ... }` declarations capture + source-owned markup for generated fragment render functions and fallback responses. - Runtime package boundaries exist for partial responses and swaps. - `runtime/partial` exposes server fragment helpers. The underlying `runtime/response` envelope carries target and swap metadata through `X-GOWDK-Fragment-Target` and `X-GOWDK-Fragment-Swap` when written to HTTP. + Helpers that also accept an HTML body are low-level compatibility APIs; + application markup should remain in `.gwdk`. - Page files can declare standalone fragment endpoints: ```gwdk @@ -42,15 +46,17 @@ Current support: types are `string`, `int`, `int64`, `uint`, `uint64`, `bool`, and `float64`. - If the same package exports a function with the fragment name and signature `func(context.Context) (response.Response, error)`, generated apps call that - user-owned hook at request time. The hook owns data loading, validation, - redirects, HTML, JSON, and fragment response decisions through - `runtime/response.Response`. `runtime/app.Request(ctx)` exposes the current + user-owned hook at request time. The hook owns data loading, validation, and + response decisions through `runtime/response.Response`; the `.gwdk` + declaration owns markup. `runtime/app.Request(ctx)` exposes the current request, `runtime/app.Params(ctx)` exposes raw dynamic route params, and `runtime/app.TypedParams(ctx)` exposes decoded typed route params. Generated typed fragment bindings return `400` for invalid scalar params and `404` for - missing params before guards or fragment hooks run. If no function with the - fragment name exists, the generated handler serves the static rendered - fragment body. + missing params before guards or fragment hooks run. The current low-level + hook contract can replace the declared body with a custom response body for + compatibility, but generated typed data binding into fragment markup remains + planned. If no function with the fragment name exists, the generated handler + serves the static rendered fragment body. - Generated embedded app action handlers can respond to `X-GOWDK-Partial` requests with rendered fragment HTML, `Cache-Control: no-store`, and fragment target metadata. Normal POST requests still use the redirect/no-content @@ -82,18 +88,19 @@ Current support: ## Examples -`examples/endpoints/src/endpoints/fragments.page.gwdk` demonstrates inline validation, table -row update, list refresh, modal body update, dashboard card refresh, standalone -fragment declarations, and action handlers that return explicit fragment -responses from normal Go. +`examples/endpoints/src/endpoints/fragments.page.gwdk` demonstrates inline +validation, table row update, list refresh, modal body update, dashboard card +refresh, and source-owned standalone fragment declarations. Its Go hooks and +external templates also exercise the current low-level custom-body +compatibility path; new application markup should stay in `.gwdk`. ## Swap Modes The current swap modes are: -- `innerHTML`: replace the target element children with the returned fragment +- `innerHTML`: replace the target element children with the rendered fragment HTML. The target element itself remains in place. -- `outerHTML`: replace the target element itself with the returned fragment +- `outerHTML`: replace the target element itself with the rendered fragment HTML. Build output records these values as `data-gowdk-swap` metadata and runtime diff --git a/docs/product/hybrid-lifecycle-spec.md b/docs/product/hybrid-lifecycle-spec.md index c6021138..59358aa3 100644 --- a/docs/product/hybrid-lifecycle-spec.md +++ b/docs/product/hybrid-lifecycle-spec.md @@ -46,8 +46,9 @@ Hybrid refresh is explicit: - Action handlers decide their own redirect, fragment, JSON, or reload result. Actions do not implicitly rerun page `server {}` data. -- Standalone and action-returned fragments own their own request-time data and - return no-store fragment responses. +- Fragment Go hooks own request-time data and response decisions, `.gwdk` + declarations own fragment markup, and generated fragment responses are + `no-store`. - `g:command` plus bound `g:query` regions can return single-flight patches for the command caller. - Query invalidation can use `/_gowdk/realtime/query-refresh` for eligible diff --git a/docs/product/language-server.md b/docs/product/language-server.md index c2f327bd..2a24fcfa 100644 --- a/docs/product/language-server.md +++ b/docs/product/language-server.md @@ -42,13 +42,17 @@ Developers editing `.gwdk` files need live feedback from the same language tooli buffers when a workspace root can be found. - Return whole-document formatting edits using `gowdk fmt` behavior. - Return keyword completions for metadata declarations, render modes, blocks, and `g:` directives. -- Return project completions for open-document components, layouts, guards, - routes, page IDs, stores, local component props, and inferred component state - or value fields. +- Return component completions and definitions from the configured workspace + source set, with dirty and unsaved matching documents overlaid on disk + sources. Honor root/module includes, excludes, default excludes, and the + configured output exclusion. +- Return project completions for open-document layouts, guards, routes, page + IDs, stores, local component props, and inferred component state or value + fields. - Return hover text for known metadata declarations, directives, blocks, routes, stores, props, components, layouts, guards, and handler symbols from open documents. - Return go-to-definition locations for same-package and `use`-qualified - component calls from open documents. + component calls from configured disk sources and matching open documents. - Return go-to-definition locations for exported Go handler symbols when the matching Go file is open in the editor session. - Return references for exact `.gwdk` project symbols across open documents, @@ -82,10 +86,12 @@ Developers editing `.gwdk` files need live feedback from the same language tooli on the directive source range. - [x] `textDocument/formatting` returns a replacement edit matching `gowdk fmt`. - [x] `textDocument/completion` returns the same language keywords exposed by editor tooling. -- [x] `textDocument/completion` returns open-project symbols for components, - layouts, guards, routes, stores, props, and component state/value fields. +- [x] `textDocument/completion` returns configured workspace components plus + open-project layouts, guards, routes, stores, props, and component + state/value fields. - [x] `textDocument/hover` returns concise markdown help for language tokens and open-project symbols. -- [x] `textDocument/definition` returns component declaration locations for open-project component calls. +- [x] `textDocument/definition` returns component declaration locations from + configured workspace sources and matching unsaved overlays. - [x] `textDocument/definition` returns open-buffer Go declaration locations for exported handler symbols. - [x] `textDocument/references` returns open-document references for page IDs, routes, components, stores, and guards. - [x] `textDocument/codeAction` returns quick fixes for old endpoint syntax and missing GOWDK use aliases. @@ -103,6 +109,9 @@ Developers editing `.gwdk` files need live feedback from the same language tooli - Closing a document should clear diagnostics for that URI. - Unknown LSP requests should return a method-not-found error. - Notifications without params should be ignored when safe. +- Excluded sources, generated output, and unselected modules must not enter the + component index even when their files are open. Unsaved files whose paths + match the active selection remain available. ## Dependencies diff --git a/docs/product/requirements.md b/docs/product/requirements.md index 9c5eb3ec..e146c7fe 100644 --- a/docs/product/requirements.md +++ b/docs/product/requirements.md @@ -55,7 +55,7 @@ language references, compiler docs, and examples. | PRD-027 | Provide opt-in browser presentation-event fanout without adding WebSocket dependencies to the root module. | Medium | Implemented | `FeatureRealtime` and `addons/realtime` provide config and `gowdk add realtime` wiring for presentation-event fanout. Dependency-free SSE fanout remains in the root module through `runtime/contracts/sse` and `realtime.NewSSE`; WebSocket fanout remains isolated in the nested `runtime/contracts/websocketfanout` module. Docs cover SSE versus WebSocket setup, deployment caveats, and the M14 boundary for live DOM reactivity. | | PRD-028 | Provide compiler-validated realtime UI subscription metadata. | Medium | Partial | ADR 0012 defines `g:subscribe` on query-owned elements. The compiler parses the directive, lowers it to `Program.RealtimeSubscriptions`, requires `realtime.Addon()`, validates referenced Go contracts as presentation events available to the web role, emits exact-span diagnostics, renders `data-gowdk-subscribe` and validated `data-gowdk-subscribe-type` markers, records build-report metadata, generated apps mount subscription-filtered SSE fanout at `/_gowdk/realtime/events` for bound subscriptions, generated stream handlers run inherited guards before opening SSE responses, the SSE adapter declares configurable browser retry timing, keeps an optional bounded replay window keyed by SSE event IDs, can revoke connected clients by server-owned audience label, exposes process-local stats, and drops events for full per-client buffers instead of blocking command execution. Generated `gowdk.js` applies explicit version-1 `replaceHTML` realtime patches to subscribed query regions, and `examples/contracts` demonstrates the live flow. The compiler also scans explicit Go `RegisterInvalidation[event, query]` edges, lowers validated bound edges to `Program.QueryInvalidations`, rejects unknown queries/events or events no scanned command emits, records `query_invalidation` build-report events, prints `invalidates` graph edges, renders `data-gowdk-query-type` markers, emits generated `gowdk.query.invalidate` presentation events after command event dispatch, and uses a generated route/query refresh endpoint before falling back to current-document refetch for matching non-subscribed query regions. Fragment/API-specific query execution is defined as fallback-only until explicit renderer metadata exists. Richer patch shapes, richer fragment/API renderers, durable replay, and production telemetry export remain planned hardening work. | | PRD-029 | Provide optional SEO build output for sitemap, robots, and structured page metadata without making crawler policy core. | Medium | Partial | `addons/seo` registers `FeatureSEO` and `gowdk.SEOProvider`; `gowdk build` emits `sitemap.xml` and `robots.txt` only when the addon supplies a valid `BaseURL`. The sitemap includes public static and `paths {}`-expanded SPA routes plus configured extra URLs, while request-time, `noindex`, and guardless default-denied pages are excluded and listed in `gowdk-build-report.json`. Pages can declare supported `jsonld` kinds for generated JSON-LD, and generated apps can serve `/sitemap.xml` with an app-owned dynamic provider. Broader schema kinds and crawler operations remain planned or app-owned. | -| PRD-030 | Provide dependency-free runtime trace primitives and opt-in generated app instrumentation. | Medium | Partial | ADR 0013 defines `runtime/trace` as the root-module observability core. It provides W3C-compatible trace/span IDs, `traceparent`/`tracestate` propagation with bounded header parsing, context spans, GOWDK surface/lane/source metadata, attributes/events/status, always-on/off and ratio sampling, console/JSONL/ring/multi/exporter sinks, a bounded JSON/SSE collector, hardened browser span ingest, and a self-contained local viewer with dropped/rejected counters. `addons/observability` gates debug-only generated route/guard/handler/SSR-load/browser/island tracing, `runtime/contracts` propagates trace context through events/jobs/workers/outbox records, and the nested `runtime/trace/otel` module provides optional OTLP HTTP export without root OpenTelemetry dependencies. Durable production storage, hosted analysis, and production sampling/access policy remain app-owned. | +| PRD-030 | Provide dependency-free runtime trace primitives and opt-in generated app instrumentation. | Medium | Partial | ADR 0013 defines `runtime/trace` as the root-module observability core. It provides W3C-compatible trace/span IDs, `traceparent`/`tracestate` propagation with bounded header parsing, context spans, GOWDK surface/lane/source metadata, attributes/events/status, always-on/off and ratio sampling, console/JSONL/ring/multi/exporter sinks, a bounded single-worker FIFO export queue with drop-newest overflow, timeout/drop health counters and a drain hook, a bounded JSON/SSE collector, hardened browser span ingest, and a self-contained local viewer with dropped/rejected counters. `addons/observability` gates debug-only generated route/guard/handler/SSR-load/browser/island tracing, `runtime/contracts` propagates trace context through events/jobs/workers/outbox records, and the nested `runtime/trace/otel` module provides optional OTLP HTTP export without root OpenTelemetry dependencies. Durable production storage, hosted analysis, and production sampling/access policy remain app-owned. | | PRD-031 | Provide a config-owned localization contract for generated page routes and typed message catalogs. | Medium | Partial | `Config.I18N` declares locale codes, optional path prefixes, default locale, and default-prefix omission. Build-time SPA routes, dynamic `paths {}` output, request-time SSR/hybrid page routes, route metadata, site-map JSON, route manifests, and SEO sitemap output expand per locale. Build helpers receive `gowdk.BuildParams.Locale`, generated HTML receives `lang`, SSR handlers attach `runtime/app.Locale(ctx)`, and `runtime/i18n` provides typed Go catalog/bundle helpers, deterministic catalog completeness reports/templates, and bounded plural, number, date, and time formatting helpers. Compiler-owned `.gwdk` message extraction, ICU/CLDR completeness, translated diagnostics, and generated per-endpoint locale policies remain planned or app-owned. | ## P0/P1/P2 Decision Backlog @@ -82,14 +82,14 @@ implemented. | Forms | Keep progressive-enhancement-first form behavior; full POST and enhanced POST share action result semantics; domain validation stays in user Go. | Partial | Generated enhanced forms preserve no-JavaScript POST behavior, send partial request headers, swap server fragments, expose failed enhanced response status/body/detail events, and use escaped live-region validation fragments. Domain validation stays in user Go. | | APIs | Broaden APIs through public request/response helpers and typed body/query helpers, not framework-specific adapters. | Partial — `runtime/api` provides strict JSON body decoding, typed query helpers, typed result status selection, JSON/error/no-content response helpers for raw `func(context.Context, *http.Request) (response.Response, error)` handlers; `addons/api` enables generated API support. Generated typed API handler signatures, strict generated query/JSON input decoding, typed JSON result adapters, OpenAPI request/response schemas from the same metadata, config-level CORS policy for generated API/command/query routes, and endpoint-local `.gwdk` API CORS clauses that inherit/override config defaults with credentialed wildcard safety validation are implemented. Route-param/header input contracts, custom typed content negotiation, and richer examples remain planned. | | Contract runtime | Add typed Go queries, commands, backend-owned domain/integration events, presentation events, and jobs after endpoint/adapter IR is stable. Frontend UI events trigger commands or queries, commands have one owner, domain events are emitted after backend state changes succeed, local in-process dispatch is default, and broker/outbox/worker roles are optional. Runtime registry, role filtering, event capture/replay, outbox/broker/fanout/EventSource/seen-store interfaces, worker ack/nack/backoff, file/in-memory/Redis/NATS adapters, SSE/WebSocket fanout, presentation-event audience labels, audience-scoped dependency-free SSE delivery, generated command event sinks, generated registries, generated worker replay helpers, generated standalone worker/cron role binaries, Go AST scanning, `go/types` diagnostics, duplicate-owner and emitted-event diagnostics, contract/list/graph/trace CLI, `g:command`/`g:query` metadata, query-bounded `g:subscribe`, explicit `RegisterInvalidation[event, query]` metadata, import-path-aware reference/subscription/invalidation linking, `g:event` rejection, IR binding status, app adapter IR, generated web command/query adapters, page-route query JSON negotiation, stable JSON success/error response shape, formatted generated adapter source, page-guard propagation, rate-limit/guard/CSRF ordering, report metadata, enforced scan diagnostics, generated subscription-filtered guarded SSE fanout, generated client `replaceHTML` patches, generated `gowdk.query.invalidate` events, configurable SSE retry/replay/revocation, and route/query refresh endpoint fallback for matching non-subscribed invalidated query regions are implemented. Fragment/API-specific query execution currently has fallback-only behavior; richer fragment/API renderers, remaining exact diagnostic spans, richer scheduler policies, richer realtime patch shapes, durable retry operations, and editor-first visualizations remain planned outside the milestone-14 runtime contract. | Implemented | -| Observability | Keep root tracing dependency-free while making generated instrumentation opt-in and debug-gated. | Partial — `runtime/trace` provides W3C-compatible IDs, bounded `traceparent`/`tracestate` propagation, context spans, GOWDK surface/lane/source metadata, span attributes/events/status, always-on/off and ratio sampling, console/JSONL/ring/multi/exporter sinks, OTLP-shaped snapshots, slog trace/span helpers, tracer export health, bounded JSON/SSE local collection, collector health, hardened browser ingest, and a self-contained viewer. `runtime/app.Metrics` records dependency-free request counters, active requests, errors, latency, and generated backend route metrics keyed by route templates and endpoint IDs. `addons/observability` enables debug-only generated backend, SSR/load, frontend, and island tracing; contracts/outbox records carry optional trace context; `runtime/trace/otel` isolates OTLP HTTP export in a nested module. Durable storage, hosted analysis, production metrics/log backends, alerting, retention, and production sampling/access policy remain app-owned or future work. | +| Observability | Keep root tracing dependency-free while making generated instrumentation opt-in and debug-gated. | Partial — `runtime/trace` provides W3C-compatible IDs, bounded `traceparent`/`tracestate` propagation, context spans, GOWDK surface/lane/source metadata, span attributes/events/status, always-on/off and ratio sampling, console/JSONL/ring/multi/exporter sinks, OTLP-shaped snapshots, slog trace/span helpers, a bounded single-worker export queue with explicit overflow, timeout/drop health, and `Flush`, bounded JSON/SSE local collection, collector health, hardened browser ingest, and a self-contained viewer. `runtime/app.Metrics` records dependency-free request counters, active requests, errors, latency, and generated backend route metrics keyed by route templates and endpoint IDs. `addons/observability` enables debug-only generated backend, SSR/load, frontend, and island tracing; contracts/outbox records carry optional trace context; `runtime/trace/otel` isolates OTLP HTTP export in a nested module. Durable storage, hosted analysis, production metrics/log backends, alerting, retention, and production sampling/access policy remain app-owned or future work. | | Cache | Keep `cache` and `revalidate` as HTTP cache policy; keep action-driven data refresh explicit through redirects, fragments, JSON, or reload responses. | Implemented for current generated responses — route reports include route/endpoint cache metadata, build reports summarize generated cache policies, generated binaries apply immutable asset cache, SPA `no-cache`, request-time `no-store`, and page `cache`/`revalidate` for successful SPA, SSR, and hybrid HTML. | | Guards | Extend guards with safe local redirects and response helpers before richer request-local state. | Partial — guards keep the `func(runtime/guard.Context) error` signature. Ordinary errors fail closed with 403, while `runtime/guard.RedirectTo`, `runtime/guard.Redirect`, and `runtime/guard.Respond` intentionally write no-store redirects or custom responses. Richer request-local state is still deferred. | | Component CSS | Make component CSS explicit, compiler-scoped, and documented; Tailwind and processors remain optional. | Partial | | Accessibility | Add accessibility diagnostics as compiler warnings with stable codes and spans. | Partial — `missing_img_alt`, `missing_form_label`, `empty_link_text`, `missing_button_type`, and `heading_order_skip` warn on literal view markup in pages, components, and layouts. Broader ARIA and full WCAG rule coverage remain outside the current compiler slice. | | Diagnostics and LSP | Expand diagnostic catalogue before broad parser recovery; prioritize hover, semantic tokens, go-to-definition, and route/type navigation. | Partial — the diagnostic registry, `gowdk explain`, JSON check output, safe fix metadata, exact ranges for high-value parser/IR-backed diagnostics, LSP diagnostics/formatting/completions/hover/definitions/references/code actions/semantic tokens, dirty-buffer `g:command`/`g:query` binding diagnostics, CLI route/sitemap/inspect reports, and [diagnostics/navigation contract](diagnostics-and-navigation.md) exist; parser recovery, remaining aggregate/addon exact-span gaps, direct markup-family emitted codes, and workspace route/type navigation remain planned. | | Testing and scaffolding | Add optional Go handler tests, generated app smoke tests, template/addon selection, and editable generated examples. | Partial — `gowdk init --tests` writes a starter `go.mod` and non-skipping generated app smoke test, `gowdk test` builds temporary generated output/app/binary artifacts and runs ordinary Go tests with `GOWDK_TEST_*` context, `runtime/testkit` provides HTTP scenario helpers, cookie-preserving clients, response assertions, and in-memory contract registry/event assertions, `examples/contracts/patients/contracts_test.go` demonstrates command event capture, `gowdk audit --run` builds a temporary generated app and runs generated runtime audit tests, and explicit repository scripts/tests cover parser fuzz smoke, generated-app integration, generated-output/report determinism, incremental-versus-clean SPA output equivalence, and focused runtime race detection. Broader IR-generated endpoint test files, generated app equivalence coverage, and first-class browser/E2E scaffolds remain planned. | -| Deployment and operations | Prefer docs and optional generators for static hosts, Docker, systemd, reverse proxies, CDN policy, health checks, metrics, logging, binary deploy, rollback, and CSRF secret rotation. | Partial — [deployment.md](../reference/deployment.md) documents static output, one-binary, generated Docker contexts, split frontend/backend, backend-only, Docker, systemd, reverse proxy, CDN/cache, health, metrics, logging, readiness, graceful shutdown, artifact layout, rollback, CSRF secret rotation, backup ownership, and incident boundaries. `gowdk build --docker` emits a minimal non-root Dockerfile and `.dockerignore` beside a compiled one-binary artifact. `gowdk build --deploy-recipe` and `Build.Targets[].DeployRecipes` emit optional static-host, systemd, Caddy, Nginx, and split frontend/backend starting points without owning secrets, domains, TLS, CDN policy, storage, backups, incident response, or rollout logic. Release workflows pin third-party action SHAs, extension publishing uses locked local `vsce`, `govulncheck` is pinned through `tools/govulncheck`, and docs distinguish convenience and high-assurance install paths. | +| Deployment and operations | Prefer docs and optional generators for static hosts, Docker, systemd, reverse proxies, CDN policy, health checks, metrics, logging, binary deploy, rollback, and CSRF secret rotation. | Partial — [deployment.md](../reference/deployment.md) documents static output, one-binary, generated Docker contexts, split frontend/backend, backend-only, Docker, systemd, reverse proxy, CDN/cache, health, metrics, logging, readiness, graceful shutdown, artifact layout, rollback, CSRF secret rotation, backup ownership, and incident boundaries. Generated apps support rollback-safe three-phase CSRF rotation with primary and verification-only keys and refresh old-key cookies on page delivery. `gowdk build --docker` emits a minimal non-root Dockerfile and `.dockerignore` beside a compiled one-binary artifact. `gowdk build --deploy-recipe` and `Build.Targets[].DeployRecipes` emit optional static-host, systemd, Caddy, Nginx, and split frontend/backend starting points without owning secrets, domains, TLS, CDN policy, storage, backups, incident response, or rollout logic. Release workflows pin third-party action SHAs, extension publishing uses locked local `vsce`, `govulncheck` is pinned through `tools/govulncheck`, and docs distinguish convenience and high-assurance install paths. | | Full-page hydration | Keep full-page hydration out of the repository core; use static pages, progressive enhancement, server fragments, and explicit islands. | Intentionally out of scope | | Island ergonomics | Improve compiler-owned island syntax, lifecycle cleanup, focus helpers, local batching, and diagnostics without exposing arbitrary JavaScript as the app contract. | Partial — generated JS islands support idempotent mount/remount, cleanup, lifecycle/effect blocks, bounded refs such as focus/blur/scroll, local batching, and diagnostics; broader HMR-style ergonomics remain deferred. | | Client builtins | Add deterministic formatting, collection, async-safe UI, focus, and selection helpers only with generated-output tests. | Partial — scalar expression helpers, list mutation built-ins, `fetchJSON`, and safe DOM ref methods are implemented in the bounded client language; broader formatting, selection, and date/time helpers remain deferred. | diff --git a/docs/reference/addons.md b/docs/reference/addons.md index 7eb98efa..107ce72c 100644 --- a/docs/reference/addons.md +++ b/docs/reference/addons.md @@ -14,16 +14,22 @@ application runtime services are wired through generated app hooks or `Config.Lifecycle.Services`. Project-aware `build`, `check`, and `dev` run importable `gowdk.config.go` -packages through the generated native helper. The helper imports the project -config package, reads `var Config`, and keeps real `gowdk.Addon` values and -supported extension interfaces in the same process as the compiler operation. +packages through the generated native-helper binary. That binary imports the +project config and executes the compiler command in the same process, so its +real addon values do not cross JSON. Separately, `LoadConfigFile` can evaluate a +dynamic config through a short-lived executable helper; that fallback sends +each addon across JSON as an explicit capability descriptor. Its receiving +proxy remains a plain `gowdk.Addon`. Compiler and generator consumers use +`gowdk.ResolveAddonCapabilities` for both direct interfaces and bridged +descriptors. ## Addon Lifecycle -An addon participates in up to four ordered phases. Each phase corresponds to a -specific interface, and an addon implements only the interfaces for the phases -it needs. The base `gowdk.Addon` (`Name()`, `Features()`) only declares a name -and feature IDs; the rest are opt-in extension points. +An addon participates in up to four ordered phases. In-process addons implement +only the interfaces for the phases they need. Executable config loading +round-trips the same supported phases through `gowdk.AddonCapabilities`. The +base `gowdk.Addon` (`Name()`, `Features()`) only declares a name and feature IDs; +the rest are opt-in extension points. 1. **Config loading** — `gowdk.Addon`. Project-aware commands execute `gowdk.config.go` as normal Go through the native helper and read @@ -64,6 +70,30 @@ The registry records this in each entry's `publicInterfaces`: A single addon can span categories (for example `addons/css` implements both `gowdk.Addon` and `gowdk.CSSProcessor`). +### Executable capability descriptors + +The executable-config wire uses stable names for the supported optional +capabilities: + +- `gowdk.css-processor` +- `gowdk.go-block-consumer` +- `gowdk.seo-provider` +- `gowdk.auth-session-provider` + +Each wire capability declares whether it is required. An unknown optional +capability is ignored so an older compiler can still use the addon as a feature +marker. An unknown required capability stops config loading with the addon and +capability names in the error; silently dropping required behavior is not +allowed. + +`gowdk.ResolveAddonCapabilities(addon)` is the common lookup API. It reads an +explicit `gowdk.AddonCapabilityProvider` descriptor when present and otherwise +falls back to the optional interfaces implemented directly by ordinary +in-process addons. An explicit descriptor is authoritative; direct methods do +not fill omitted fields. Code consuming addons should use this resolver so +combined capabilities such as auth plus CSS or auth plus Go-block handling are +preserved exactly. + ## Boundary Rules - `addons/.Addon()` belongs in `gowdk.config.go` and declares features or @@ -109,6 +139,9 @@ Addons declare what they target two ways: the tool is absent; GOWDK does not download it. - **Missing feature addon** — using a capability without enabling its addon is a compiler diagnostic, not a silent no-op. +- **Unsupported required executable capability** — config loading fails with + the addon and stable capability names. Unknown optional capabilities are + ignored. - **Version-incompatible addon** — `SupportsVersion` returns `VersionUnsupported` for a CLI version outside an entry's `minGOWDK`/`maxGOWDK`. Tooling can surface this; build-time auto-enforcement of the version bound remains a deliberate diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 98a85519..cfba54c2 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -37,7 +37,7 @@ gowdk serve --dir [--addr ] gowdk playground policy [--json] gowdk playground export --dir --out [--json] gowdk playground run --dir --out --allow-hosted-execution (--module-cache | --allow-shared-module-cache) -gowdk lsp [--config ] [--project-root ] [--ssr] +gowdk lsp [--config ] [--project-root ] [--module ] [--ssr] ``` ## Flags @@ -191,7 +191,7 @@ gowdk lsp [--config ] [--project-root ] [--ssr] optimization/hardening option, not a security boundary. - `--target`: supported by `build`, `test`, and `clean`; may be repeated or comma-separated. For `build` it runs the selected `Build.Targets` entries; for `test` it selects the target module set while writing artifacts to a temporary workdir; for `clean` it restricts removal to the selected targets' outputs. - `--module`: supported by `check`, `doctor`, `test`, `audit`, `manifest`, `sitemap`, `routes`, - `endpoints`, `inspect`, `generate stubs`, and `build`; may be repeated or + `endpoints`, `inspect`, `generate stubs`, `lsp`, and `build`; may be repeated or comma-separated, and limits discovery to selected configured modules when no explicit file list is passed. - `--out`: supported by `build` and `clean`; for `build` it selects the output directory and overrides `Build.Output`, and for `clean` it adds an extra output directory to remove alongside the configured outputs. `clean --target` refuses to remove a selected target root that contains artifacts owned by an unselected configured target. `playground export` uses `--out` as the archive path. `playground run` uses `--out` as the generated output directory and never writes build output into the source project. @@ -334,10 +334,11 @@ config requirement. If no files are passed, commands discover configured root loaded config does not declare source includes. `--module` limits discovery to selected configured modules and skips root `Source.Include`; explicit file paths still bypass discovery. A module with a name and no explicit include uses -`/**/*.gwdk`. Discovery excludes `.git`, `vendor`, `node_modules`, -`testdata`, root/module `Source.Exclude` globs, and the configured build output -directory when one exists. `build --out` overrides `Build.Output`; one of them -is required for `build`. Every successful disk build writes +`/**/*.gwdk`. Discovery excludes `.git`, `.gowdk`, `bin`, `dist`, +`gowdk_cache`, `vendor`, `node_modules`, `testdata`, root/module +`Source.Exclude` globs, configured target app/output directories, and the active +build output directory. `build --out` overrides `Build.Output`; one of them is +required for `build`. Every successful disk build writes `gowdk-build-report.json` to the output root. The report includes validation, planning, write, manifest, cache-policy, cleanup, and completion events; request-time SSR/hybrid pages that are intentionally skipped from static diff --git a/docs/reference/config.md b/docs/reference/config.md index 50f34b7e..aceca2ca 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -30,7 +30,9 @@ directory passed with `--project-root `, or load the exact file passed with `internal/discover`, and `gowdk build` reads `Source.Include` and `Source.Exclude` fields from the loaded config when no explicit files are supplied. Explicit file paths still require a loaded config and must stay under -the selected project root. +the selected project root. Automatic discovery prunes GOWDK-generated +directories (`.gowdk`, `gowdk_cache`, configured target apps/outputs) and the +conventional `bin` and `dist` output roots. `Modules` declares named source groups. Build discovery treats modules as source selectors. Generated app and binary composition is controlled by the @@ -407,6 +409,10 @@ patterns override that default for the module. Root `Source.Exclude` and module `Source.Exclude` patterns are both honored. `gowdk build --module ` limits discovery to selected configured modules. +`gowdk lsp` uses the same source selection for workspace component completion +and definition lookup. Pass `--module ` to scope an editor session; dirty +or unsaved documents are indexed only when their paths match that selection. + The selected modules define what gets compiled into the build output and, when `--app` or `--bin` is used, what is copied into the generated app and embedded in the generated binary. Ad hoc CLI flags can still package modules directly: @@ -496,6 +502,13 @@ present. Process environment values always win over file values. The file is only a value source for the same validation contract; it does not bypass `Required` or `MinBytes`. +Environment files accept `NAME=value` assignment lines up to 1 MiB (1,048,576 +bytes), excluding the line ending; multiline continuation is not supported. +Larger lines fail with `env_file_line_too_long` and include the file and line +without printing the value. Use process-environment or deployment secret +injection for larger values, which may also exceed operating-system environment +limits. + The same name cannot appear in both `Vars` and `Secrets`. Secret-looking var names ending in `_SECRET`, `_TOKEN`, `_PASSWORD`, or `_KEY` are rejected and must move to `Secrets`. Diagnostics print names only and never print values. @@ -550,13 +563,14 @@ type HeadConfig struct { } type CSRFConfig struct { - Enabled bool - Disabled bool - SecretEnv string - CookieName string - FieldName string - HeaderName string - Insecure bool + Enabled bool + Disabled bool + SecretEnv string + VerificationSecretEnvs []string + CookieName string + FieldName string + HeaderName string + Insecure bool } type SecurityHeadersConfig struct { @@ -665,6 +679,23 @@ flag, uses the default cookie name `gowdk-csrf` instead of `__Host-gowdk-csrf`, and rejects explicit `__Host-`/`__Secure-` cookie names because browsers require those prefixes to be Secure. +`VerificationSecretEnvs` provides verification-only keys for rolling rotation. +New tokens are always signed with `SecretEnv`; tokens signed by any configured +verification key remain valid, and serving a page replaces an old-key cookie +with a primary-key token. + +```go +CSRF: gowdk.CSRFConfig{ + SecretEnv: "GOWDK_CSRF_PRIMARY", + VerificationSecretEnvs: []string{"GOWDK_CSRF_OVERLAP"}, +} +``` + +Every configured secret is required at generated-app startup and must contain +at least 32 bytes. Environment variable names must be non-empty and unique. +See [CSRF Secret Rotation](deployment.md#csrf-secret-rotation) for the +three-phase deployment procedure. + `SecurityHeaders` controls additional headers written by generated app handlers. When `Enabled` is true, each entry in `Headers` is passed to `runtime/app` and emitted on every generated response path, including health diff --git a/docs/reference/contracts.md b/docs/reference/contracts.md index dab47924..6b0f8a06 100644 --- a/docs/reference/contracts.md +++ b/docs/reference/contracts.md @@ -948,7 +948,9 @@ Current behavior: - Page-owned query routes share the page path, so generated apps dispatch them only for explicit query requests: `Accept: application/json`, another `+json` media type, or `X-GOWDK-Query: true`. Normal document requests keep - serving the page HTML at the same route. + serving the page HTML at the same route. JSON media ranges with `q=0` or an + invalid quality value do not select the query route; malformed media ranges + are ignored. `X-GOWDK-Query: true` remains an explicit override. - When the scanner can see the exported query input struct fields, generated adapters decode supported URL query parameters into the typed query input. - Query references on guarded pages inherit the page guards. When rate limiting diff --git a/docs/reference/deployment.md b/docs/reference/deployment.md index fd08ab8d..6e89f072 100644 --- a/docs/reference/deployment.md +++ b/docs/reference/deployment.md @@ -269,24 +269,36 @@ dotenv file; host environment values still take precedence over file values. ## CSRF Secret Rotation -Generated CSRF currently validates tokens with one active signing secret from -`Build.CSRF.SecretEnv` or `GOWDK_CSRF_SECRET`. CSRF is enabled by default for -generated action and web-command POSTs; generated apps fail closed at startup if -those endpoints are present and the secret is absent. There is no multi-key -grace period yet. - -Rotate CSRF secrets as a coordinated deploy: - -1. Build and smoke-test the new binary. -2. Set the new secret in the deployment platform. -3. Restart or replace every generated app instance that serves action POSTs. -4. Confirm `/_gowdk/health` is reachable on every instance. -5. Expect forms rendered before the rotation to fail with HTTP 403 - `invalid csrf token`; users should reload the page and resubmit. - -Do not run mixed old/new CSRF secrets behind the same load balancer for longer -than the deploy window. If a rollback is needed, restore both the previous -binary and the previous CSRF secret. +Generated CSRF signs new tokens with the primary secret from +`Build.CSRF.SecretEnv` or `GOWDK_CSRF_SECRET`. It can also validate tokens with +verification-only secrets listed in `Build.CSRF.VerificationSecretEnvs`. +Generated apps fail closed at startup if any configured value is absent or +shorter than 32 bytes. + +Use three deployment phases to rotate from `GOWDK_CSRF_OLD` to +`GOWDK_CSRF_NEXT`: + +1. Deploy old as primary and next as verification-only. Old and updated + instances accept each other's tokens during a mixed rollout. +2. Deploy next as primary and old as verification-only. New pages and refreshed + old-key cookies receive next-key tokens. Rolling back to phase 1 remains safe. +3. After the chosen overlap window, deploy next as primary with no old + verification key. + +Example phase-2 config: + +```go +CSRF: gowdk.CSRFConfig{ + SecretEnv: "GOWDK_CSRF_NEXT", + VerificationSecretEnvs: []string{"GOWDK_CSRF_OLD"}, +} +``` + +Keep both environment values available throughout phases 1 and 2, and verify +`/_gowdk/health` after each rollout. CSRF tokens do not carry an expiry, so the +deployment operator chooses the overlap window. Once the old key is retired, +an unrefreshed form holding an old token receives HTTP 403 +`invalid csrf token`; reloading the page obtains a current token. ## systemd diff --git a/docs/reference/diagnostic-codes.md b/docs/reference/diagnostic-codes.md index 2f735abf..6a958a60 100644 --- a/docs/reference/diagnostic-codes.md +++ b/docs/reference/diagnostic-codes.md @@ -105,9 +105,11 @@ Parser diagnostics emit stable codes for common unsupported syntax and keep `malformed_package_declaration`, `malformed_legacy_metadata`, `old_action_block_syntax`, `old_api_block_syntax`, `malformed_go_import`, `malformed_gowdk_use`, + `source_line_too_long`, `unsupported_literal_record_syntax`, `unsupported_top_level_block`, `unsupported_layout_metadata`, `invalid_component_prop`, `unsupported_component_prop_type`, `unterminated_string`. +- Environment files: `env_file_line_too_long`. - Packages and imports: `missing_package_declaration`, `package_mismatch`, `go_package_error`, `invalid_go_import`, `duplicate_go_import_alias`. - GOWDK source imports: `duplicate_gowdk_use_alias`, diff --git a/docs/reference/observability.md b/docs/reference/observability.md index f2a79424..85b4a4af 100644 --- a/docs/reference/observability.md +++ b/docs/reference/observability.md @@ -54,6 +54,8 @@ Current generated instrumentation: - `runtime/app.Metrics` records request count, active request count, latency, errors, and generated backend route metrics keyed by route templates and endpoint IDs. +- Metrics and tracing are independent: tracer-only requests still record spans, + metrics-only requests still record route counters, and either can be omitted. - Generated app health includes tracer export health when a tracer is attached, and the local collector JSON includes collector queue/reject health. @@ -133,10 +135,16 @@ if err != nil { } defer sink.Shutdown(ctx) -tracer := trace.NewTracer(trace.WithSink(sink)) +tracer := trace.NewTracer( + trace.WithSink(sink), + trace.WithExportQueueSize(256), + trace.WithExportTimeout(5*time.Second), +) ``` - For local collectors use `otel.WithInsecure()` instead of TLS. +- `tracer.Flush(ctx)` drains spans accepted by GOWDK's bounded tracer queue. + Call it before `sink.ForceFlush(ctx)` when both queues must reach the exporter. - `sink.ForceFlush(ctx)` drains buffered spans at a checkpoint (signal, pre-deploy) without shutting the provider down; `sink.Shutdown(ctx)` flushes and stops a GOWDK-owned provider. @@ -144,8 +152,12 @@ tracer := trace.NewTracer(trace.WithSink(sink)) OTLP path exposes `otel.ExporterFailureCount()` (export batches that failed after retries) and `otel.UnsupportedAttributeCount()` (attribute values outside the OTel value model). -- The bounded queue defines overflow behavior: when full, the batch processor - drops new spans rather than growing memory without bound. +- Two bounded queues can be present: the tracer defaults to 256 waiting spans + with one active sink call, while the OTel batch processor uses + `otel.WithMaxQueueSize`. Both drop new spans when full rather than growing + memory without bound. Tracer drops and timeouts appear in + `Tracer.HealthSnapshot`; OTel exporter failures remain available through the + bridge counters. ### GOWDK-owned vs app-owned diff --git a/docs/reference/routing.md b/docs/reference/routing.md index f6873d14..5019b4b9 100644 --- a/docs/reference/routing.md +++ b/docs/reference/routing.md @@ -335,6 +335,20 @@ method/path pair, validation fails with a route conflict diagnostic. For session, search, JSON CRUD, and webhook API examples, see `examples/endpoints/src/endpoints/api.page.gwdk`. +### Runtime Backend Route Patterns + +`runtime/app.BackendRouter` accepts concrete paths plus dynamic `{name}`, +typed `{name:type}`, and final rest `{name...}` segments. Type annotations +select generated decoding; they do not make the request-path match more +specific. + +For one HTTP method, capture names and type annotations do not distinguish +otherwise identical patterns. Registering `/blog/{slug}` and `/blog/{id}`, or +`/patients/{id:int}` and `/patients/{name:string}`, returns a duplicate-route +error. Rest capture names follow the same rule. Concrete routes take precedence +over dynamic matches regardless of registration order, so `/blog/archive` wins +over `/blog/{slug}` for that exact request path. + ## SSR Routes SSR is optional and must be enabled for validation: diff --git a/docs/reference/tracing.md b/docs/reference/tracing.md index e396faf0..7d363d1e 100644 --- a/docs/reference/tracing.md +++ b/docs/reference/tracing.md @@ -78,6 +78,29 @@ Current sinks: - `NewCollector(limit, options...)`: sink plus local JSON/SSE HTTP handler and browser span ingest. +Completed spans pass through a tracer-owned export budget before reaching any +sink: + +```go +tracer := gowdktrace.NewTracer( + gowdktrace.WithSink(sink), + gowdktrace.WithExportQueueSize(256), + gowdktrace.WithExportTimeout(5*time.Second), +) +``` + +The defaults are a 256-span waiting queue, one active sink export, and a +5-second deadline per export. `Span.End` never waits for the sink. When the +queue is full, the newest completed span is dropped; accepted spans remain +FIFO. `Tracer.HealthSnapshot` reports exported, failed, timed-out, and dropped +spans plus queue depth, capacity, and in-flight state. + +Use `tracer.Flush(ctx)` during graceful shutdown to wait for spans already +accepted by the tracer queue. It does not shut down or flush buffers owned by +the sink itself. Sink cancellation is cooperative: a sink that ignores its +context can pin the single worker, but the bounded queue prevents additional +export goroutines and memory growth. + ## Collector ```go diff --git a/editors/vscode/README.md b/editors/vscode/README.md index 11102e01..4344ae86 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -110,7 +110,9 @@ LSP-capable editors can launch: gowdk lsp ``` -Use `gowdk lsp --ssr` when editing projects that should validate SSR pages as if `ssr.Addon()` is enabled. +Use `gowdk lsp --ssr` when editing projects that should validate SSR pages as if +`ssr.Addon()` is enabled. Use repeatable or comma-separated `--module` flags to +limit component discovery to selected configured modules. ## Commands diff --git a/examples/components/wasm/README.md b/examples/components/wasm/README.md index f2dede6c..d697b4cd 100644 --- a/examples/components/wasm/README.md +++ b/examples/components/wasm/README.md @@ -4,9 +4,13 @@ This example shows the supported component-level WASM island package shape. ```gwdk component WasmCounter -wasm ./examples/components/wasm/browser/counter +wasm ./browser/counter ``` +The `wasm` package path is relative to the component source file, so the +component and its browser package remain portable when the build runs from the +repository root. + The browser Go package is a normal `package main` compiled with `GOOS=js GOARCH=wasm`. It must export the component-scoped ABI functions with `//go:wasmexport`: diff --git a/examples/components/wasm/abi-counter.cmp.gwdk b/examples/components/wasm/abi-counter.cmp.gwdk index 10a55db6..6c6596a9 100644 --- a/examples/components/wasm/abi-counter.cmp.gwdk +++ b/examples/components/wasm/abi-counter.cmp.gwdk @@ -1,7 +1,7 @@ package componentwasm component WasmCounter -wasm ./examples/components/wasm/browser/counter +wasm ./browser/counter view { +} +`) + writeLSPDiscoverySource(t, root, "components/private.cmp.gwdk", `package private + +component PrivateCard + +view { +
+} +`) + pageDoc := document{URI: fileURI(pagePath), Path: pagePath, Version: 1, Text: mustReadLSPDiscoverySource(t, pagePath)} + server := NewProjectServer(gowdk.Config{}, ProjectOptions{Root: root}) + server.log = nil + server.documents[pageDoc.URI] = pageDoc + + completions := server.projectCompletions(pageDoc.URI) + for _, label := range []string{"LocalCard", "ui.Button"} { + if !hasItemLabel(completions, label) { + t.Fatalf("expected visible component completion %q, got %#v", label, completions) + } + } + for _, label := range []string{"Button", "PrivateCard", "private.PrivateCard"} { + if hasItemLabel(completions, label) { + t.Fatalf("unresolvable component completion %q leaked: %#v", label, completions) + } + } +} + +func TestConfiguredComponentCompletionsSurviveMalformedCurrentPage(t *testing.T) { + root := t.TempDir() + writeLSPDiscoverySource(t, root, "components/local.cmp.gwdk", `package app + +component LocalCard + +view { +
+} +`) + writeLSPDiscoverySource(t, root, "components/button.cmp.gwdk", `package design + +component Button + +view { + +} +`) + pagePath := filepath.Join(root, "pages", "home.page.gwdk") + for name, source := range map[string]string{ + "parser error": `package app +use ui "design" + +page home +route "/" + +view { +
+`, + "lexer error": `package app +use ui "design" + +page home +route "/" + +view { +
0 { diff --git a/internal/parser/diagnostic.go b/internal/parser/diagnostic.go index 6d17c098..8b041a6d 100644 --- a/internal/parser/diagnostic.go +++ b/internal/parser/diagnostic.go @@ -16,6 +16,7 @@ const ( DiagnosticOldActionBlockSyntax = "old_action_block_syntax" DiagnosticOldAPIBlockSyntax = "old_api_block_syntax" DiagnosticPackageMustBeFirst = "package_must_be_first" + DiagnosticSourceLineTooLong = "source_line_too_long" DiagnosticUnsupportedLiteralRecord = "unsupported_literal_record_syntax" DiagnosticUnsupportedTopLevelBlock = "unsupported_top_level_block" DiagnosticUnsupportedLayoutMetadata = "unsupported_layout_metadata" diff --git a/internal/parser/line_scanner.go b/internal/parser/line_scanner.go new file mode 100644 index 00000000..8e58c1d0 --- /dev/null +++ b/internal/parser/line_scanner.go @@ -0,0 +1,56 @@ +package parser + +import ( + "bufio" + "bytes" + "errors" + "fmt" +) + +const ( + // MaxSourceLineBytes is the maximum line size accepted in ordinary .gwdk + // source files. + MaxSourceLineBytes = 1 << 20 + + // MaxAuditLineBytes is the maximum line size accepted in *.audit.gwdk + // policy files. + MaxAuditLineBytes = MaxSourceLineBytes + + lineScannerInitialBytes = 64 << 10 +) + +func newSourceLineScanner(src []byte, maxLineBytes int) *bufio.Scanner { + scanner := bufio.NewScanner(bytes.NewReader(src)) + // Leave room for a trailing CRLF. The explicit token-length check keeps + // the documented limit independent from Scanner's delimiter buffering. + scanner.Buffer(make([]byte, lineScannerInitialBytes), maxLineBytes+2) + return scanner +} + +func lineTooLong(scanner *bufio.Scanner, maxLineBytes int) bool { + return len(scanner.Bytes()) > maxLineBytes || errors.Is(scanner.Err(), bufio.ErrTooLong) +} + +func sourceLineTooLongError(lineNumber, maxLineBytes int, inputKind string) error { + return lineDiagnosticError( + DiagnosticSourceLineTooLong, + lineNumber, + "", + "%s line exceeds the %d-byte limit; split the content across lines or move it to an external file", + inputKind, + maxLineBytes, + ) +} + +func addScannerError(addError func(error), scanner *bufio.Scanner, lineNumber, maxLineBytes int, inputKind string) bool { + err := scanner.Err() + if err == nil { + return false + } + if lineTooLong(scanner, maxLineBytes) { + addError(sourceLineTooLongError(lineNumber, maxLineBytes, inputKind)) + return true + } + addError(fmt.Errorf("line %d: scan %s: %w", lineNumber, inputKind, err)) + return true +} diff --git a/internal/parser/line_scanner_test.go b/internal/parser/line_scanner_test.go new file mode 100644 index 00000000..81dc15ad --- /dev/null +++ b/internal/parser/line_scanner_test.go @@ -0,0 +1,92 @@ +package parser + +import ( + "strings" + "testing" +) + +func TestParseSyntaxAcceptsLinesLargerThanScannerDefault(t *testing.T) { + payload := strings.Repeat("x", 70<<10) + tests := []struct { + name string + source string + }{ + { + name: "metadata", + source: "page " + payload + "\n", + }, + { + name: "inline js", + source: "js {\nconst payload = \"" + payload + "\"\n}\n", + }, + { + name: "inline css", + source: "style {\n.long { --payload: " + payload + "; }\n}\n", + }, + { + name: "inline go", + source: "go {\nvar payload = \"" + payload + "\"\n}\n", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := ParseSyntax([]byte(test.source)); err != nil { + t.Fatalf("expected long line to parse: %v", err) + } + }) + } +} + +func TestParseSyntaxRejectsOversizedLineWithStableDiagnostic(t *testing.T) { + source := "js {\n" + strings.Repeat("x", MaxSourceLineBytes+3) + "\n}\n" + _, err := ParseSyntax([]byte(source)) + if err == nil { + t.Fatal("expected oversized source line error") + } + diagnostic, ok := ParserDiagnostic(err) + if !ok { + t.Fatalf("expected typed parser diagnostic, got %T: %v", err, err) + } + if diagnostic.Code != DiagnosticSourceLineTooLong || diagnostic.Span.Start.Line != 2 { + t.Fatalf("unexpected diagnostic: %#v", diagnostic) + } + if !strings.Contains(diagnostic.Message, "1048576-byte limit") { + t.Fatalf("expected documented source limit, got %q", diagnostic.Message) + } + if strings.Contains(err.Error(), "block missing closing") { + t.Fatalf("scanner limit should not cause a cascading block error: %v", err) + } +} + +func TestParseAuditSyntaxAcceptsLineLargerThanScannerDefault(t *testing.T) { + selector := strings.Repeat("x", 70<<10) + source := "policy long {\n match \"" + selector + "\"\n}\n" + file, err := ParseAuditSyntax([]byte(source)) + if err != nil { + t.Fatal(err) + } + if len(file.Policies) != 1 || len(file.Policies[0].Applies) != 1 || file.Policies[0].Applies[0].Selector != selector { + t.Fatalf("unexpected long audit policy: %#v", file.Policies) + } +} + +func TestParseAuditSyntaxRejectsOversizedLineWithStableDiagnostic(t *testing.T) { + source := "policy long {\n" + strings.Repeat("x", MaxAuditLineBytes+3) + "\n}\n" + _, err := ParseAuditSyntax([]byte(source)) + if err == nil { + t.Fatal("expected oversized audit line error") + } + diagnostic, ok := ParserDiagnostic(err) + if !ok { + t.Fatalf("expected typed parser diagnostic, got %T: %v", err, err) + } + if diagnostic.Code != DiagnosticSourceLineTooLong || diagnostic.Span.Start.Line != 2 { + t.Fatalf("unexpected diagnostic: %#v", diagnostic) + } + if !strings.Contains(diagnostic.Message, "1048576-byte limit") { + t.Fatalf("expected documented audit limit, got %q", diagnostic.Message) + } + if strings.Contains(err.Error(), "unterminated policy") { + t.Fatalf("scanner limit should not cause a cascading policy error: %v", err) + } +} diff --git a/internal/parser/syntax.go b/internal/parser/syntax.go index 3963f6a4..5d237509 100644 --- a/internal/parser/syntax.go +++ b/internal/parser/syntax.go @@ -1,8 +1,6 @@ package parser import ( - "bufio" - "bytes" "fmt" "strconv" "strings" @@ -63,9 +61,14 @@ func ParseSyntax(src []byte) (SyntaxFile, error) { skipScanner = braceScanner{lang: blockScanLang(kind)} } - scanner := bufio.NewScanner(bytes.NewReader(src)) - for lineNumber := 1; scanner.Scan(); lineNumber++ { + scanner := newSourceLineScanner(src, MaxSourceLineBytes) + lineNumber := 1 + for ; scanner.Scan(); lineNumber++ { rawLine := scanner.Text() + if lineTooLong(scanner, MaxSourceLineBytes) { + addError(sourceLineTooLongError(lineNumber, MaxSourceLineBytes, "source")) + continue + } line := strings.TrimSpace(rawLine) if skippingBlock { if line == "}" && !skipScanner.inMultiline() { @@ -420,13 +423,11 @@ func ParseSyntax(src []byte) (SyntaxFile, error) { continue } } - if err := scanner.Err(); err != nil { - addError(err) - } - if captured.Kind != "" { + scanFailed := addScannerError(addError, scanner, lineNumber, MaxSourceLineBytes, "source") + if captured.Kind != "" && !scanFailed { addError(fmt.Errorf("%s block missing closing }", captured.Kind)) } - if capturedFragment != nil { + if capturedFragment != nil && !scanFailed { addError(fmt.Errorf("fragment %s block missing closing }", capturedFragment.Name)) } attachSyntaxAssetScopes(&file) diff --git a/internal/project/config.go b/internal/project/config.go index 8ae73c86..cfdb5b39 100644 --- a/internal/project/config.go +++ b/internal/project/config.go @@ -96,6 +96,9 @@ func validateLoadedConfig(path string, config gowdk.Config) error { if err := config.Build.CORS.Validate(); err != nil { return fmt.Errorf("%s CORS policy: %w", path, err) } + if err := config.Build.CSRF.Validate(); err != nil { + return fmt.Errorf("%s CSRF policy: %w", path, err) + } if err := gowdk.ValidateAddons(config.Addons); err != nil { return fmt.Errorf("%s addons: %w", path, err) } @@ -773,6 +776,8 @@ func parseCSRFConfig(expression ast.Expr) gowdk.CSRFConfig { csrf.Disabled = parseBool(field.Value) case "SecretEnv": csrf.SecretEnv = parseString(field.Value) + case "VerificationSecretEnvs": + csrf.VerificationSecretEnvs = parseStringList(field.Value) case "CookieName": csrf.CookieName = parseString(field.Value) case "FieldName": diff --git a/internal/project/config_exec.go b/internal/project/config_exec.go index bdece212..ce07bfe5 100644 --- a/internal/project/config_exec.go +++ b/internal/project/config_exec.go @@ -30,18 +30,27 @@ type executableConfig struct { } type executableAddonDetails struct { - Index int `json:"index"` + Index int `json:"index"` + Name string `json:"name"` + Features []gowdk.Feature `json:"features"` + Capabilities []executableAddonCapability `json:"capabilities,omitempty"` +} + +type executableAddonCapability struct { Name string `json:"name"` - Features []gowdk.Feature `json:"features"` - CSSProcessor bool `json:"cssProcessor"` - GoBlockConsumer bool `json:"goBlockConsumer"` + Required bool `json:"required,omitempty"` GoBlockTargets []string `json:"goBlockTargets,omitempty"` - SEOProvider bool `json:"seoProvider"` SEOOptions gowdk.SEOOptions `json:"seoOptions,omitempty"` - AuthSession bool `json:"authSession"` AuthSessionOptions gowdk.AuthSessionOptions `json:"authSessionOptions,omitempty"` } +const ( + executableCapabilityCSSProcessor = "gowdk.css-processor" + executableCapabilityGoBlockConsumer = "gowdk.go-block-consumer" + executableCapabilitySEOProvider = "gowdk.seo-provider" + executableCapabilityAuthSessionProvider = "gowdk.auth-session-provider" +) + type executableCSSResponse struct { Result gowdk.CSSResult `json:"result"` Error string `json:"error"` @@ -72,26 +81,29 @@ type executableAddon struct { index int name string features []gowdk.Feature + cssProcessor bool + goBlockConsumer bool goBlockTargets []string seoProvider bool seoOptions gowdk.SEOOptions + authSession bool authSessionOptions gowdk.AuthSessionOptions } -type executableCSSAddon struct { - executableAddon +type executableCSSCapability struct { + addon executableAddon } -type executableGoBlockAddon struct { - executableAddon +type executableGoBlockCapability struct { + addon executableAddon } -type executableCSSGoBlockAddon struct { - executableAddon +type executableSEOCapability struct { + options gowdk.SEOOptions } -type executableAuthAddon struct { - executableAddon +type executableAuthSessionCapability struct { + options gowdk.AuthSessionOptions } func loadExecutableConfig(configPath string) (gowdk.Config, error) { @@ -103,7 +115,10 @@ func loadExecutableConfig(configPath string) (gowdk.Config, error) { if err := json.Unmarshal(payload, &wire); err != nil { return gowdk.Config{}, fmt.Errorf("decode executable config: %w", err) } + return configFromExecutableWire(configPath, wire) +} +func configFromExecutableWire(configPath string, wire executableConfig) (gowdk.Config, error) { config := gowdk.Config{ AppName: wire.AppName, Source: wire.Source, @@ -117,27 +132,31 @@ func loadExecutableConfig(configPath string) (gowdk.Config, error) { } for _, addon := range wire.Addons { proxy := executableAddon{ - configPath: configPath, - index: addon.Index, - name: addon.Name, - features: append([]gowdk.Feature(nil), addon.Features...), - goBlockTargets: append([]string(nil), addon.GoBlockTargets...), - seoProvider: addon.SEOProvider, - seoOptions: cloneExecutableSEOOptions(addon.SEOOptions), - authSessionOptions: addon.AuthSessionOptions, + configPath: configPath, + index: addon.Index, + name: addon.Name, + features: append([]gowdk.Feature(nil), addon.Features...), } - switch { - case addon.AuthSession: - config.Addons = append(config.Addons, executableAuthAddon{executableAddon: proxy}) - case addon.CSSProcessor && addon.GoBlockConsumer: - config.Addons = append(config.Addons, executableCSSGoBlockAddon{executableAddon: proxy}) - case addon.CSSProcessor: - config.Addons = append(config.Addons, executableCSSAddon{executableAddon: proxy}) - case addon.GoBlockConsumer: - config.Addons = append(config.Addons, executableGoBlockAddon{executableAddon: proxy}) - default: - config.Addons = append(config.Addons, proxy) + for _, capability := range addon.Capabilities { + switch capability.Name { + case executableCapabilityCSSProcessor: + proxy.cssProcessor = true + case executableCapabilityGoBlockConsumer: + proxy.goBlockConsumer = true + proxy.goBlockTargets = append([]string(nil), capability.GoBlockTargets...) + case executableCapabilitySEOProvider: + proxy.seoProvider = true + proxy.seoOptions = cloneExecutableSEOOptions(capability.SEOOptions) + case executableCapabilityAuthSessionProvider: + proxy.authSession = true + proxy.authSessionOptions = capability.AuthSessionOptions + default: + if capability.Required { + return gowdk.Config{}, fmt.Errorf("addon %q requires unsupported executable capability %q", addon.Name, capability.Name) + } + } } + config.Addons = append(config.Addons, proxy) } return config, nil } @@ -150,12 +169,29 @@ func (addon executableAddon) Features() []gowdk.Feature { return append([]gowdk.Feature(nil), addon.features...) } -func (addon executableAddon) SEOOptions() gowdk.SEOOptions { - return cloneExecutableSEOOptions(addon.seoOptions) +func (addon executableAddon) AddonCapabilities() gowdk.AddonCapabilities { + var capabilities gowdk.AddonCapabilities + if addon.cssProcessor { + capabilities.CSSProcessor = executableCSSCapability{addon: addon} + } + if addon.goBlockConsumer { + capabilities.GoBlockConsumer = executableGoBlockCapability{addon: addon} + } + if addon.seoProvider { + capabilities.SEOProvider = executableSEOCapability{options: cloneExecutableSEOOptions(addon.seoOptions)} + } + if addon.authSession { + capabilities.AuthSessionProvider = executableAuthSessionCapability{options: addon.authSessionOptions} + } + return capabilities +} + +func (capability executableAuthSessionCapability) AuthSessionOptions() gowdk.AuthSessionOptions { + return capability.options } -func (addon executableAuthAddon) AuthSessionOptions() gowdk.AuthSessionOptions { - return addon.authSessionOptions +func (capability executableSEOCapability) SEOOptions() gowdk.SEOOptions { + return cloneExecutableSEOOptions(capability.options) } func cloneExecutableSEOOptions(options gowdk.SEOOptions) gowdk.SEOOptions { @@ -165,12 +201,16 @@ func cloneExecutableSEOOptions(options gowdk.SEOOptions) gowdk.SEOOptions { return options } -func (addon executableCSSAddon) ProcessCSS(context gowdk.CSSContext) (gowdk.CSSResult, error) { - return addon.processCSS(context) +func (capability executableCSSCapability) Name() string { + return capability.addon.Name() +} + +func (capability executableCSSCapability) Features() []gowdk.Feature { + return capability.addon.Features() } -func (addon executableCSSGoBlockAddon) ProcessCSS(context gowdk.CSSContext) (gowdk.CSSResult, error) { - return addon.processCSS(context) +func (capability executableCSSCapability) ProcessCSS(context gowdk.CSSContext) (gowdk.CSSResult, error) { + return capability.addon.processCSS(context) } func (addon executableAddon) processCSS(context gowdk.CSSContext) (gowdk.CSSResult, error) { @@ -192,24 +232,16 @@ func (addon executableAddon) processCSS(context gowdk.CSSContext) (gowdk.CSSResu return response.Result, nil } -func (addon executableGoBlockAddon) GoBlockTargets() []string { - return addon.goBlockTargetsCopy() -} - -func (addon executableCSSGoBlockAddon) GoBlockTargets() []string { - return addon.goBlockTargetsCopy() +func (capability executableGoBlockCapability) GoBlockTargets() []string { + return capability.addon.goBlockTargetsCopy() } func (addon executableAddon) goBlockTargetsCopy() []string { return append([]string(nil), addon.goBlockTargets...) } -func (addon executableGoBlockAddon) ValidateGoBlock(target gowdk.GoBlockTarget, context gowdk.GoBlockContext) []gowdk.GoBlockDiagnostic { - return addon.validateGoBlock(target, context) -} - -func (addon executableCSSGoBlockAddon) ValidateGoBlock(target gowdk.GoBlockTarget, context gowdk.GoBlockContext) []gowdk.GoBlockDiagnostic { - return addon.validateGoBlock(target, context) +func (capability executableGoBlockCapability) ValidateGoBlock(target gowdk.GoBlockTarget, context gowdk.GoBlockContext) []gowdk.GoBlockDiagnostic { + return capability.addon.validateGoBlock(target, context) } func (addon executableAddon) validateGoBlock(target gowdk.GoBlockTarget, context gowdk.GoBlockContext) []gowdk.GoBlockDiagnostic { @@ -231,12 +263,8 @@ func (addon executableAddon) validateGoBlock(target gowdk.GoBlockTarget, context return response.Diagnostics } -func (addon executableGoBlockAddon) GeneratedGo(target gowdk.GoBlockTarget, context gowdk.GoBlockContext) ([]gowdk.GoBlockFile, error) { - return addon.generatedGo(target, context) -} - -func (addon executableCSSGoBlockAddon) GeneratedGo(target gowdk.GoBlockTarget, context gowdk.GoBlockContext) ([]gowdk.GoBlockFile, error) { - return addon.generatedGo(target, context) +func (capability executableGoBlockCapability) GeneratedGo(target gowdk.GoBlockTarget, context gowdk.GoBlockContext) ([]gowdk.GoBlockFile, error) { + return capability.addon.generatedGo(target, context) } func (addon executableAddon) generatedGo(target gowdk.GoBlockTarget, context gowdk.GoBlockContext) ([]gowdk.GoBlockFile, error) { @@ -404,18 +432,27 @@ type executableConfig struct { } type executableAddonDetails struct { - Index int ` + "`json:\"index\"`" + ` + Index int ` + "`json:\"index\"`" + ` + Name string ` + "`json:\"name\"`" + ` + Features []gowdk.Feature ` + "`json:\"features\"`" + ` + Capabilities []executableAddonCapability ` + "`json:\"capabilities,omitempty\"`" + ` +} + +type executableAddonCapability struct { Name string ` + "`json:\"name\"`" + ` - Features []gowdk.Feature ` + "`json:\"features\"`" + ` - CSSProcessor bool ` + "`json:\"cssProcessor\"`" + ` - GoBlockConsumer bool ` + "`json:\"goBlockConsumer\"`" + ` + Required bool ` + "`json:\"required,omitempty\"`" + ` GoBlockTargets []string ` + "`json:\"goBlockTargets,omitempty\"`" + ` - SEOProvider bool ` + "`json:\"seoProvider\"`" + ` SEOOptions gowdk.SEOOptions ` + "`json:\"seoOptions,omitempty\"`" + ` - AuthSession bool ` + "`json:\"authSession\"`" + ` AuthSessionOptions gowdk.AuthSessionOptions ` + "`json:\"authSessionOptions,omitempty\"`" + ` } +const ( + executableCapabilityCSSProcessor = "gowdk.css-processor" + executableCapabilityGoBlockConsumer = "gowdk.go-block-consumer" + executableCapabilitySEOProvider = "gowdk.seo-provider" + executableCapabilityAuthSessionProvider = "gowdk.auth-session-provider" +) + type executableCSSResponse struct { Result gowdk.CSSResult ` + "`json:\"result\"`" + ` Error string ` + "`json:\"error\"`" + ` @@ -485,33 +522,40 @@ func writeConfig() { CSS: config.CSS, } for index, addon := range config.Addons { - _, cssProcessor := addon.(gowdk.CSSProcessor) - goBlockConsumer, hasGoBlockConsumer := addon.(gowdk.GoBlockConsumer) - seoProvider, hasSEOProvider := addon.(gowdk.SEOProvider) - authSessionProvider, hasAuthSessionProvider := addon.(gowdk.AuthSessionProvider) - var goBlockTargets []string - if hasGoBlockConsumer { - goBlockTargets = goBlockConsumer.GoBlockTargets() + resolved := gowdk.ResolveAddonCapabilities(addon) + var capabilities []executableAddonCapability + if resolved.CSSProcessor != nil { + capabilities = append(capabilities, executableAddonCapability{ + Name: executableCapabilityCSSProcessor, + Required: true, + }) + } + if resolved.GoBlockConsumer != nil { + capabilities = append(capabilities, executableAddonCapability{ + Name: executableCapabilityGoBlockConsumer, + Required: true, + GoBlockTargets: resolved.GoBlockConsumer.GoBlockTargets(), + }) } - var seoOptions gowdk.SEOOptions - if hasSEOProvider { - seoOptions = seoProvider.SEOOptions() + if resolved.SEOProvider != nil { + capabilities = append(capabilities, executableAddonCapability{ + Name: executableCapabilitySEOProvider, + Required: true, + SEOOptions: resolved.SEOProvider.SEOOptions(), + }) } - var authSessionOptions gowdk.AuthSessionOptions - if hasAuthSessionProvider { - authSessionOptions = authSessionProvider.AuthSessionOptions() + if resolved.AuthSessionProvider != nil { + capabilities = append(capabilities, executableAddonCapability{ + Name: executableCapabilityAuthSessionProvider, + Required: true, + AuthSessionOptions: resolved.AuthSessionProvider.AuthSessionOptions(), + }) } wire.Addons = append(wire.Addons, executableAddonDetails{ - Index: index, - Name: addon.Name(), - Features: addon.Features(), - CSSProcessor: cssProcessor, - GoBlockConsumer: hasGoBlockConsumer, - GoBlockTargets: goBlockTargets, - SEOProvider: hasSEOProvider, - SEOOptions: seoOptions, - AuthSession: hasAuthSessionProvider, - AuthSessionOptions: authSessionOptions, + Index: index, + Name: addon.Name(), + Features: addon.Features(), + Capabilities: capabilities, }) } writeJSON(wire) @@ -523,8 +567,8 @@ func processCSS(index int) { writeJSON(executableCSSResponse{Error: fmt.Sprintf("addon index %%d is out of range", index)}) return } - processor, ok := config.Addons[index].(gowdk.CSSProcessor) - if !ok { + processor := gowdk.ResolveAddonCapabilities(config.Addons[index]).CSSProcessor + if processor == nil { writeJSON(executableCSSResponse{Error: fmt.Sprintf("addon %%s does not implement CSSProcessor", config.Addons[index].Name())}) return } @@ -582,8 +626,8 @@ func goBlockConsumer(config gowdk.Config, index int) (gowdk.GoBlockConsumer, err if index < 0 || index >= len(config.Addons) { return nil, fmt.Errorf("addon index %d is out of range", index) } - consumer, ok := config.Addons[index].(gowdk.GoBlockConsumer) - if !ok { + consumer := gowdk.ResolveAddonCapabilities(config.Addons[index]).GoBlockConsumer + if consumer == nil { return nil, fmt.Errorf("addon %s does not implement GoBlockConsumer", config.Addons[index].Name()) } return consumer, nil diff --git a/internal/project/config_test.go b/internal/project/config_test.go index 00178b88..7ac0bf79 100644 --- a/internal/project/config_test.go +++ b/internal/project/config_test.go @@ -79,13 +79,14 @@ var Config = gowdk.Config{ TwitterCard: "summary_large_image", }, CSRF: gowdk.CSRFConfig{ - Enabled: true, - Disabled: true, - SecretEnv: "EXAMPLE_CSRF_SECRET", - CookieName: "__Host-example-csrf", - FieldName: "_example_csrf", - HeaderName: "X-Example-CSRF", - Insecure: true, + Enabled: true, + Disabled: true, + SecretEnv: "EXAMPLE_CSRF_SECRET", + VerificationSecretEnvs: []string{"EXAMPLE_NEXT_CSRF_SECRET", "EXAMPLE_OLD_CSRF_SECRET"}, + CookieName: "__Host-example-csrf", + FieldName: "_example_csrf", + HeaderName: "X-Example-CSRF", + Insecure: true, }, CORS: gowdk.CORSConfig{ Enabled: true, @@ -241,7 +242,7 @@ var Config = gowdk.Config{ if config.Build.Head.SiteName != "Example" || config.Build.Head.Favicon != "/favicon.ico" || config.Build.Head.Image != "https://example.com/social.png" || config.Build.Head.TwitterCard != "summary_large_image" { t.Fatalf("unexpected build head config: %#v", config.Build.Head) } - if !config.Build.CSRF.Enabled || !config.Build.CSRF.Disabled || config.Build.CSRF.SecretEnv != "EXAMPLE_CSRF_SECRET" || config.Build.CSRF.CookieName != "__Host-example-csrf" || config.Build.CSRF.FieldName != "_example_csrf" || config.Build.CSRF.HeaderName != "X-Example-CSRF" || !config.Build.CSRF.Insecure { + if !config.Build.CSRF.Enabled || !config.Build.CSRF.Disabled || config.Build.CSRF.SecretEnv != "EXAMPLE_CSRF_SECRET" || strings.Join(config.Build.CSRF.VerificationSecretEnvs, ",") != "EXAMPLE_NEXT_CSRF_SECRET,EXAMPLE_OLD_CSRF_SECRET" || config.Build.CSRF.CookieName != "__Host-example-csrf" || config.Build.CSRF.FieldName != "_example_csrf" || config.Build.CSRF.HeaderName != "X-Example-CSRF" || !config.Build.CSRF.Insecure { t.Fatalf("unexpected build csrf config: %#v", config.Build.CSRF) } if !config.Build.CORS.Enabled || strings.Join(config.Build.CORS.AllowedOrigins, ",") != "https://app.example" || strings.Join(config.Build.CORS.AllowedMethods, ",") != "GET,POST" || strings.Join(config.Build.CORS.AllowedHeaders, ",") != "Content-Type,X-CSRF" || strings.Join(config.Build.CORS.ExposedHeaders, ",") != "X-Total-Count" || !config.Build.CORS.AllowCredentials || config.Build.CORS.MaxAgeSeconds != 600 { @@ -861,9 +862,9 @@ var Config = gowdk.Config{ if !config.HasFeature(gowdk.FeatureSEO) { t.Fatalf("expected executable config to keep seo addon, got %#v", config.Addons) } - provider, ok := config.Addons[1].(gowdk.SEOProvider) - if !ok { - t.Fatalf("expected executable seo addon to preserve SEOProvider, got %T", config.Addons[1]) + provider := gowdk.ResolveAddonCapabilities(config.Addons[1]).SEOProvider + if provider == nil { + t.Fatalf("expected executable seo addon descriptor to preserve SEOProvider, got %T", config.Addons[1]) } options := provider.SEOOptions() if options.BaseURL != "https://example.com" || len(options.ExtraURLs) != 1 || options.ExtraURLs[0].Loc != "/feed.xml" { @@ -904,8 +905,8 @@ var Config = gowdk.Config{ if !config.HasFeature(gowdk.FeatureAuth) { t.Fatal("expected parsed config to enable auth") } - provider, ok := config.Addons[0].(gowdk.AuthSessionProvider) - if !ok { + provider := gowdk.ResolveAddonCapabilities(config.Addons[0]).AuthSessionProvider + if provider == nil { t.Fatalf("expected AuthSessionProvider, got %T", config.Addons[0]) } options := provider.AuthSessionOptions() @@ -961,9 +962,9 @@ var Config = gowdk.Config{ if !config.HasFeature(gowdk.FeatureAuth) { t.Fatalf("expected executable config to keep auth addon, got %#v", config.Addons) } - provider, ok := config.Addons[0].(gowdk.AuthSessionProvider) - if !ok { - t.Fatalf("expected executable auth addon to preserve AuthSessionProvider, got %T", config.Addons[0]) + provider := gowdk.ResolveAddonCapabilities(config.Addons[0]).AuthSessionProvider + if provider == nil { + t.Fatalf("expected executable auth addon descriptor to preserve AuthSessionProvider, got %T", config.Addons[0]) } options := provider.AuthSessionOptions() if options.SecretEnv != "GOWDK_SITE_SESSION_SECRET" || options.CookieName != "site_session" || options.TTL.String() != "2h0m0s" || !options.Insecure { @@ -1009,8 +1010,8 @@ var Config = gowdk.Config{ if !config.HasFeature(gowdk.FeatureSEO) { t.Fatal("expected parsed config to enable SEO") } - provider, ok := config.Addons[0].(gowdk.SEOProvider) - if !ok { + provider := gowdk.ResolveAddonCapabilities(config.Addons[0]).SEOProvider + if provider == nil { t.Fatalf("expected SEOProvider, got %T", config.Addons[0]) } options := provider.SEOOptions() @@ -1067,9 +1068,9 @@ var Config = gowdk.Config{ if !config.HasFeature(gowdk.FeatureSEO) { t.Fatal("expected executable config to enable SEO") } - provider, ok := config.Addons[0].(gowdk.SEOProvider) - if !ok { - t.Fatalf("expected SEOProvider, got %T", config.Addons[0]) + provider := gowdk.ResolveAddonCapabilities(config.Addons[0]).SEOProvider + if provider == nil { + t.Fatalf("expected executable SEOProvider descriptor, got %T", config.Addons[0]) } options := provider.SEOOptions() if options.BaseURL != "https://dynamic.example.com/docs" || len(options.Disallow) != 1 || options.Disallow[0] != "/admin" { @@ -1122,9 +1123,9 @@ var Config = gowdk.Config{ if err != nil { t.Fatal(err) } - provider, ok := config.Addons[0].(gowdk.SEOProvider) - if !ok { - t.Fatalf("expected SEOProvider, got %T", config.Addons[0]) + provider := gowdk.ResolveAddonCapabilities(config.Addons[0]).SEOProvider + if provider == nil { + t.Fatalf("expected executable SEOProvider descriptor, got %T", config.Addons[0]) } dynamic := provider.SEOOptions().DynamicSitemap if dynamic.MaxURLs != 25 || dynamic.CacheSeconds != 60 { @@ -1132,6 +1133,155 @@ var Config = gowdk.Config{ } } +func TestLoadConfigFilePreservesCombinedExecutableCapabilities(t *testing.T) { + root := t.TempDir() + repoRoot := repositoryRoot(t) + writeTestFile(t, filepath.Join(root, "go.mod"), `module example.com/site + +go 1.22 + +require github.com/cssbruno/gowdk v0.0.0 + +replace github.com/cssbruno/gowdk => `+repoRoot+` +`) + path := filepath.Join(root, DefaultConfigFile) + writeTestFile(t, path, `package app + +import ( + "os" + + "github.com/cssbruno/gowdk" +) + +type combinedAddon struct{} + +func (combinedAddon) Name() string { + return "combined" +} + +func (combinedAddon) Features() []gowdk.Feature { + return []gowdk.Feature{gowdk.FeatureAuth, gowdk.FeatureCSS, gowdk.FeatureSEO} +} + +func (combinedAddon) ProcessCSS(context gowdk.CSSContext) (gowdk.CSSResult, error) { + return gowdk.CSSResult{ + Assets: []gowdk.CSSAsset{{ + Path: "assets/combined.css", + Contents: []byte(context.OutputDir), + }}, + }, nil +} + +func (combinedAddon) GoBlockTargets() []string { + return []string{"addon.combined"} +} + +func (combinedAddon) ValidateGoBlock(target gowdk.GoBlockTarget, context gowdk.GoBlockContext) []gowdk.GoBlockDiagnostic { + return []gowdk.GoBlockDiagnostic{{ + Code: "combined_seen", + Message: target.Body + " " + string(context.Render), + }} +} + +func (combinedAddon) GeneratedGo(target gowdk.GoBlockTarget, context gowdk.GoBlockContext) ([]gowdk.GoBlockFile, error) { + return []gowdk.GoBlockFile{{ + Path: "combined/generated.go", + Source: "package combined\n", + }}, nil +} + +func (combinedAddon) SEOOptions() gowdk.SEOOptions { + return gowdk.SEOOptions{BaseURL: "https://combined.example.com"} +} + +func (combinedAddon) AuthSessionOptions() gowdk.AuthSessionOptions { + return gowdk.AuthSessionOptions{SecretEnv: "COMBINED_SESSION_SECRET"} +} + +var Config = gowdk.Config{ + AppName: os.Getenv("GOWDK_TEST_APP_NAME"), + Addons: []gowdk.Addon{combinedAddon{}}, +} +`) + tidyTestModule(t, root) + t.Setenv("GOWDK_TEST_APP_NAME", "Combined Capabilities") + + config, err := LoadConfigFile(path) + if err != nil { + t.Fatal(err) + } + if len(config.Addons) != 1 { + t.Fatalf("unexpected addons: %#v", config.Addons) + } + addon := config.Addons[0] + for name, implemented := range map[string]bool{ + "CSSProcessor": implementsCSSProcessor(addon), + "GoBlockConsumer": implementsGoBlockConsumer(addon), + "SEOProvider": implementsSEOProvider(addon), + "AuthSessionProvider": implementsAuthSessionProvider(addon), + } { + if implemented { + t.Fatalf("executable addon must not implement %s directly: %T", name, addon) + } + } + + capabilities := gowdk.ResolveAddonCapabilities(addon) + if capabilities.CSSProcessor == nil || capabilities.GoBlockConsumer == nil || + capabilities.SEOProvider == nil || capabilities.AuthSessionProvider == nil { + t.Fatalf("combined capabilities were not preserved: %#v", capabilities) + } + cssResult, err := capabilities.CSSProcessor.ProcessCSS(gowdk.CSSContext{OutputDir: "dist/site"}) + if err != nil { + t.Fatal(err) + } + if len(cssResult.Assets) != 1 || string(cssResult.Assets[0].Contents) != "dist/site" { + t.Fatalf("unexpected CSS result: %#v", cssResult) + } + diagnostics := capabilities.GoBlockConsumer.ValidateGoBlock( + gowdk.GoBlockTarget{Target: "addon.combined", Body: "check"}, + gowdk.GoBlockContext{Render: gowdk.SSR}, + ) + if len(diagnostics) != 1 || diagnostics[0].Code != "combined_seen" || diagnostics[0].Message != "check ssr" { + t.Fatalf("unexpected go block diagnostics: %#v", diagnostics) + } + files, err := capabilities.GoBlockConsumer.GeneratedGo( + gowdk.GoBlockTarget{Target: "addon.combined"}, + gowdk.GoBlockContext{}, + ) + if err != nil { + t.Fatal(err) + } + if len(files) != 1 || files[0].Path != "combined/generated.go" { + t.Fatalf("unexpected generated Go files: %#v", files) + } + if options := capabilities.SEOProvider.SEOOptions(); options.BaseURL != "https://combined.example.com" { + t.Fatalf("unexpected SEO options: %#v", options) + } + if options := capabilities.AuthSessionProvider.AuthSessionOptions(); options.SecretEnv != "COMBINED_SESSION_SECRET" { + t.Fatalf("unexpected auth options: %#v", options) + } +} + +func implementsCSSProcessor(addon gowdk.Addon) bool { + _, ok := addon.(gowdk.CSSProcessor) + return ok +} + +func implementsGoBlockConsumer(addon gowdk.Addon) bool { + _, ok := addon.(gowdk.GoBlockConsumer) + return ok +} + +func implementsSEOProvider(addon gowdk.Addon) bool { + _, ok := addon.(gowdk.SEOProvider) + return ok +} + +func implementsAuthSessionProvider(addon gowdk.Addon) bool { + _, ok := addon.(gowdk.AuthSessionProvider) + return ok +} + func TestLoadConfigFileReadsImportableExternalAddon(t *testing.T) { root := t.TempDir() repoRoot := repositoryRoot(t) @@ -1297,20 +1447,22 @@ var Config = gowdk.Config{ if !config.HasFeature(gowdk.FeatureCSS) || !config.HasFeature(gowdk.Feature("brand")) || !config.HasFeature(gowdk.Feature("marker")) { t.Fatalf("expected external addon features, got %#v", config.Addons[0].Features()) } - processor, ok := config.Addons[0].(gowdk.CSSProcessor) - if !ok { - t.Fatalf("expected external addon proxy to implement CSSProcessor, got %T", config.Addons[0]) + brandCapabilities := gowdk.ResolveAddonCapabilities(config.Addons[0]) + processor := brandCapabilities.CSSProcessor + if processor == nil { + t.Fatalf("expected external addon descriptor to preserve CSSProcessor, got %T", config.Addons[0]) } - if _, ok := config.Addons[1].(gowdk.CSSProcessor); ok { - t.Fatalf("expected non-css external addon proxy not to implement CSSProcessor, got %T", config.Addons[1]) + markerCapabilities := gowdk.ResolveAddonCapabilities(config.Addons[1]) + if markerCapabilities.CSSProcessor != nil { + t.Fatalf("expected non-css external addon descriptor not to expose CSSProcessor, got %T", config.Addons[1]) } - brandConsumer, ok := config.Addons[0].(gowdk.GoBlockConsumer) - if !ok { - t.Fatalf("expected css external addon proxy to preserve GoBlockConsumer, got %T", config.Addons[0]) + brandConsumer := brandCapabilities.GoBlockConsumer + if brandConsumer == nil { + t.Fatalf("expected css external addon descriptor to preserve GoBlockConsumer, got %T", config.Addons[0]) } - markerConsumer, ok := config.Addons[1].(gowdk.GoBlockConsumer) - if !ok { - t.Fatalf("expected non-css external addon proxy to preserve GoBlockConsumer, got %T", config.Addons[1]) + markerConsumer := markerCapabilities.GoBlockConsumer + if markerConsumer == nil { + t.Fatalf("expected non-css external addon descriptor to preserve GoBlockConsumer, got %T", config.Addons[1]) } if targets := brandConsumer.GoBlockTargets(); len(targets) != 1 || targets[0] != "addon.brand" { t.Fatalf("unexpected brand go block targets: %#v", targets) @@ -1395,8 +1547,8 @@ var Config = gowdk.Config{ if len(config.Addons) != 1 || config.Addons[0].Name() != "tailwind" { t.Fatalf("unexpected addons: %#v", config.Addons) } - processor, ok := config.Addons[0].(gowdk.CSSProcessor) - if !ok { + processor := gowdk.ResolveAddonCapabilities(config.Addons[0]).CSSProcessor + if processor == nil { t.Fatalf("expected tailwind addon to implement CSSProcessor, got %T", config.Addons[0]) } _, err = processor.ProcessCSS(gowdk.CSSContext{}) @@ -1481,6 +1633,137 @@ var Config = secretConfig("SECRET_TOKEN") } } +func TestConfigFromExecutableWirePreservesEveryCapabilityCombination(t *testing.T) { + for mask := 0; mask < 16; mask++ { + var names []string + var capabilities []executableAddonCapability + if mask&1 != 0 { + names = append(names, "css") + capabilities = append(capabilities, executableAddonCapability{ + Name: executableCapabilityCSSProcessor, + Required: true, + }) + } + if mask&2 != 0 { + names = append(names, "go-block") + capabilities = append(capabilities, executableAddonCapability{ + Name: executableCapabilityGoBlockConsumer, + Required: true, + GoBlockTargets: []string{"addon.combo"}, + }) + } + if mask&4 != 0 { + names = append(names, "seo") + capabilities = append(capabilities, executableAddonCapability{ + Name: executableCapabilitySEOProvider, + Required: true, + SEOOptions: gowdk.SEOOptions{BaseURL: "https://example.com"}, + }) + } + if mask&8 != 0 { + names = append(names, "auth") + capabilities = append(capabilities, executableAddonCapability{ + Name: executableCapabilityAuthSessionProvider, + Required: true, + AuthSessionOptions: gowdk.AuthSessionOptions{SecretEnv: "SESSION_SECRET"}, + }) + } + name := strings.Join(names, "+") + if name == "" { + name = "none" + } + t.Run(name, func(t *testing.T) { + config, err := configFromExecutableWire("/project/gowdk.config.go", executableConfig{ + Addons: []executableAddonDetails{{ + Name: "combo", + Features: []gowdk.Feature{gowdk.Feature("combo")}, + Capabilities: capabilities, + }}, + }) + if err != nil { + t.Fatal(err) + } + if len(config.Addons) != 1 { + t.Fatalf("unexpected addons: %#v", config.Addons) + } + addon := config.Addons[0] + if _, ok := addon.(gowdk.CSSProcessor); ok { + t.Fatalf("executable addon must not incidentally implement CSSProcessor: %T", addon) + } + if _, ok := addon.(gowdk.GoBlockConsumer); ok { + t.Fatalf("executable addon must not incidentally implement GoBlockConsumer: %T", addon) + } + if _, ok := addon.(gowdk.SEOProvider); ok { + t.Fatalf("executable addon must not incidentally implement SEOProvider: %T", addon) + } + if _, ok := addon.(gowdk.AuthSessionProvider); ok { + t.Fatalf("executable addon must not incidentally implement AuthSessionProvider: %T", addon) + } + + resolved := gowdk.ResolveAddonCapabilities(addon) + if got := resolved.CSSProcessor != nil; got != (mask&1 != 0) { + t.Fatalf("CSSProcessor presence = %t, want %t", got, mask&1 != 0) + } + if got := resolved.GoBlockConsumer != nil; got != (mask&2 != 0) { + t.Fatalf("GoBlockConsumer presence = %t, want %t", got, mask&2 != 0) + } + if got := resolved.SEOProvider != nil; got != (mask&4 != 0) { + t.Fatalf("SEOProvider presence = %t, want %t", got, mask&4 != 0) + } + if got := resolved.AuthSessionProvider != nil; got != (mask&8 != 0) { + t.Fatalf("AuthSessionProvider presence = %t, want %t", got, mask&8 != 0) + } + if resolved.CSSProcessor != nil && resolved.CSSProcessor.Name() != "combo" { + t.Fatalf("unexpected CSS capability owner: %q", resolved.CSSProcessor.Name()) + } + if resolved.GoBlockConsumer != nil { + targets := resolved.GoBlockConsumer.GoBlockTargets() + if len(targets) != 1 || targets[0] != "addon.combo" { + t.Fatalf("unexpected go block targets: %#v", targets) + } + } + if resolved.SEOProvider != nil && resolved.SEOProvider.SEOOptions().BaseURL != "https://example.com" { + t.Fatalf("unexpected SEO capability: %#v", resolved.SEOProvider.SEOOptions()) + } + if resolved.AuthSessionProvider != nil && resolved.AuthSessionProvider.AuthSessionOptions().SecretEnv != "SESSION_SECRET" { + t.Fatalf("unexpected auth capability: %#v", resolved.AuthSessionProvider.AuthSessionOptions()) + } + }) + } +} + +func TestConfigFromExecutableWireHandlesUnknownCapabilitiesByRequirement(t *testing.T) { + optional, err := configFromExecutableWire("/project/gowdk.config.go", executableConfig{ + Addons: []executableAddonDetails{{ + Name: "optional", + Features: []gowdk.Feature{gowdk.Feature("optional")}, + Capabilities: []executableAddonCapability{{ + Name: "example.future-capability", + }}, + }}, + }) + if err != nil { + t.Fatalf("unknown optional capability must be ignored: %v", err) + } + if len(optional.Addons) != 1 { + t.Fatalf("unexpected optional addon result: %#v", optional.Addons) + } + + _, err = configFromExecutableWire("/project/gowdk.config.go", executableConfig{ + Addons: []executableAddonDetails{{ + Name: "required", + Features: []gowdk.Feature{gowdk.Feature("required")}, + Capabilities: []executableAddonCapability{{ + Name: "example.future-capability", + Required: true, + }}, + }}, + }) + if err == nil || !strings.Contains(err.Error(), `addon "required" requires unsupported executable capability "example.future-capability"`) { + t.Fatalf("expected unsupported required capability error, got %v", err) + } +} + func TestConfigHelperSourceRewritesImportWithAST(t *testing.T) { source, err := configHelperSource("example.com/app/config") if err != nil { @@ -1495,6 +1778,16 @@ func TestConfigHelperSourceRewritesImportWithAST(t *testing.T) { if strings.Contains(source, configHelperImportPlaceholder) { t.Fatalf("placeholder import leaked into generated source:\n%s", source) } + for _, capability := range []string{ + executableCapabilityCSSProcessor, + executableCapabilityGoBlockConsumer, + executableCapabilitySEOProvider, + executableCapabilityAuthSessionProvider, + } { + if !strings.Contains(source, capability) { + t.Fatalf("generated helper is missing stable capability name %q", capability) + } + } } func TestLoadConfigFailsMissingDefault(t *testing.T) { diff --git a/internal/project/config_validation.go b/internal/project/config_validation.go index 9658ea76..6cd004c9 100644 --- a/internal/project/config_validation.go +++ b/internal/project/config_validation.go @@ -92,6 +92,9 @@ func validateLoadedConfigStructure(path string, config gowdk.Config) error { if err := config.Build.CORS.Validate(); err != nil { return fmt.Errorf("%s CORS policy: %w", path, err) } + if err := config.Build.CSRF.Validate(); err != nil { + return fmt.Errorf("%s CSRF policy: %w", path, err) + } if err := gowdk.ValidateAddons(config.Addons); err != nil { return fmt.Errorf("%s addons: %w", path, err) } diff --git a/internal/publicapi/gowdk_test.go b/internal/publicapi/gowdk_test.go index b3bcbd1f..4e55fdaa 100644 --- a/internal/publicapi/gowdk_test.go +++ b/internal/publicapi/gowdk_test.go @@ -56,10 +56,10 @@ func TestValidateAddonsRejectsInvalidIdentityAndOwnership(t *testing.T) { }, want: "duplicates feature"}, {name: "seo provider mismatch", addons: []gowdk.Addon{ gowdk.NewAddon("seo-marker", gowdk.FeatureSEO), - }, want: "does not implement gowdk.SEOProvider"}, + }, want: "does not provide gowdk.SEOProvider capability"}, {name: "auth provider mismatch", addons: []gowdk.Addon{ gowdk.NewAddon("auth-marker", gowdk.FeatureAuth), - }, want: "does not implement gowdk.AuthSessionProvider"}, + }, want: "does not provide gowdk.AuthSessionProvider capability"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -71,6 +71,86 @@ func TestValidateAddonsRejectsInvalidIdentityAndOwnership(t *testing.T) { } } +func TestResolveAddonCapabilitiesFallsBackToDirectInterfaces(t *testing.T) { + addon := publicAPICapabilityAddon{} + capabilities := gowdk.ResolveAddonCapabilities(addon) + if capabilities.CSSProcessor == nil { + t.Fatal("expected direct CSSProcessor capability") + } + if capabilities.SEOProvider == nil { + t.Fatal("expected direct SEOProvider capability") + } + if capabilities.AuthSessionProvider == nil { + t.Fatal("expected direct AuthSessionProvider capability") + } + if capabilities.GoBlockConsumer == nil { + t.Fatal("expected direct GoBlockConsumer capability") + } + if err := gowdk.ValidateAddons([]gowdk.Addon{addon}); err != nil { + t.Fatalf("expected direct capabilities to satisfy feature contracts: %v", err) + } +} + +func TestResolveAddonCapabilitiesTreatsExplicitDescriptorAsAuthoritative(t *testing.T) { + addon := publicAPIExplicitCapabilityAddon{} + capabilities := gowdk.ResolveAddonCapabilities(addon) + if capabilities.SEOProvider == nil { + t.Fatal("expected explicitly described SEOProvider capability") + } + if capabilities.CSSProcessor != nil || capabilities.AuthSessionProvider != nil || capabilities.GoBlockConsumer != nil { + t.Fatalf("direct interfaces leaked around explicit descriptor: %#v", capabilities) + } + if err := gowdk.ValidateAddons([]gowdk.Addon{addon}); err != nil { + t.Fatalf("expected explicit descriptor to satisfy feature contracts: %v", err) + } +} + +type publicAPICapabilityAddon struct{} + +func (publicAPICapabilityAddon) Name() string { + return "capabilities" +} + +func (publicAPICapabilityAddon) Features() []gowdk.Feature { + return []gowdk.Feature{gowdk.FeatureCSS, gowdk.FeatureSEO, gowdk.FeatureAuth} +} + +func (publicAPICapabilityAddon) ProcessCSS(gowdk.CSSContext) (gowdk.CSSResult, error) { + return gowdk.CSSResult{}, nil +} + +func (publicAPICapabilityAddon) SEOOptions() gowdk.SEOOptions { + return gowdk.SEOOptions{BaseURL: "https://example.com"} +} + +func (publicAPICapabilityAddon) AuthSessionOptions() gowdk.AuthSessionOptions { + return gowdk.AuthSessionOptions{SecretEnv: "SESSION_SECRET"} +} + +func (publicAPICapabilityAddon) GoBlockTargets() []string { + return []string{"addon.capabilities"} +} + +func (publicAPICapabilityAddon) ValidateGoBlock(gowdk.GoBlockTarget, gowdk.GoBlockContext) []gowdk.GoBlockDiagnostic { + return nil +} + +func (publicAPICapabilityAddon) GeneratedGo(gowdk.GoBlockTarget, gowdk.GoBlockContext) ([]gowdk.GoBlockFile, error) { + return nil, nil +} + +type publicAPIExplicitCapabilityAddon struct { + publicAPICapabilityAddon +} + +func (publicAPIExplicitCapabilityAddon) Features() []gowdk.Feature { + return []gowdk.Feature{gowdk.FeatureSEO} +} + +func (publicAPIExplicitCapabilityAddon) AddonCapabilities() gowdk.AddonCapabilities { + return gowdk.AddonCapabilities{SEOProvider: publicAPICapabilityAddon{}} +} + func TestValidateAddonsAllowsExplicitSPAAliasFeatureOverlap(t *testing.T) { err := gowdk.ValidateAddons([]gowdk.Addon{ gowdk.NewAddon("spa", gowdk.FeatureSPA), diff --git a/runtime/actions/actions_test.go b/runtime/actions/actions_test.go index 401ac0ad..372f7a8c 100644 --- a/runtime/actions/actions_test.go +++ b/runtime/actions/actions_test.go @@ -211,6 +211,96 @@ func TestCSRFBindsTokenToPrincipal(t *testing.T) { } } +func TestCSRFRotatesSecretsWithoutInvalidatingOverlap(t *testing.T) { + oldSecret := []byte(strings.Repeat("o", 32)) + newSecret := []byte(strings.Repeat("n", 32)) + binding := func(request *http.Request) []byte { + return []byte(request.Header.Get("X-Principal")) + } + + oldCSRF, err := NewCSRF(CSRFOptions{Secret: oldSecret, Insecure: true, Binding: binding}) + if err != nil { + t.Fatal(err) + } + oldMintRequest := httptest.NewRequest(http.MethodGet, "/", nil) + oldMintRequest.Header.Set("X-Principal", "alice") + oldResponse := httptest.NewRecorder() + oldToken, err := oldCSRF.Token(oldResponse, oldMintRequest) + if err != nil { + t.Fatal(err) + } + oldCookie := oldResponse.Result().Cookies()[0] + + rotatedCSRF, err := NewCSRF(CSRFOptions{ + Secret: newSecret, + VerificationSecrets: [][]byte{oldSecret}, + Insecure: true, + Binding: binding, + }) + if err != nil { + t.Fatal(err) + } + oldSubmit := httptest.NewRequest(http.MethodPost, "/submit", nil) + oldSubmit.Header.Set("X-Principal", "alice") + oldSubmit.Header.Set(defaultCSRFHeader, oldToken) + oldSubmit.AddCookie(oldCookie) + if err := rotatedCSRF.Validate(oldSubmit); err != nil { + t.Fatalf("expected old token to validate during overlap: %v", err) + } + + wrongPrincipal := httptest.NewRequest(http.MethodPost, "/submit", nil) + wrongPrincipal.Header.Set("X-Principal", "mallory") + wrongPrincipal.Header.Set(defaultCSRFHeader, oldToken) + wrongPrincipal.AddCookie(oldCookie) + if err := rotatedCSRF.Validate(wrongPrincipal); err == nil { + t.Fatal("expected rotated token to preserve principal binding") + } + + refreshRequest := httptest.NewRequest(http.MethodGet, "/", nil) + refreshRequest.Header.Set("X-Principal", "alice") + refreshRequest.AddCookie(oldCookie) + refreshResponse := httptest.NewRecorder() + refreshedToken, err := rotatedCSRF.Token(refreshResponse, refreshRequest) + if err != nil { + t.Fatal(err) + } + if refreshedToken == oldToken { + t.Fatal("expected verification-key token to refresh with the primary key") + } + refreshedCookies := refreshResponse.Result().Cookies() + if len(refreshedCookies) != 1 || refreshedCookies[0].Value != refreshedToken { + t.Fatalf("expected refreshed primary-key cookie, got %#v", refreshedCookies) + } + + newOnlyCSRF, err := NewCSRF(CSRFOptions{Secret: newSecret, Insecure: true, Binding: binding}) + if err != nil { + t.Fatal(err) + } + newSubmit := httptest.NewRequest(http.MethodPost, "/submit", nil) + newSubmit.Header.Set("X-Principal", "alice") + newSubmit.Header.Set(defaultCSRFHeader, refreshedToken) + newSubmit.AddCookie(refreshedCookies[0]) + if err := newOnlyCSRF.Validate(newSubmit); err != nil { + t.Fatalf("expected refreshed token to validate after old-key retirement: %v", err) + } + if err := newOnlyCSRF.Validate(oldSubmit); err == nil { + t.Fatal("expected old token to fail after old-key retirement") + } + + preStagedCSRF, err := NewCSRF(CSRFOptions{ + Secret: oldSecret, + VerificationSecrets: [][]byte{newSecret}, + Insecure: true, + Binding: binding, + }) + if err != nil { + t.Fatal(err) + } + if err := preStagedCSRF.Validate(newSubmit); err != nil { + t.Fatalf("expected pre-staged instance to accept the next primary key: %v", err) + } +} + func tamperToken(token string) string { raw, err := base64.RawURLEncoding.DecodeString(token) if err != nil || len(raw) == 0 { @@ -227,6 +317,16 @@ func TestNewCSRFRejectsShortSecret(t *testing.T) { } } +func TestNewCSRFRejectsShortVerificationSecret(t *testing.T) { + _, err := NewCSRF(CSRFOptions{ + Secret: []byte(strings.Repeat("s", 32)), + VerificationSecrets: [][]byte{[]byte("short")}, + }) + if err == nil || !strings.Contains(err.Error(), "verification secret 1") { + t.Fatalf("expected short verification secret error, got %v", err) + } +} + func TestNewCSRFRejectsSecureCookiePrefixInInsecureMode(t *testing.T) { for _, name := range []string{"__Host-gowdk-csrf", "__Secure-gowdk-csrf"} { _, err := NewCSRF(CSRFOptions{ diff --git a/runtime/actions/csrf.go b/runtime/actions/csrf.go index 99da722b..a30b48b8 100644 --- a/runtime/actions/csrf.go +++ b/runtime/actions/csrf.go @@ -33,12 +33,16 @@ type CSRFTokenSource interface { // CSRFOptions configures signed double-submit CSRF tokens. type CSRFOptions struct { - Secret []byte - CookieName string - FieldName string - HeaderName string - Insecure bool - SameSite http.SameSite + // Secret signs new tokens and validates existing tokens. + Secret []byte + // VerificationSecrets validate tokens during a staged key rotation but + // never sign new tokens. + VerificationSecrets [][]byte + CookieName string + FieldName string + HeaderName string + Insecure bool + SameSite http.SameSite // Binding, when set, ties each token to a per-request identity (typically // the authenticated principal). The returned value is mixed into the token // signature, so a token minted for one principal is rejected once the @@ -51,13 +55,14 @@ type CSRFOptions struct { // CSRF validates signed double-submit CSRF tokens for generated actions. type CSRF struct { - secret []byte - cookieName string - fieldName string - headerName string - secure bool - sameSite http.SameSite - binding func(*http.Request) []byte + secret []byte + verificationSecrets [][]byte + cookieName string + fieldName string + headerName string + secure bool + sameSite http.SameSite + binding func(*http.Request) []byte } // NewCSRF creates a validator with secure cookie defaults. @@ -65,6 +70,13 @@ func NewCSRF(options CSRFOptions) (*CSRF, error) { if len(options.Secret) < 32 { return nil, fmt.Errorf("csrf secret must be at least 32 bytes") } + verificationSecrets := make([][]byte, len(options.VerificationSecrets)) + for index, secret := range options.VerificationSecrets { + if len(secret) < 32 { + return nil, fmt.Errorf("csrf verification secret %d must be at least 32 bytes", index+1) + } + verificationSecrets[index] = append([]byte(nil), secret...) + } cookieName := options.CookieName if cookieName == "" { cookieName = defaultCSRFCookie @@ -88,13 +100,14 @@ func NewCSRF(options CSRFOptions) (*CSRF, error) { sameSite = http.SameSiteLaxMode } return &CSRF{ - secret: append([]byte(nil), options.Secret...), - cookieName: cookieName, - fieldName: fieldName, - headerName: headerName, - secure: !options.Insecure, - sameSite: sameSite, - binding: options.Binding, + secret: append([]byte(nil), options.Secret...), + verificationSecrets: verificationSecrets, + cookieName: cookieName, + fieldName: fieldName, + headerName: headerName, + secure: !options.Insecure, + sameSite: sameSite, + binding: options.Binding, }, nil } @@ -114,12 +127,12 @@ func secureCookiePrefix(name string) bool { // Token returns the CSRF token for a generated hidden form field. It reuses // the request's valid CSRF cookie when present so concurrently open tabs keep -// working, and only mints and stores a new token when the cookie is absent or -// invalid. +// working. A cookie signed by a verification-only key is replaced with a new +// primary-key token so active clients naturally migrate during key rotation. func (csrf *CSRF) Token(response http.ResponseWriter, request *http.Request) (string, error) { binding := csrf.bindingFor(request) if request != nil { - if cookie, err := request.Cookie(csrf.cookieName); err == nil && csrf.valid(cookie.Value, binding) { + if cookie, err := request.Cookie(csrf.cookieName); err == nil && csrf.validWithPrimary(cookie.Value, binding) { return cookie.Value, nil } } @@ -191,15 +204,37 @@ func (csrf *CSRF) sign(nonce, binding []byte) string { } func (csrf *CSRF) valid(token string, binding []byte) bool { + nonce, signature, ok := decodeCSRFToken(token) + if !ok { + return false + } + matches := csrfSignatureMatches(csrf.secret, nonce, binding, signature) + for _, secret := range csrf.verificationSecrets { + matches |= csrfSignatureMatches(secret, nonce, binding, signature) + } + return matches == 1 +} + +func (csrf *CSRF) validWithPrimary(token string, binding []byte) bool { + nonce, signature, ok := decodeCSRFToken(token) + if !ok { + return false + } + return csrfSignatureMatches(csrf.secret, nonce, binding, signature) == 1 +} + +func decodeCSRFToken(token string) (nonce []byte, signature []byte, ok bool) { raw, err := base64.RawURLEncoding.DecodeString(token) if err != nil || len(raw) != csrfNonceBytes+csrfMACBytes { - return false + return nil, nil, false } - nonce := raw[:csrfNonceBytes] - signature := raw[csrfNonceBytes:] - mac := hmac.New(sha256.New, csrf.secret) + return raw[:csrfNonceBytes], raw[csrfNonceBytes:], true +} + +func csrfSignatureMatches(secret, nonce, binding, signature []byte) int { + mac := hmac.New(sha256.New, secret) mac.Write(nonce) mac.Write(binding) expected := mac.Sum(nil) - return subtle.ConstantTimeCompare(signature, expected) == 1 + return subtle.ConstantTimeCompare(signature, expected) } diff --git a/runtime/app/app_test.go b/runtime/app/app_test.go index 7109cc85..6cc6e9ba 100644 --- a/runtime/app/app_test.go +++ b/runtime/app/app_test.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "errors" + "fmt" "io" "mime/multipart" "net" @@ -1674,6 +1675,78 @@ func TestBackendRouterRejectsDuplicateRoutes(t *testing.T) { } } +func TestBackendRouterRejectsEquivalentDynamicRoutes(t *testing.T) { + handler := NotImplemented("missing") + tests := []struct { + name string + first string + second string + }{ + {name: "capture name", first: "/blog/{slug}", second: "/blog/{id}"}, + {name: "type annotation", first: "/patients/{id:int}", second: "/patients/{slug:string}"}, + {name: "rest capture name", first: "/files/{path...}", second: "/files/{rest...}"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := NewBackendRouter( + BackendRoute{Method: http.MethodGet, Path: test.first, Handler: handler}, + BackendRoute{Method: http.MethodGet, Path: test.second, Handler: handler}, + ) + if err == nil || !strings.Contains(err.Error(), "duplicate backend route GET "+test.second) { + t.Fatalf("expected equivalent route error, got %v", err) + } + }) + } +} + +func TestBackendRouterAllowsEquivalentDynamicRoutesForDifferentMethods(t *testing.T) { + handler := NotImplemented("missing") + if _, err := NewBackendRouter( + BackendRoute{Method: http.MethodGet, Path: "/blog/{slug}", Handler: handler}, + BackendRoute{Method: http.MethodPost, Path: "/blog/{id}", Handler: handler}, + ); err != nil { + t.Fatalf("expected methods to have separate route tables: %v", err) + } +} + +func TestBackendRouterPrefersStaticRouteOverDynamicRoute(t *testing.T) { + for _, staticFirst := range []bool{false, true} { + t.Run(fmt.Sprintf("static_first_%t", staticFirst), func(t *testing.T) { + dynamic := BackendRoute{ + Method: http.MethodGet, + Path: "/blog/{slug}", + Handler: func(writer http.ResponseWriter, _ *http.Request) bool { + writer.WriteHeader(http.StatusAccepted) + return true + }, + } + static := BackendRoute{ + Method: http.MethodGet, + Path: "/blog/archive", + Handler: func(writer http.ResponseWriter, _ *http.Request) bool { + writer.WriteHeader(http.StatusNoContent) + return true + }, + } + routes := []BackendRoute{dynamic, static} + if staticFirst { + routes[0], routes[1] = routes[1], routes[0] + } + router, err := NewBackendRouter(routes...) + if err != nil { + t.Fatal(err) + } + recorder := httptest.NewRecorder() + if !router.Dispatch(recorder, httptest.NewRequest(http.MethodGet, "/blog/archive", nil)) { + t.Fatal("expected static route to dispatch") + } + if recorder.Code != http.StatusNoContent { + t.Fatalf("expected static route status %d, got %d", http.StatusNoContent, recorder.Code) + } + }) + } +} + func TestBackendRouterOnlyDispatchesQueryRoutesForJSONRequests(t *testing.T) { router, err := NewBackendRouter(BackendRoute{ Method: http.MethodGet, @@ -1709,7 +1782,21 @@ func TestBackendRouterOnlyDispatchesQueryRoutesForJSONRequests(t *testing.T) { t.Fatalf("unexpected JSON query status: %d", jsonRecorder.Code) } + rejectedJSONRequest := httptest.NewRequest(http.MethodGet, "/patients", nil) + rejectedJSONRequest.Header.Set("Accept", "application/json;q=0, text/html") + if router.Dispatch(httptest.NewRecorder(), rejectedJSONRequest) { + t.Fatal("expected q=0 JSON request not to dispatch query route") + } + + multipleHeadersRequest := httptest.NewRequest(http.MethodGet, "/patients", nil) + multipleHeadersRequest.Header.Add("Accept", "text/html") + multipleHeadersRequest.Header.Add("Accept", "application/problem+json;q=0.5") + if !router.Dispatch(httptest.NewRecorder(), multipleHeadersRequest) { + t.Fatal("expected positive JSON range in multiple Accept headers to dispatch query route") + } + headerRequest := httptest.NewRequest(http.MethodGet, "/patients", nil) + headerRequest.Header.Set("Accept", "application/json;q=0") headerRequest.Header.Set("X-GOWDK-Query", "true") headerRecorder := httptest.NewRecorder() if !router.Dispatch(headerRecorder, headerRequest) { @@ -1717,6 +1804,35 @@ func TestBackendRouterOnlyDispatchesQueryRoutesForJSONRequests(t *testing.T) { } } +func TestAcceptsJSONRespectsQualityValues(t *testing.T) { + tests := []struct { + name string + header string + want bool + }{ + {name: "json", header: "application/json", want: true}, + {name: "uppercase", header: "APPLICATION/JSON", want: true}, + {name: "structured suffix", header: "application/problem+json", want: true}, + {name: "structured suffix wildcard", header: "application/*+json", want: true}, + {name: "positive quality", header: "text/html, application/json; q=0.5", want: true}, + {name: "zero quality", header: "application/json;q=0, text/html", want: false}, + {name: "zero structured suffix quality", header: "application/problem+json;q=0.000", want: false}, + {name: "invalid high quality", header: "application/json;q=1.001", want: false}, + {name: "invalid quality", header: "application/json;q=invalid", want: false}, + {name: "quoted comma", header: `application/json; note="a,b"; q=0`, want: false}, + {name: "wildcard", header: "*/*", want: false}, + {name: "non application suffix", header: "text/problem+json", want: true}, + {name: "malformed", header: `application/json; q="`, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := acceptsJSON(tt.header); got != tt.want { + t.Fatalf("acceptsJSON(%q) = %v, want %v", tt.header, got, tt.want) + } + }) + } +} + func TestBackendRouterRecoversActionPanic(t *testing.T) { router, err := NewBackendRouter(BackendRoute{ Method: http.MethodPost, @@ -2097,6 +2213,97 @@ func TestActionDataParsesMultipartFiles(t *testing.T) { } } +func TestMultipartRequestClassificationUsesStructuredMediaType(t *testing.T) { + tests := []struct { + name string + contentType string + want bool + }{ + {name: "valid", contentType: "multipart/form-data; boundary=gowdk", want: true}, + {name: "case insensitive", contentType: "Multipart/Form-Data; boundary=gowdk", want: true}, + {name: "invalid prefix only", contentType: "multipart/form-dataevil; boundary=gowdk", want: false}, + {name: "url encoded", contentType: "application/x-www-form-urlencoded", want: false}, + {name: "malformed boundary", contentType: "multipart/form-data; boundary=", want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/upload", nil) + request.Header.Set("Content-Type", tt.contentType) + if got := isMultipartRequest(request); got != tt.want { + t.Fatalf("isMultipartRequest(%q) = %v, want %v", tt.contentType, got, tt.want) + } + }) + } +} + +func TestActionDataRejectsMalformedMultipartBoundary(t *testing.T) { + for _, contentType := range []string{ + "multipart/form-data", + "multipart/form-data; boundary=", + } { + t.Run(contentType, func(t *testing.T) { + handler := ActionData(func(context.Context, form.Data) (response.Response, error) { + t.Fatal("handler should not run for malformed multipart content type") + return response.Response{}, nil + }) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/upload", strings.NewReader("not-multipart")) + request.Header.Set("Content-Type", contentType) + + handler(recorder, request) + + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusBadRequest) + } + }) + } +} + +func TestActionDataWithBodyLimitRejectsTooLargeMultipartBody(t *testing.T) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("avatar", "avatar.png") + if err != nil { + t.Fatal(err) + } + if _, err := part.Write([]byte(strings.Repeat("a", 256))); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + + handler := ActionDataWithBodyLimit(64, func(context.Context, form.Data) (response.Response, error) { + t.Fatal("handler should not run for oversized multipart body") + return response.Response{}, nil + }) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/upload", &body) + request.Header.Set("Content-Type", writer.FormDataContentType()) + + handler(recorder, request) + + if recorder.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusRequestEntityTooLarge) + } +} + +func TestActionValuesRejectsMalformedNonOversizedForm(t *testing.T) { + handler := ActionValuesWithBodyLimit(1024, func(context.Context, form.Values) (response.Response, error) { + t.Fatal("handler should not run for malformed form") + return response.Response{}, nil + }) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/submit", strings.NewReader("field=%")) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + handler(recorder, request) + + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusBadRequest) + } +} + func TestAPIHandlerCapsRequestBody(t *testing.T) { var readErr error handler := APIHandler(func(_ context.Context, request *http.Request) (response.Response, error) { @@ -2656,6 +2863,85 @@ func TestTracedBackendRouteMarksServerStatusError(t *testing.T) { } } +func TestBackendRouteInstrumentationModes(t *testing.T) { + kinds := []string{"action", "api", "query"} + modes := []struct { + name string + metrics bool + tracer bool + }{ + {name: "neither"}, + {name: "metrics only", metrics: true}, + {name: "tracer only", tracer: true}, + {name: "metrics and tracer", metrics: true, tracer: true}, + } + for _, kind := range kinds { + for _, mode := range modes { + t.Run(kind+"/"+mode.name, func(t *testing.T) { + method := http.MethodGet + if kind == "action" { + method = http.MethodPost + } + router, err := NewBackendRouter(BackendRoute{ + Method: method, + Path: "/patients", + Kind: kind, + EndpointID: "patients." + kind, + Handler: func(writer http.ResponseWriter, request *http.Request) bool { + writer.WriteHeader(http.StatusNoContent) + return true + }, + }) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(method, "/patients", nil) + if kind == "query" { + request.Header.Set("Accept", "application/json") + } + var metrics *Metrics + if mode.metrics { + metrics = &Metrics{} + request = request.WithContext(contextWithMetrics(request.Context(), metrics)) + } + var ring *gowdktrace.RingSink + if mode.tracer { + ring = gowdktrace.NewRingSink(4) + tracer := gowdktrace.NewTracer(gowdktrace.WithSink(ring)) + request = request.WithContext(gowdktrace.ContextWithTracer(request.Context(), tracer)) + } + recorder := httptest.NewRecorder() + + if !router.Dispatch(recorder, request) { + t.Fatal("expected backend handler to handle request") + } + if recorder.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusNoContent) + } + if mode.metrics { + snapshot := metrics.Snapshot() + if len(snapshot.Routes) != 1 { + t.Fatalf("route metrics = %#v, want one route", snapshot.Routes) + } + route := snapshot.Routes[0] + if route.Kind != kind || route.Requests != 1 || route.ActiveRequests != 0 { + t.Fatalf("unexpected route metrics: %#v", route) + } + } + if mode.tracer { + spans := waitForSpans(t, ring) + if len(spans) != 1 { + t.Fatalf("spans = %d, want 1", len(spans)) + } + if spans[0].Lane != backendTraceLane(kind) { + t.Fatalf("span lane = %q, want %q", spans[0].Lane, backendTraceLane(kind)) + } + } + }) + } + } +} + func TestTracedBackendRouteRecordsEndpointLanes(t *testing.T) { tests := []struct { kind string diff --git a/runtime/app/backend.go b/runtime/app/backend.go index b1e3685c..48077902 100644 --- a/runtime/app/backend.go +++ b/runtime/app/backend.go @@ -3,6 +3,7 @@ package app import ( "context" "fmt" + "mime" "net/http" "path" "strings" @@ -60,6 +61,7 @@ type backendRouteEntry struct { type backendPatternRouteEntry struct { key backendRouteKey + shape string kind string id string source gowdktrace.SourceRef @@ -112,12 +114,13 @@ func (router *BackendRouter) handle(route BackendRoute) error { } handler := BackendBoundary(kind, traceBackendRoute(kind, key.path, route.EndpointID, route.Source, route.Handler)) if backendRouteIsDynamic(key.path) { + shape := canonicalBackendRoutePattern(key.path) for _, existing := range router.patterns { - if existing.key == key { + if existing.key.method == key.method && existing.shape == shape { return fmt.Errorf("duplicate backend route %s %s", key.method, key.path) } } - router.patterns = append(router.patterns, backendPatternRouteEntry{key: key, kind: kind, id: route.EndpointID, source: route.Source, cors: routeCORS, handler: handler}) + router.patterns = append(router.patterns, backendPatternRouteEntry{key: key, shape: shape, kind: kind, id: route.EndpointID, source: route.Source, cors: routeCORS, handler: handler}) return nil } if _, exists := router.routes[key]; exists { @@ -280,8 +283,10 @@ func traceBackendRoute(kind, routePath string, endpointID string, source gowdktr traceRecorder = &traceResponseWriter{ResponseWriter: writer, status: http.StatusOK} writer = wrapTraceResponseWriter(traceRecorder) } - routeMetric, routeStart := metrics.startRoute(kind, routePath, endpointID) - defer func() { metrics.finishRoute(routeMetric, routeStart, traceRecorder.status) }() + if metrics != nil { + routeMetric, routeStart := metrics.startRoute(kind, routePath, endpointID) + defer func() { metrics.finishRoute(routeMetric, routeStart, traceRecorder.status) }() + } if !hasTracer { return handler(writer, request) } @@ -340,6 +345,25 @@ func backendRouteIsDynamic(routePath string) bool { return strings.Contains(routePath, "{") && strings.Contains(routePath, "}") } +func canonicalBackendRoutePattern(routePath string) string { + routePath = normalizeBackendPath(routePath) + segments := strings.Split(strings.Trim(routePath, "/"), "/") + if len(segments) == 1 && segments[0] == "" { + return "/" + } + for index, segment := range segments { + if !strings.HasPrefix(segment, "{") || !strings.HasSuffix(segment, "}") { + continue + } + if strings.HasSuffix(segment, "...}") { + segments[index] = "{...}" + continue + } + segments[index] = "{}" + } + return "/" + strings.Join(segments, "/") +} + func isContractQueryRequest(request *http.Request) bool { if request == nil { return false @@ -357,15 +381,85 @@ func isContractQueryRequest(request *http.Request) bool { } func acceptsJSON(header string) bool { - for _, part := range strings.Split(header, ",") { - mediaType := strings.ToLower(strings.TrimSpace(strings.Split(part, ";")[0])) - if mediaType == "application/json" || strings.HasSuffix(mediaType, "+json") { - return true + for _, part := range splitHTTPHeaderList(header) { + mediaType, params, err := mime.ParseMediaType(strings.TrimSpace(part)) + if err != nil || !isJSONMediaType(mediaType) || !hasPositiveAcceptQuality(params) { + continue } + return true } return false } +func splitHTTPHeaderList(value string) []string { + start := 0 + quoted := false + escaped := false + parts := make([]string, 0, strings.Count(value, ",")+1) + for index := 0; index < len(value); index++ { + switch current := value[index]; { + case escaped: + escaped = false + case quoted && current == '\\': + escaped = true + case current == '"': + quoted = !quoted + case current == ',' && !quoted: + parts = append(parts, value[start:index]) + start = index + 1 + } + } + return append(parts, value[start:]) +} + +func isJSONMediaType(mediaType string) bool { + mediaType = strings.ToLower(strings.TrimSpace(mediaType)) + if mediaType == "application/json" { + return true + } + return len(mediaType) > len("+json") && strings.HasSuffix(mediaType, "+json") +} + +func hasPositiveAcceptQuality(params map[string]string) bool { + quality, ok := params["q"] + if !ok { + return true + } + whole, fraction, decimal := strings.Cut(strings.TrimSpace(quality), ".") + switch whole { + case "0": + if !decimal { + return false + } + if len(fraction) > 3 { + return false + } + positive := false + for _, digit := range fraction { + if digit < '0' || digit > '9' { + return false + } + positive = positive || digit != '0' + } + return positive + case "1": + if !decimal { + return true + } + if len(fraction) > 3 { + return false + } + for _, digit := range fraction { + if digit != '0' { + return false + } + } + return true + default: + return false + } +} + // HandlerFunc returns the router as a generated runtime hook. func (router *BackendRouter) HandlerFunc() HandlerFunc { return func(writer http.ResponseWriter, request *http.Request) bool { @@ -551,7 +645,7 @@ func prepareActionValues(writer http.ResponseWriter, request *http.Request, body } request.Body = http.MaxBytesReader(writer, request.Body, normalizeBodyLimit(bodyLimit, DefaultActionBodyLimit)) if err := request.ParseForm(); err != nil { - if strings.Contains(err.Error(), "request body too large") { + if response.IsRequestBodyTooLarge(err) { response.WriteNoStoreError(writer, http.StatusRequestEntityTooLarge, "request body too large") return nil, nil, false } @@ -571,7 +665,7 @@ func prepareActionData(writer http.ResponseWriter, request *http.Request, bodyLi request.Body = http.MaxBytesReader(writer, request.Body, normalizeBodyLimit(bodyLimit, DefaultActionBodyLimit)) if isMultipartRequest(request) { if err := request.ParseMultipartForm(form.DefaultMultipartMemoryBytes); err != nil { - if strings.Contains(err.Error(), "request body too large") { + if response.IsRequestBodyTooLarge(err) { response.WriteNoStoreError(writer, http.StatusRequestEntityTooLarge, "request body too large") return nil, form.Data{}, nil, false } @@ -587,7 +681,7 @@ func prepareActionData(writer http.ResponseWriter, request *http.Request, bodyLi return ctx, form.FromMultipartForm(request.MultipartForm), cleanup, true } if err := request.ParseForm(); err != nil { - if strings.Contains(err.Error(), "request body too large") { + if response.IsRequestBodyTooLarge(err) { response.WriteNoStoreError(writer, http.StatusRequestEntityTooLarge, "request body too large") return nil, form.Data{}, nil, false } @@ -602,8 +696,12 @@ func isMultipartRequest(request *http.Request) bool { if request == nil { return false } - contentType := strings.ToLower(strings.TrimSpace(request.Header.Get("Content-Type"))) - return strings.HasPrefix(contentType, "multipart/form-data") + contentType := strings.TrimSpace(request.Header.Get("Content-Type")) + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + mediaType, _, _ = strings.Cut(contentType, ";") + } + return strings.EqualFold(strings.TrimSpace(mediaType), "multipart/form-data") } func normalizeBodyLimit(bodyLimit int64, fallback int64) int64 { diff --git a/runtime/envfile/envfile.go b/runtime/envfile/envfile.go index fa8ea20e..105f252d 100644 --- a/runtime/envfile/envfile.go +++ b/runtime/envfile/envfile.go @@ -4,6 +4,7 @@ package envfile import ( "bufio" + "errors" "fmt" "os" "path/filepath" @@ -11,6 +12,35 @@ import ( "sync" ) +const ( + // MaxLineBytes is the maximum logical line size accepted in an env file. + MaxLineBytes = 1 << 20 + + DiagnosticLineTooLong = "env_file_line_too_long" +) + +// DiagnosticError describes an env-file parse diagnostic without exposing the +// input value. +type DiagnosticError struct { + Code string + Path string + Line int + Limit int +} + +func (err *DiagnosticError) Error() string { + if err == nil { + return "" + } + return fmt.Sprintf( + "%s:%d: %s: env-file line exceeds the %d-byte limit; use process environment or secret injection for larger values", + err.Path, + err.Line, + err.Code, + err.Limit, + ) +} + // LoadResult describes one env-file load without exposing values. type LoadResult struct { Path string @@ -113,10 +143,15 @@ func ParseFile(path string) ([]Entry, error) { var entries []Entry scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 64<<10), MaxLineBytes+2) lineNumber := 0 for scanner.Scan() { lineNumber++ - line := strings.TrimSpace(scanner.Text()) + rawLine := scanner.Text() + if len(rawLine) > MaxLineBytes { + return nil, lineTooLongError(path, lineNumber) + } + line := strings.TrimSpace(rawLine) if line == "" || strings.HasPrefix(line, "#") { continue } @@ -129,11 +164,23 @@ func ParseFile(path string) ([]Entry, error) { } } if err := scanner.Err(); err != nil { + if errors.Is(err, bufio.ErrTooLong) { + return nil, lineTooLongError(path, lineNumber+1) + } return nil, err } return entries, nil } +func lineTooLongError(path string, lineNumber int) error { + return &DiagnosticError{ + Code: DiagnosticLineTooLong, + Path: path, + Line: lineNumber, + Limit: MaxLineBytes, + } +} + func parseLine(line string) (Entry, bool, error) { line = strings.TrimSpace(line) line = strings.TrimPrefix(line, "\ufeff") diff --git a/runtime/envfile/envfile_test.go b/runtime/envfile/envfile_test.go index f0fb0ea4..213d1d3a 100644 --- a/runtime/envfile/envfile_test.go +++ b/runtime/envfile/envfile_test.go @@ -1,6 +1,7 @@ package envfile import ( + "errors" "os" "path/filepath" "reflect" @@ -57,6 +58,64 @@ func TestParseFileRejectsInvalidLine(t *testing.T) { } } +func TestParseFileAcceptsValueLargerThanScannerDefault(t *testing.T) { + path := filepath.Join(t.TempDir(), ".env") + value := strings.Repeat("x", 70<<10) + if err := os.WriteFile(path, []byte("LONG_VALUE="+value+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + entries, err := ParseFile(path) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].Name != "LONG_VALUE" || entries[0].Value != value { + t.Fatalf("unexpected long env entry: %#v", entries) + } +} + +func TestParseFileAcceptsLineAtLimit(t *testing.T) { + path := filepath.Join(t.TempDir(), ".env") + prefix := "EXACT=" + value := strings.Repeat("x", MaxLineBytes-len(prefix)) + if err := os.WriteFile(path, []byte(prefix+value+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + entries, err := ParseFile(path) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].Value != value { + t.Fatalf("unexpected exact-limit env entry") + } +} + +func TestParseFileRejectsOversizedLineWithPathAndLine(t *testing.T) { + path := filepath.Join(t.TempDir(), ".env") + source := "OK=value\nTOO_LONG=" + strings.Repeat("x", MaxLineBytes) + "\n" + if err := os.WriteFile(path, []byte(source), 0o600); err != nil { + t.Fatal(err) + } + + _, err := ParseFile(path) + if err == nil { + t.Fatal("expected oversized env line error") + } + var diagnostic *DiagnosticError + if !errors.As(err, &diagnostic) { + t.Fatalf("expected typed env-file diagnostic, got %T: %v", err, err) + } + if diagnostic.Code != DiagnosticLineTooLong || diagnostic.Path != path || diagnostic.Line != 2 || diagnostic.Limit != MaxLineBytes { + t.Fatalf("unexpected env-file diagnostic: %#v", diagnostic) + } + for _, expected := range []string{path + ":2:", DiagnosticLineTooLong, "1048576-byte limit"} { + if !strings.Contains(err.Error(), expected) { + t.Fatalf("expected %q in %v", expected, err) + } + } +} + func TestLoadIntoEnvPreservesProcessValues(t *testing.T) { path := filepath.Join(t.TempDir(), ".env") if err := os.WriteFile(path, []byte("GOWDK_TEST_FILE_ONLY=file\nGOWDK_TEST_PROCESS=from-file\n"), 0o600); err != nil { diff --git a/runtime/response/response.go b/runtime/response/response.go index 2e7cfb51..33f6a1ff 100644 --- a/runtime/response/response.go +++ b/runtime/response/response.go @@ -144,14 +144,20 @@ func expectedErrorStatus(kind ErrorKind) int { } } +// IsRequestBodyTooLarge reports whether err contains the typed error returned +// by http.MaxBytesReader after a request exceeds its configured limit. +func IsRequestBodyTooLarge(err error) bool { + var maxBytesErr *http.MaxBytesError + return errors.As(err, &maxBytesErr) +} + // HandlerStatus returns a handler error status, or fallback for ordinary errors. func HandlerStatus(err error, fallback int) int { var handlerErr HandlerError if errors.As(err, &handlerErr) && handlerErr.Status != 0 { return handlerErr.Status } - var maxBytesErr *http.MaxBytesError - if errors.As(err, &maxBytesErr) { + if IsRequestBodyTooLarge(err) { return http.StatusRequestEntityTooLarge } return fallback diff --git a/runtime/response/response_test.go b/runtime/response/response_test.go index bbeeefe5..f38a1cf5 100644 --- a/runtime/response/response_test.go +++ b/runtime/response/response_test.go @@ -434,6 +434,23 @@ func TestHandlerError(t *testing.T) { } } +func TestIsRequestBodyTooLargeUsesTypedErrors(t *testing.T) { + limitErr := &http.MaxBytesError{Limit: 1024} + if !IsRequestBodyTooLarge(limitErr) { + t.Fatal("expected direct MaxBytesError to match") + } + wrapped := errors.Join(errors.New("parse form"), limitErr) + if !IsRequestBodyTooLarge(wrapped) { + t.Fatal("expected wrapped MaxBytesError to match") + } + if IsRequestBodyTooLarge(errors.New("unrelated: request body too large")) { + t.Fatal("message-only lookalike must not match") + } + if IsRequestBodyTooLarge(nil) { + t.Fatal("nil error must not match") + } +} + func TestHandlerErrorKeepsUnkeyedLiteralShape(t *testing.T) { err := HandlerError{http.StatusServiceUnavailable, "handler unavailable", errors.New("database unavailable")} diff --git a/runtime/trace/export_queue.go b/runtime/trace/export_queue.go new file mode 100644 index 00000000..2773ab18 --- /dev/null +++ b/runtime/trace/export_queue.go @@ -0,0 +1,155 @@ +package trace + +import ( + "context" + "errors" + "fmt" + "time" +) + +const ( + // DefaultExportQueueSize is the maximum number of completed spans waiting + // behind the one active sink export. + DefaultExportQueueSize = 256 + + // DefaultExportTimeout is the deadline applied to one sink export. + DefaultExportTimeout = 5 * time.Second +) + +func (tracer *Tracer) enqueueExport(snapshot Snapshot) { + if tracer == nil || tracer.sink == nil { + return + } + + tracer.exportMu.Lock() + capacity := tracer.exportQueueCapacity + if capacity <= 0 { + capacity = DefaultExportQueueSize + tracer.exportQueueCapacity = capacity + } + if len(tracer.exportQueue) >= capacity { + tracer.exportDropped.Add(1) + tracer.exportMu.Unlock() + return + } + tracer.exportQueue = append(tracer.exportQueue, snapshot) + tracer.exportAccepted++ + startWorker := !tracer.exportWorkerRunning + if startWorker { + tracer.exportWorkerRunning = true + } + tracer.exportMu.Unlock() + + if startWorker { + go tracer.drainExports() + } +} + +func (tracer *Tracer) drainExports() { + for { + snapshot, ok := tracer.nextExport() + if !ok { + return + } + tracer.exportOne(snapshot) + tracer.finishExport() + } +} + +func (tracer *Tracer) nextExport() (Snapshot, bool) { + tracer.exportMu.Lock() + defer tracer.exportMu.Unlock() + if len(tracer.exportQueue) == 0 { + tracer.exportWorkerRunning = false + tracer.exportInFlight = false + tracer.notifyExportChangeLocked() + return Snapshot{}, false + } + snapshot := tracer.exportQueue[0] + tracer.exportQueue[0] = Snapshot{} + tracer.exportQueue = tracer.exportQueue[1:] + if len(tracer.exportQueue) == 0 { + tracer.exportQueue = nil + } + tracer.exportInFlight = true + return snapshot, true +} + +func (tracer *Tracer) finishExport() { + tracer.exportMu.Lock() + tracer.exportInFlight = false + tracer.exportCompleted++ + tracer.notifyExportChangeLocked() + tracer.exportMu.Unlock() +} + +func (tracer *Tracer) exportOne(snapshot Snapshot) { + start := time.Now() + var ( + exportContext context.Context + exportErr error + ) + defer func() { + if recovered := recover(); recovered != nil { + exportErr = fmt.Errorf("panic: %v", recovered) + } + timedOut := exportContext != nil && errors.Is(exportContext.Err(), context.DeadlineExceeded) + if timedOut && exportErr == nil { + exportErr = context.DeadlineExceeded + } + tracer.recordExport(time.Since(start), exportErr, timedOut) + logSinkFailure(exportErr) + }() + + timeout := tracer.exportTimeout + if timeout <= 0 { + timeout = DefaultExportTimeout + } + var cancel context.CancelFunc + exportContext, cancel = context.WithTimeout(context.Background(), timeout) + defer cancel() + exportErr = tracer.sink.RecordSpan(exportContext, snapshot) +} + +// Flush waits until every span accepted by the export queue before the call +// has completed. It does not shut down or flush the configured sink's own +// buffers. +func (tracer *Tracer) Flush(ctx context.Context) error { + if tracer == nil { + return nil + } + if ctx == nil { + ctx = context.Background() + } + + tracer.exportMu.Lock() + target := tracer.exportAccepted + for tracer.exportCompleted < target { + wait := tracer.exportWaitChannelLocked() + tracer.exportMu.Unlock() + select { + case <-ctx.Done(): + return ctx.Err() + case <-wait: + } + tracer.exportMu.Lock() + } + tracer.exportMu.Unlock() + return nil +} + +func (tracer *Tracer) exportWaitChannelLocked() <-chan struct{} { + if tracer.exportChanged == nil { + tracer.exportChanged = make(chan struct{}) + } + return tracer.exportChanged +} + +func (tracer *Tracer) notifyExportChangeLocked() { + if tracer.exportChanged == nil { + tracer.exportChanged = make(chan struct{}) + return + } + close(tracer.exportChanged) + tracer.exportChanged = make(chan struct{}) +} diff --git a/runtime/trace/export_queue_test.go b/runtime/trace/export_queue_test.go new file mode 100644 index 00000000..51fccb36 --- /dev/null +++ b/runtime/trace/export_queue_test.go @@ -0,0 +1,196 @@ +package trace_test + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/cssbruno/gowdk/runtime/trace" +) + +func TestTracerExportQueuePreservesOrderAndFlushes(t *testing.T) { + sink := &recordingSink{} + tracer := trace.NewTracer( + trace.WithSink(sink), + trace.WithExportQueueSize(4), + ) + for _, name := range []string{"first", "second", "third"} { + endNamedSpan(t, tracer, name) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := tracer.Flush(ctx); err != nil { + t.Fatal(err) + } + + if len(sink.spans) != 3 { + t.Fatalf("expected three exported spans, got %#v", sink.spans) + } + for index, name := range []string{"first", "second", "third"} { + if sink.spans[index].Name != name { + t.Fatalf("export order = %#v", sink.spans) + } + } + health := tracer.HealthSnapshot() + if health.ExportedSpans != 3 || health.ExportFailures != 0 || health.ExportDroppedSpans != 0 { + t.Fatalf("unexpected export health: %#v", health) + } + if health.ExportQueueDepth != 0 || health.ExportQueueCapacity != 4 || health.ExportInFlight { + t.Fatalf("unexpected drained queue health: %#v", health) + } +} + +func TestTracerExportQueueCountsTimeout(t *testing.T) { + logged := captureSinkLogs(t) + sink := &deadlineSink{entered: make(chan struct{})} + tracer := trace.NewTracer( + trace.WithSink(sink), + trace.WithExportTimeout(10*time.Millisecond), + ) + endNamedSpan(t, tracer, "timeout") + + select { + case <-sink.entered: + case <-time.After(time.Second): + t.Fatal("timed out waiting for sink") + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := tracer.Flush(ctx); err != nil { + t.Fatal(err) + } + + health := tracer.HealthSnapshot() + if health.ExportedSpans != 0 || health.ExportFailures != 1 || health.ExportTimeouts != 1 { + t.Fatalf("unexpected timeout health: %#v", health) + } + _ = waitForSinkLog(t, logged) +} + +func TestTracerExportQueueBoundsBlockedSinkAndDropsNewest(t *testing.T) { + sink := newGatedSink() + tracer := trace.NewTracer( + trace.WithSink(sink), + trace.WithExportQueueSize(1), + trace.WithExportTimeout(time.Second), + ) + endNamedSpan(t, tracer, "first") + select { + case name := <-sink.entered: + if name != "first" { + t.Fatalf("first sink call = %q", name) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for blocked sink") + } + + endNamedSpan(t, tracer, "second") + for index := 0; index < 18; index++ { + endNamedSpan(t, tracer, "dropped") + } + + health := waitForTracerHealth(t, tracer, func(health trace.TracerHealthSnapshot) bool { + return health.ExportDroppedSpans == 18 + }) + if health.ExportQueueDepth != 1 || !health.ExportInFlight || health.ExportQueueCapacity != 1 { + t.Fatalf("unexpected blocked queue health: %#v", health) + } + if sink.maxActive.Load() != 1 { + t.Fatalf("expected one concurrent sink call, got %d", sink.maxActive.Load()) + } + + blockedCtx, blockedCancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer blockedCancel() + if err := tracer.Flush(blockedCtx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("blocked Flush error = %v, want deadline exceeded", err) + } + + close(sink.release) + flushCtx, flushCancel := context.WithTimeout(context.Background(), time.Second) + defer flushCancel() + if err := tracer.Flush(flushCtx); err != nil { + t.Fatal(err) + } + + names := sink.Names() + if len(names) != 2 || names[0] != "first" || names[1] != "second" { + t.Fatalf("drop-newest export order = %#v", names) + } + health = tracer.HealthSnapshot() + if health.ExportedSpans != 2 || health.ExportDroppedSpans != 18 || health.ExportFailures != 0 { + t.Fatalf("unexpected final queue health: %#v", health) + } + if sink.maxActive.Load() != 1 { + t.Fatalf("sink concurrency exceeded one: %d", sink.maxActive.Load()) + } +} + +func endNamedSpan(t *testing.T, tracer *trace.Tracer, name string) { + t.Helper() + _, span := tracer.Start(context.Background(), name) + if span == nil { + t.Fatalf("expected sampled span %q", name) + } + span.End() +} + +type deadlineSink struct { + entered chan struct{} + once sync.Once +} + +func (sink *deadlineSink) RecordSpan(ctx context.Context, _ trace.Snapshot) error { + sink.once.Do(func() { + close(sink.entered) + }) + <-ctx.Done() + return ctx.Err() +} + +type gatedSink struct { + entered chan string + release chan struct{} + active atomic.Int64 + maxActive atomic.Int64 + mu sync.Mutex + names []string +} + +func newGatedSink() *gatedSink { + return &gatedSink{ + entered: make(chan string, 4), + release: make(chan struct{}), + } +} + +func (sink *gatedSink) RecordSpan(_ context.Context, span trace.Snapshot) error { + active := sink.active.Add(1) + updateAtomicMax(&sink.maxActive, active) + defer sink.active.Add(-1) + + sink.mu.Lock() + sink.names = append(sink.names, span.Name) + sink.mu.Unlock() + sink.entered <- span.Name + <-sink.release + return nil +} + +func (sink *gatedSink) Names() []string { + sink.mu.Lock() + defer sink.mu.Unlock() + return append([]string(nil), sink.names...) +} + +func updateAtomicMax(target *atomic.Int64, value int64) { + for { + current := target.Load() + if value <= current || target.CompareAndSwap(current, value) { + return + } + } +} diff --git a/runtime/trace/span.go b/runtime/trace/span.go index 629d4323..29c88db4 100644 --- a/runtime/trace/span.go +++ b/runtime/trace/span.go @@ -2,7 +2,6 @@ package trace import ( "context" - "fmt" "log" "sync" "time" @@ -12,8 +11,6 @@ import ( type spanContextKey struct{} -const defaultSinkTimeout = 5 * time.Second - // SinkLogger receives completed-span export failures. Set it to nil to silence // sink failure logging. It defaults to the standard log package. var SinkLogger func(message string) = func(message string) { @@ -73,7 +70,6 @@ func (span *Span) EndTime(t time.Time) { return } var snapshot Snapshot - var sink Sink var tracer *Tracer span.mu.Lock() if span.ended { @@ -85,37 +81,13 @@ func (span *Span) EndTime(t time.Time) { snapshot = span.snapshotLocked() if span.tracer != nil { tracer = span.tracer - sink = span.tracer.sink } span.mu.Unlock() - if sink != nil { - recordSpanAsync(tracer, sink, snapshot) + if tracer != nil { + tracer.enqueueExport(snapshot) } } -func recordSpanAsync(tracer *Tracer, sink Sink, snapshot Snapshot) { - go func() { - start := time.Now() - var exportErr error - defer func() { - if recovered := recover(); recovered != nil { - exportErr = fmt.Errorf("panic: %v", recovered) - tracer.recordExport(time.Since(start), exportErr) - logSinkFailure(exportErr) - } - }() - ctx, cancel := context.WithTimeout(context.Background(), defaultSinkTimeout) - defer cancel() - if err := sink.RecordSpan(ctx, snapshot); err != nil { - exportErr = err - tracer.recordExport(time.Since(start), err) - logSinkFailure(err) - return - } - tracer.recordExport(time.Since(start), nil) - }() -} - func logSinkFailure(err error) { if err == nil || SinkLogger == nil { return diff --git a/runtime/trace/tracer.go b/runtime/trace/tracer.go index f48325e8..541bc7c9 100644 --- a/runtime/trace/tracer.go +++ b/runtime/trace/tracer.go @@ -3,6 +3,7 @@ package trace import ( "context" "strconv" + "sync" "sync/atomic" "time" ) @@ -17,8 +18,20 @@ type Tracer struct { sampledSpans atomic.Uint64 exportedSpans atomic.Uint64 exportFailures atomic.Uint64 + exportTimeouts atomic.Uint64 + exportDropped atomic.Uint64 lastExportNS atomic.Int64 maxExportNS atomic.Int64 + + exportMu sync.Mutex + exportQueue []Snapshot + exportQueueCapacity int + exportTimeout time.Duration + exportWorkerRunning bool + exportInFlight bool + exportAccepted uint64 + exportCompleted uint64 + exportChanged chan struct{} } // TracerOption configures a Tracer. @@ -31,6 +44,26 @@ func WithSink(sink Sink) TracerOption { } } +// WithExportQueueSize sets the maximum number of completed spans waiting for +// the single sink-export worker. Non-positive values keep the default. +func WithExportQueueSize(size int) TracerOption { + return func(tracer *Tracer) { + if size > 0 { + tracer.exportQueueCapacity = size + } + } +} + +// WithExportTimeout sets the deadline for one sink export. Non-positive values +// keep the default. +func WithExportTimeout(timeout time.Duration) TracerOption { + return func(tracer *Tracer) { + if timeout > 0 { + tracer.exportTimeout = timeout + } + } +} + // WithSampler sets the sampler. Nil means AlwaysOn. func WithSampler(sampler Sampler) TracerOption { return func(tracer *Tracer) { @@ -51,7 +84,13 @@ func WithIDGenerator(generator IDGenerator) TracerOption { // NewTracer creates a Tracer. func NewTracer(options ...TracerOption) *Tracer { - tracer := &Tracer{sampler: AlwaysOn(), idGen: defaultIDGenerator} + tracer := &Tracer{ + sampler: AlwaysOn(), + idGen: defaultIDGenerator, + exportQueueCapacity: DefaultExportQueueSize, + exportTimeout: DefaultExportTimeout, + exportChanged: make(chan struct{}), + } for _, option := range options { option(tracer) } @@ -327,6 +366,11 @@ type TracerHealthSnapshot struct { SampledSpans uint64 `json:"sampledSpans"` ExportedSpans uint64 `json:"exportedSpans"` ExportFailures uint64 `json:"exportFailures"` + ExportTimeouts uint64 `json:"exportTimeouts"` + ExportDroppedSpans uint64 `json:"exportDroppedSpans"` + ExportQueueDepth int `json:"exportQueueDepth"` + ExportQueueCapacity int `json:"exportQueueCapacity"` + ExportInFlight bool `json:"exportInFlight"` LastExportLatencyNS int64 `json:"lastExportLatencyNs"` MaxExportLatencyNS int64 `json:"maxExportLatencyNs"` } @@ -336,12 +380,22 @@ func (tracer *Tracer) HealthSnapshot() TracerHealthSnapshot { if tracer == nil { return TracerHealthSnapshot{} } + tracer.exportMu.Lock() + queueDepth := len(tracer.exportQueue) + queueCapacity := tracer.exportQueueCapacity + inFlight := tracer.exportInFlight + tracer.exportMu.Unlock() return TracerHealthSnapshot{ Sampler: samplerDescription(tracer.sampler), SamplingRatio: samplerRatio(tracer.sampler), SampledSpans: tracer.sampledSpans.Load(), ExportedSpans: tracer.exportedSpans.Load(), ExportFailures: tracer.exportFailures.Load(), + ExportTimeouts: tracer.exportTimeouts.Load(), + ExportDroppedSpans: tracer.exportDropped.Load(), + ExportQueueDepth: queueDepth, + ExportQueueCapacity: queueCapacity, + ExportInFlight: inFlight, LastExportLatencyNS: tracer.lastExportNS.Load(), MaxExportLatencyNS: tracer.maxExportNS.Load(), } @@ -368,7 +422,7 @@ func samplerRatio(sampler Sampler) string { return "" } -func (tracer *Tracer) recordExport(duration time.Duration, err error) { +func (tracer *Tracer) recordExport(duration time.Duration, err error, timedOut bool) { if tracer == nil { return } @@ -378,6 +432,9 @@ func (tracer *Tracer) recordExport(duration time.Duration, err error) { } tracer.lastExportNS.Store(ns) updateMaxInt64(&tracer.maxExportNS, ns) + if timedOut { + tracer.exportTimeouts.Add(1) + } if err != nil { tracer.exportFailures.Add(1) return