Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions documentation/en/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
* [Curio Service](curio-service.md)
* [Storage Configuration](storage-configuration.md)
* [Configuration](configuration/README.md)
* [Configuration editor fidelity](configuration/configuration-editor.md)
* [Listen Address](configuration/listen-address.md)
* [Prometheus Metrics](configuration/prometheus-metrics.md)
* [Metrics Reference](configuration/metrics-reference.md)
Expand Down
62 changes: 62 additions & 0 deletions documentation/en/configuration/configuration-editor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Configuration editor fidelity

The Curio Configuration editor reflects the current Curio configuration model.
Nested `Dynamic[T]` fields expose `T`, not the internal synchronization wrapper.
Durations are Go duration strings; FIL amounts are strings validated by the
runtime config decoder. The generated source comments supply field help.
The separately built Skiff editor intentionally exposes its smaller model.

A layer is a sparse set of overrides, not a complete effective configuration.
Checking a field includes an override. Zero, false, empty arrays, and values
equal to the public default must remain explicit when saved: they can override
an earlier layer. Unchecking a field removes that override, allowing earlier
layers or defaults to apply. Saving preserves values, not TOML comments or
formatting. Configuration history is not rewritten.

Supported TOML field names are normalized to canonical Go field names for the
editor; literal map keys are not renamed. The existing legacy address-table
and empty-Dynamic-table compatibility path remains in use. Truly unknown keys
stop editing/saving with an error instead of being silently removed. Use a
compatible binary or explicitly review such a layer; do not delete keys just
to dismiss the error. Unrepresentable JavaScript integers also stop the editor
instead of silently rounding a stored value.

The editor checks the loaded layer against the actual schema and verifies that
JSON Editor retained every existing value before enabling Save. It preserves
empty strings in arrays. The save handler independently validates the submitted
and existing layers using the runtime decoder. This is not optimistic locking
between simultaneous human editors, nor a change to runtime policy validation,
dynamic reload, restart requirements, or database schema.

## Regression checks

`TestUIConfigModelCompleteness` walks the actual GET schema and Curio model,
including nested references, structs, pointers, arrays, slices, maps, durations,
FIL, and Dynamic inner types. There are no field exclusions. An unsupported
future type or excluded model field fails and requires explicit review.
`TestUISchemaDocumentation` compares generated help with schema descriptions.

`TestUIGenericLayerConfigRoundTrip` exercises production layer load/save
preparation and runtime loading for subsystem settings, durations, and queue
limits. Additional tests protect explicit default overrides,
unknown keys, case normalization, and nested address/FIL values.

`TestUICustomConfigRoundTrip` additionally covers multiple SDR pacing intervals
and release controls. `layer-editor-curio.test.mjs` exercises those actual field
paths, including explicit false/zero overrides and untouched unknown values.

Database-free commands (use the repository's working native build environment,
with database connection variables and integration opt-ins absent):

```sh
go test -tags=cgo,fvm,nosupraseal -count=1 ./web/api/config ./deps/config
go test -race -tags=cgo,fvm,nosupraseal -count=1 ./web/api/config ./deps/config
node --test web/static/config/layer-editor.test.mjs
make cfgdoc-gen
make fiximports
```

These tests do not execute `harmony_config` SQL. Real GUI-instance validation
must separately verify the serving binary, schema response, loaded assets,
and a disposable layer's save/reload behavior. A field rendered in a local
browser fixture does not prove which binary a deployed GUI is serving.
71 changes: 65 additions & 6 deletions web/api/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http"
"reflect"
"strconv"
"strings"
"time"

"github.com/gorilla/mux"
Expand All @@ -22,8 +23,8 @@ import (
var log = logging.Logger("config-ui")

// durationPattern validates Go time.ParseDuration strings (e.g. "1h30m", "1m1s", "30s").
// Each clause is optional, but at least one number+unit pair is required.
const durationPattern = `^(\d+(\.\d+)?(h|m|s|ms|us|µs|ns))+$`
// Range and field-specific validation remain with the config loader/consumer.
const durationPattern = `^[+-]?(0|(([0-9]+(\.[0-9]*)?|\.[0-9]+)(ns|us|µs|μs|ms|s|m|h))+)$`

type cfg struct {
*deps.Deps
Expand Down Expand Up @@ -68,16 +69,18 @@ func uiSchemaMapper(i reflect.Type) *jsonschema.Schema {
if mapped := uiSchemaSpecialType(inner); mapped != nil {
return mapped
}
return (&jsonschema.Reflector{Mapper: uiSchemaMapper}).ReflectFromType(inner)
// A mapper returns a subschema, not a separate document. Root-relative
// references in a nested ReflectFromType result otherwise escape its $defs.
return (&jsonschema.Reflector{Mapper: uiSchemaMapper, DoNotReference: true, Anonymous: true}).ReflectFromType(inner)
}
return uiSchemaSpecialType(i)
}

func uiSchemaSpecialType(i reflect.Type) *jsonschema.Schema {
if i == reflect.TypeOf(types.MustParseFIL("1 Fil")) {
return &jsonschema.Schema{
Type: "string",
Pattern: "1 fil/0.03 fil/0.31/1 attofil",
Type: "string",
Description: "Decimal FIL amount, optionally suffixed with FIL or attoFIL; validated by the config loader.",
}
}
if i == reflect.TypeFor[time.Duration]() {
Expand Down Expand Up @@ -176,9 +179,65 @@ func buildUISchema() *jsonschema.Schema {
}
}
allOpt(sch)
addUIFieldDocs(sch, sch, reflect.TypeOf(uiSchemaRoot()))
return sch
}

// Follow the actual model so inline Dynamic elements receive the same help as
// named definitions. No list of individual configuration fields is maintained.
func addUIFieldDocs(root, node *jsonschema.Schema, typ reflect.Type) {
if inner, ok := config.DynamicInnerType(typ); ok {
typ = inner
}
for typ.Kind() == reflect.Pointer {
typ = typ.Elem()
}
if node == nil {
return
}
if node.Ref != "" {
node = root.Definitions[strings.TrimPrefix(node.Ref, "#/$defs/")]
}
if node == nil {
return
}
if typ == reflect.TypeFor[types.FIL]() || typ == reflect.TypeFor[time.Duration]() {
return
}
switch typ.Kind() {
case reflect.Struct:
for _, field := range config.Doc[typ.Name()] {
if node.Properties == nil {
continue
}
if prop, ok := node.Properties.Get(field.Name); ok && field.Comment != "" {
prop.Description = field.Comment
}
}
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
if !field.IsExported() || node.Properties == nil {
continue
}
name := field.Name
if tag := strings.Split(field.Tag.Get("json"), ",")[0]; tag != "" {
name = tag
}
if field.Anonymous && field.Tag.Get("json") == "" {
addUIFieldDocs(root, node, field.Type)
continue
}
if prop, ok := node.Properties.Get(name); ok {
addUIFieldDocs(root, prop, field.Type)
}
}
case reflect.Array, reflect.Slice:
addUIFieldDocs(root, node.Items, typ.Elem())
case reflect.Map:
addUIFieldDocs(root, node.AdditionalProperties, typ.Elem())
}
}

func (c *cfg) getLayers(w http.ResponseWriter, r *http.Request) {
var layers []string
apihelper.OrHTTPFail(w, c.DB.Select(context.Background(), &layers, `SELECT title FROM harmony_config ORDER BY title`))
Expand Down Expand Up @@ -209,7 +268,7 @@ func (c *cfg) setLayer(w http.ResponseWriter, r *http.Request) {
apihelper.OrHTTPFail(w, dec.Decode(&configStruct))

var existingToml string
_ = c.DB.QueryRow(context.Background(), `SELECT config FROM harmony_config WHERE title = $1`, layer).Scan(&existingToml)
apihelper.OrHTTPFail(w, c.DB.QueryRow(r.Context(), `SELECT config FROM harmony_config WHERE title = $1`, layer).Scan(&existingToml))

configStr, err := uiPrepareLayerSave(layer, configStruct, existingToml)
apihelper.OrHTTPFail(w, err)
Expand Down
1 change: 1 addition & 0 deletions web/api/config/duration_pattern_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ func TestDurationPattern(t *testing.T) {
re := regexp.MustCompile(durationPattern)

valid := []string{
"0", "0s", ".5h", "1.h", "+1m", "-1m", "1μs", "43m45s", "25m20s",
"0h0m0s",
"8h0m0s",
"1h30m",
Expand Down
Loading
Loading