From 1aefed471c8aef6f5d82054e7fb8c9399664be98 Mon Sep 17 00:00:00 2001 From: "beug.k@beugsiud.de" Date: Mon, 12 Oct 2026 21:08:59 +0200 Subject: [PATCH] feat(server): wire database, licensing, API and metrics into ddi-server - commands: serve, migrate, bootstrap-admin, gen-master-key - /metrics with route-pattern labels and license gauge, /readyz checks DB - fix OpenAPI flow mappings with commas; generate TypeScript API types (openapi-typescript) and check them in CI Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/ci.yml | 4 +- Makefile | 5 +- go.mod | 7 + go.sum | 22 + server/api/openapi.yaml | 4 +- server/cmd/ddi-server/main.go | 190 +- server/cmd/ddi-server/main_test.go | 191 +- server/internal/app/app.go | 206 +++ server/internal/app/metrics.go | 97 + server/internal/httpserver/server.go | 15 + server/internal/httpserver/server_test.go | 22 + web/.prettierignore | 1 + web/eslint.config.js | 2 +- web/package-lock.json | 300 ++++ web/package.json | 10 +- web/src/api/schema.d.ts | 1967 +++++++++++++++++++++ 16 files changed, 2921 insertions(+), 122 deletions(-) create mode 100644 server/internal/app/app.go create mode 100644 server/internal/app/metrics.go create mode 100644 web/src/api/schema.d.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 3e944c5..7e14637 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -65,7 +65,7 @@ jobs: - name: Lint and format run: make lint-proto - name: Generated code is up to date - run: make check-generated + run: buf generate && git diff --exit-code -- proto/gen web: name: Web (lint, test, audit, build) @@ -77,6 +77,8 @@ jobs: node-version: "24" - name: Install dependencies run: make web-deps + - name: API types are up to date with the OpenAPI spec + run: cd web && npm run gen:api && git diff --exit-code -- src/api/schema.d.ts - name: Lint, format, typecheck run: make lint-web - name: Test diff --git a/Makefile b/Makefile index 3ac034d..1d8fe72 100644 --- a/Makefile +++ b/Makefile @@ -81,15 +81,16 @@ web-deps: web: web-deps cd web && npm run build -## generate: regenerate protobuf code +## generate: regenerate protobuf code and the TypeScript API types .PHONY: generate generate: buf generate + cd web && npm run gen:api ## check-generated: fail if generated code is out of date .PHONY: check-generated check-generated: generate - git diff --exit-code -- proto/gen + git diff --exit-code -- proto/gen web/src/api/schema.d.ts # --------------------------------------------------------------------------- quality diff --git a/go.mod b/go.mod index b8d7fd8..29fecd0 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/getkin/kin-openapi v0.149.0 github.com/jackc/pgx/v5 v5.11.0 github.com/pressly/goose/v3 v3.29.0 + github.com/prometheus/client_golang v1.25.0 go.yaml.in/yaml/v3 v3.0.5 golang.org/x/crypto v0.58.0 golang.org/x/oauth2 v0.37.0 @@ -19,6 +20,8 @@ require ( ) require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-openapi/jsonpointer v0.22.5 // indirect github.com/go-openapi/swag/jsonname v0.25.5 // indirect @@ -26,8 +29,12 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.3 // indirect github.com/mfridman/interpolate v0.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/oasdiff/yaml v0.1.1 // indirect github.com/oasdiff/yaml3 v0.0.14 // indirect + github.com/prometheus/client_model v0.6.3 // indirect + github.com/prometheus/common v0.72.0 // indirect + github.com/prometheus/procfs v0.22.0 // indirect github.com/rogpeppe/go-internal v1.16.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect github.com/sethvargo/go-retry v0.5.0 // indirect diff --git a/go.sum b/go.sum index 4647b9d..2515366 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM= github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -35,14 +39,20 @@ github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.3 h1:f8HoREISb3l5eO9coPgK7U3hKLhWGokOFVcThl5jrao= github.com/jackc/puddle/v2 v2.2.3/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk= github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g= github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY= @@ -52,6 +62,14 @@ github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pressly/goose/v3 v3.29.0 h1:dtEnBCT7CqkHIUCdgrXsrmeisaN8gFkRU4Y5OEwif4o= github.com/pressly/goose/v3 v3.29.0/go.mod h1:plA4aP4+u+Q3FMTgbFbhF8TuAWpAdfWpoFGkJsIFWh0= +github.com/prometheus/client_golang v1.25.0 h1:cDtW1TcFsMi1RJc8khwCKB4VhzwV04aKqbYtNOXuiK0= +github.com/prometheus/client_golang v1.25.0/go.mod h1:j2X/4/kaQHLPnmyEU8zjmkndo1T317VpCIQEml8LTKk= +github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= +github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM= +github.com/prometheus/common v0.72.0 h1:tAYsE+sPJxIncDAobm4H5aQjmox9ZxEIIqPbiffa8G4= +github.com/prometheus/common v0.72.0/go.mod h1:77NWqAQ2tXT7BIK40qjJdw5Acrsrg1TlHAnsQi3i6mk= +github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics= +github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= @@ -65,8 +83,12 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= diff --git a/server/api/openapi.yaml b/server/api/openapi.yaml index 0533786..d99271d 100644 --- a/server/api/openapi.yaml +++ b/server/api/openapi.yaml @@ -850,7 +850,7 @@ components: type: { type: string } title: { type: string } status: { type: integer } - code: { type: string, description: Stable error code, translated by the UI. } + code: { type: string, description: "Stable error code, translated by the UI." } detail: { type: string } request_id: { type: string } params: { type: object, additionalProperties: { type: string } } @@ -970,7 +970,7 @@ components: required: [state, reason, limits, features, fingerprint, usage] properties: state: { $ref: "#/components/schemas/LicenseState" } - reason: { type: string, description: Translated by the UI (ok, trial, trial_expired, unreachable, expired, suspended, …). } + reason: { type: string, description: "Translated by the UI (ok, trial, trial_expired, unreachable, expired, suspended, …)." } source: { type: string, enum: [online, file] } key_hint: { type: string } license_id: { type: string } diff --git a/server/cmd/ddi-server/main.go b/server/cmd/ddi-server/main.go index 60d3ea4..db6442b 100644 --- a/server/cmd/ddi-server/main.go +++ b/server/cmd/ddi-server/main.go @@ -6,20 +6,22 @@ package main import ( "context" "errors" + "flag" "fmt" "io" "log/slog" - "net/http" "os" "os/signal" + "path/filepath" "syscall" "git.beugsiud.de/beug.k/DDI/internal/brand" "git.beugsiud.de/beug.k/DDI/internal/buildinfo" "git.beugsiud.de/beug.k/DDI/internal/logging" + "git.beugsiud.de/beug.k/DDI/server/internal/app" "git.beugsiud.de/beug.k/DDI/server/internal/config" - "git.beugsiud.de/beug.k/DDI/server/internal/httpserver" - "git.beugsiud.de/beug.k/DDI/web" + "git.beugsiud.de/beug.k/DDI/server/internal/secrets" + "git.beugsiud.de/beug.k/DDI/server/internal/service" ) func main() { @@ -30,12 +32,15 @@ func main() { } func usage(w io.Writer) { - _, _ = fmt.Fprintf(w, `Usage: %[1]s + _, _ = fmt.Fprintf(w, `Usage: %[1]s [options] Commands: - serve run the management server (default) - version print version information - help show this help + serve run the management server (default) + migrate apply database migrations and exit + bootstrap-admin create the first administrator and print its one-time password + gen-master-key write a new master key file (refuses to overwrite) + version print version information + help show this help Configuration: YAML file from $DDI_CONFIG_FILE (if set), overridden by environment variables; see docs/ARCHITECTURE.md. @@ -45,89 +50,136 @@ environment variables; see docs/ARCHITECTURE.md. func run(ctx context.Context, args []string, lookup config.LookupFunc, stdout, stderr io.Writer) int { cmd := "serve" if len(args) > 0 { - cmd = args[0] + cmd, args = args[0], args[1:] } + var err error switch cmd { case "serve": - if err := serve(ctx, lookup, stderr); err != nil { - _, _ = fmt.Fprintf(stderr, "%s: %v\n", brand.ServerBinary, err) - return 1 - } - return 0 + err = cmdServe(ctx, lookup, stderr) + case "migrate": + err = cmdMigrate(ctx, lookup, stderr) + case "bootstrap-admin": + err = cmdBootstrapAdmin(ctx, args, lookup, stdout, stderr) + case "gen-master-key": + err = cmdGenMasterKey(args, lookup, stdout) case "version", "--version": - if err := buildinfo.Print(stdout, brand.ServerBinary); err != nil { - return 1 - } - return 0 + err = buildinfo.Print(stdout, brand.ServerBinary) case "help", "-h", "--help": usage(stdout) - return 0 default: _, _ = fmt.Fprintf(stderr, "%s: unknown command %q\n\n", brand.ServerBinary, cmd) usage(stderr) return 2 } -} - -// configPath returns the configuration file named by DDI_CONFIG_FILE. -func configPath(lookup config.LookupFunc) string { - p, _ := lookup(config.EnvName("CONFIG_FILE")) - return p -} - -func serve(ctx context.Context, lookup config.LookupFunc, logOut io.Writer) error { - cfg, err := config.Load(configPath(lookup), lookup) if err != nil { - return fmt.Errorf("invalid configuration: %w", err) + _, _ = fmt.Fprintf(stderr, "%s: %v\n", brand.ServerBinary, err) + return 1 + } + return 0 +} + +// setup loads the configuration and creates the logger. +func setup(lookup config.LookupFunc, logOut io.Writer) (config.Config, *slog.Logger, error) { + path, _ := lookup(config.EnvName("CONFIG_FILE")) + cfg, err := config.Load(path, lookup) + if err != nil { + return config.Config{}, nil, fmt.Errorf("invalid configuration: %w", err) } log, err := logging.New(logOut, logging.Options{Level: cfg.Log.Level, Format: cfg.Log.Format}) if err != nil { - return fmt.Errorf("invalid configuration: %w", err) + return config.Config{}, nil, fmt.Errorf("invalid configuration: %w", err) } + return cfg, log, nil +} - srv := httpserver.New(httpserver.Options{ - Addr: cfg.HTTP.Listen, - TLS: cfg.TLSEnabled(), - Logger: log, - UI: web.Handler(), - }) - - info := buildinfo.Get() - log.Info("starting server", - slog.String("version", info.Version), - slog.String("commit", info.Commit), - slog.String("listen", cfg.HTTP.Listen), - slog.Bool("tls", cfg.TLSEnabled()), - ) - if !cfg.TLSEnabled() { - log.Warn("TLS is disabled; use only for local development", - slog.String("hint", "set "+config.EnvName("TLS_CERT_FILE")+" and "+config.EnvName("TLS_KEY_FILE"))) +func cmdServe(ctx context.Context, lookup config.LookupFunc, logOut io.Writer) error { + cfg, log, err := setup(lookup, logOut) + if err != nil { + return err } - - errCh := make(chan error, 1) - go func() { - if cfg.TLSEnabled() { - errCh <- srv.ListenAndServeTLS(cfg.HTTP.TLSCertFile, cfg.HTTP.TLSKeyFile) - } else { - errCh <- srv.ListenAndServe() - } - }() - - select { - case err := <-errCh: - return fmt.Errorf("http listener: %w", err) - case <-ctx.Done(): + core, err := app.Open(ctx, cfg, log, cfg.AutoMigrate()) + if err != nil { + return err } + defer core.Close() + return app.Serve(ctx, core) +} - log.Info("shutting down", slog.Duration("timeout", cfg.ShutdownTimeout.Std())) - shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cfg.ShutdownTimeout.Std()) - defer cancel() - if err := srv.Shutdown(shutdownCtx); err != nil { - return fmt.Errorf("graceful shutdown: %w", err) +func cmdMigrate(ctx context.Context, lookup config.LookupFunc, logOut io.Writer) error { + cfg, log, err := setup(lookup, logOut) + if err != nil { + return err } - if err := <-errCh; err != nil && !errors.Is(err, http.ErrServerClosed) { - return fmt.Errorf("http listener: %w", err) + core, err := app.Open(ctx, cfg, log, true) + if err != nil { + return err } - log.Info("stopped") + core.Close() + log.Info("database is up to date") return nil } + +func cmdBootstrapAdmin(ctx context.Context, args []string, lookup config.LookupFunc, stdout, logOut io.Writer) error { + fs := flag.NewFlagSet("bootstrap-admin", flag.ContinueOnError) + fs.SetOutput(logOut) + username := fs.String("username", "admin", "name of the first administrator") + if err := fs.Parse(args); err != nil { + return err + } + cfg, log, err := setup(lookup, logOut) + if err != nil { + return err + } + core, err := app.Open(ctx, cfg, log, true) + if err != nil { + return err + } + defer core.Close() + pw, err := core.Service.BootstrapAdmin(ctx, *username) + if errors.Is(err, service.ErrAlreadyInitialized) { + return errors.New("users already exist; use the web interface to manage them") + } + if err != nil { + return err + } + _, err = fmt.Fprintf(stdout, "username: %s\npassword: %s\n(one-time password; it must be changed at the first login)\n", *username, pw) + return err +} + +func cmdGenMasterKey(args []string, lookup config.LookupFunc, stdout io.Writer) error { + fs := flag.NewFlagSet("gen-master-key", flag.ContinueOnError) + out := fs.String("out", "", "path of the key file (default: configured master_key_file)") + keyID := fs.String("id", "k1", "key ID") + if err := fs.Parse(args); err != nil { + return err + } + path := *out + if path == "" { + cfgPath, _ := lookup(config.EnvName("CONFIG_FILE")) + cfg, err := config.Load(cfgPath, lookup) + if err != nil { + return fmt.Errorf("invalid configuration: %w", err) + } + path = cfg.MasterKeyFile + } + data, err := secrets.GenerateKeyFile(*keyID) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return err + } + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o400) //nolint:gosec // operator-chosen path + if err != nil { + return fmt.Errorf("create master key file (it must not exist yet): %w", err) + } + if _, err := f.Write(data); err != nil { + _ = f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + _, err = fmt.Fprintf(stdout, "master key written to %s; back it up, without it encrypted data cannot be recovered\n", path) + return err +} diff --git a/server/cmd/ddi-server/main_test.go b/server/cmd/ddi-server/main_test.go index 754b13f..350325f 100644 --- a/server/cmd/ddi-server/main_test.go +++ b/server/cmd/ddi-server/main_test.go @@ -5,14 +5,21 @@ package main import ( "bytes" "context" + "crypto/rand" + "encoding/hex" "fmt" "io" "net" "net/http" + "net/url" + "os" + "path/filepath" "strings" "sync" "testing" "time" + + "github.com/jackc/pgx/v5" ) func lookupFrom(m map[string]string) func(string) (string, bool) { @@ -27,9 +34,8 @@ func TestRunVersionAndHelp(t *testing.T) { if !strings.HasPrefix(out.String(), "ddi-server ") { t.Errorf("version output %q", out.String()) } - out.Reset() - if code := run(context.Background(), []string{"help"}, lookupFrom(nil), &out, &errOut); code != 0 || !strings.Contains(out.String(), "Usage:") { + if code := run(context.Background(), []string{"help"}, lookupFrom(nil), &out, &errOut); code != 0 || !strings.Contains(out.String(), "bootstrap-admin") { t.Errorf("help: code %d, output %q", code, out.String()) } } @@ -39,9 +45,6 @@ func TestRunUnknownCommand(t *testing.T) { if code := run(context.Background(), []string{"frobnicate"}, lookupFrom(nil), &out, &errOut); code != 2 { t.Errorf("exit code %d, want 2", code) } - if !strings.Contains(errOut.String(), "unknown command") { - t.Errorf("stderr %q", errOut.String()) - } } func TestRunInvalidConfig(t *testing.T) { @@ -52,6 +55,64 @@ func TestRunInvalidConfig(t *testing.T) { } } +func TestServeFailsWithoutMasterKey(t *testing.T) { + var errOut bytes.Buffer + env := map[string]string{"DDI_MASTER_KEY_FILE": filepath.Join(t.TempDir(), "missing.key"), "DDI_DATABASE_URL": "postgres://x"} + if code := run(context.Background(), []string{"serve"}, lookupFrom(env), io.Discard, &errOut); code != 1 || !strings.Contains(errOut.String(), "master key") { + t.Errorf("code %d, stderr %q", code, errOut.String()) + } +} + +func TestGenMasterKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "sub", "master.key") + var out, errOut bytes.Buffer + if code := run(context.Background(), []string{"gen-master-key", "-out", path}, lookupFrom(nil), &out, &errOut); code != 0 { + t.Fatalf("code %d: %s", code, errOut.String()) + } + data, err := os.ReadFile(path) + if err != nil || !strings.Contains(string(data), "k1 ") { + t.Fatalf("key file: %q %v", data, err) + } + if code := run(context.Background(), []string{"gen-master-key", "-out", path}, lookupFrom(nil), &out, &errOut); code != 1 { + t.Error("existing key file must not be overwritten") + } +} + +// testDatabase creates a private schema and returns a URL that uses it. +func testDatabase(t *testing.T) string { + t.Helper() + base := os.Getenv("DDI_TEST_DATABASE_URL") + if base == "" { + if os.Getenv("DDI_REQUIRE_DB_TESTS") == "1" { + t.Fatal("DDI_TEST_DATABASE_URL is not set but DDI_REQUIRE_DB_TESTS=1") + } + t.Skip("DDI_TEST_DATABASE_URL not set") + } + var b [6]byte + _, _ = rand.Read(b[:]) + schema := "t_" + hex.EncodeToString(b[:]) + ctx := context.Background() + conn, err := pgx.Connect(ctx, base) + if err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(ctx, "CREATE SCHEMA "+schema); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = conn.Exec(context.Background(), "DROP SCHEMA "+schema+" CASCADE") + _ = conn.Close(context.Background()) + }) + u, err := url.Parse(base) + if err != nil { + t.Fatal(err) + } + q := u.Query() + q.Set("search_path", schema) + u.RawQuery = q.Encode() + return u.String() +} + func freeAddr(t *testing.T) string { t.Helper() l, err := net.Listen("tcp", "127.0.0.1:0") @@ -63,47 +124,6 @@ func freeAddr(t *testing.T) string { return addr } -// TestServeStartsAndStopsGracefully runs the real server on a loopback port. -func TestServeStartsAndStopsGracefully(t *testing.T) { - addr := freeAddr(t) - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan int, 1) - var logs bytes.Buffer - go func() { - done <- run(ctx, nil, lookupFrom(map[string]string{"DDI_HTTP_LISTEN": addr}), io.Discard, &syncWriter{w: &logs}) - }() - - url := fmt.Sprintf("http://%s/healthz", addr) - var resp *http.Response - deadline := time.Now().Add(5 * time.Second) - for { - var err error - resp, err = http.Get(url) //nolint:noctx // test helper polling a local server - if err == nil { - break - } - if time.Now().After(deadline) { - cancel() - t.Fatalf("server did not come up: %v", err) - } - time.Sleep(20 * time.Millisecond) - } - _ = resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Errorf("healthz status %d", resp.StatusCode) - } - - cancel() - select { - case code := <-done: - if code != 0 { - t.Errorf("exit code %d", code) - } - case <-time.After(10 * time.Second): - t.Fatal("server did not shut down") - } -} - // syncWriter serializes writes from the server goroutines. type syncWriter struct { mu sync.Mutex @@ -115,3 +135,82 @@ func (s *syncWriter) Write(p []byte) (int, error) { defer s.mu.Unlock() return s.w.Write(p) } + +// TestServeEndToEnd runs the real server against the test database. +func TestServeEndToEnd(t *testing.T) { + dbURL := testDatabase(t) + dir := t.TempDir() + keyFile := filepath.Join(dir, "master.key") + if code := run(context.Background(), []string{"gen-master-key", "-out", keyFile}, lookupFrom(nil), io.Discard, io.Discard); code != 0 { + t.Fatal("gen-master-key failed") + } + addr := freeAddr(t) + env := map[string]string{ + "DDI_HTTP_LISTEN": addr, "DDI_DATABASE_URL": dbURL, "DDI_MASTER_KEY_FILE": keyFile, "DDI_STATE_DIR": dir, + "KEYGEN_URL": "http://127.0.0.1:1", "KEYGEN_ACCOUNT_ID": "9de54794-8e94-4250-83f6-87e3d5686a0b", + "KEYGEN_PUBLIC_KEY": "40495568fa239e44736e28949ae2ae93f2c9b6e6bc6d917f4c8cc0eb629e3c62", + } + + var out bytes.Buffer + if code := run(context.Background(), []string{"bootstrap-admin", "-username", "root"}, lookupFrom(env), &out, io.Discard); code != 0 { + t.Fatalf("bootstrap-admin failed: %s", out.String()) + } + if !strings.Contains(out.String(), "username: root") || !strings.Contains(out.String(), "password: ") { + t.Errorf("bootstrap output %q", out.String()) + } + if code := run(context.Background(), []string{"bootstrap-admin"}, lookupFrom(env), io.Discard, io.Discard); code != 1 { + t.Error("second bootstrap must fail") + } + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan int, 1) + var logs bytes.Buffer + go func() { done <- run(ctx, []string{"serve"}, lookupFrom(env), io.Discard, &syncWriter{w: &logs}) }() + + base := fmt.Sprintf("http://%s", addr) + get := func(path string) (int, string) { + t.Helper() + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, base+path, nil) + res, err := http.DefaultClient.Do(req) + if err != nil { + return 0, err.Error() + } + defer res.Body.Close() //nolint:errcheck // test + b, _ := io.ReadAll(res.Body) + return res.StatusCode, string(b) + } + deadline := time.Now().Add(15 * time.Second) + for { + if code, _ := get("/healthz"); code != 0 { + break + } + if time.Now().After(deadline) { + cancel() + t.Fatalf("server did not come up:\n%s", logs.String()) + } + time.Sleep(50 * time.Millisecond) + } + for path, want := range map[string]string{ + "/readyz": `"database":"ok"`, + "/api/v1/system/info": `"license_state":"trial"`, + "/metrics": "ddi_license_writable 1", + } { + code, body := get(path) + if code != http.StatusOK || !strings.Contains(body, want) { + t.Errorf("%s: %d %q", path, code, body) + } + } + if code, body := get("/api/v1/networks"); code != http.StatusUnauthorized || !strings.Contains(body, "unauthenticated") { + t.Errorf("unauthenticated API access: %d %q", code, body) + } + + cancel() + select { + case code := <-done: + if code != 0 { + t.Errorf("exit code %d:\n%s", code, logs.String()) + } + case <-time.After(20 * time.Second): + t.Fatal("server did not shut down") + } +} diff --git a/server/internal/app/app.go b/server/internal/app/app.go new file mode 100644 index 0000000..72e8280 --- /dev/null +++ b/server/internal/app/app.go @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: LicenseRef-DDI-Proprietary + +// Package app wires the management server together: configuration, +// database, secrets, licensing, services, HTTP API and background jobs. +package app + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "os" + "time" + + "git.beugsiud.de/beug.k/DDI/internal/brand" + "git.beugsiud.de/beug.k/DDI/internal/buildinfo" + "git.beugsiud.de/beug.k/DDI/server/internal/config" + "git.beugsiud.de/beug.k/DDI/server/internal/httpapi" + "git.beugsiud.de/beug.k/DDI/server/internal/httpserver" + "git.beugsiud.de/beug.k/DDI/server/internal/license" + "git.beugsiud.de/beug.k/DDI/server/internal/secrets" + "git.beugsiud.de/beug.k/DDI/server/internal/service" + "git.beugsiud.de/beug.k/DDI/server/internal/store" + "git.beugsiud.de/beug.k/DDI/web" +) + +// Persistence adapts the store to license.Persistence. +type Persistence struct{ Store *store.Store } + +// GetLicenseState implements license.Persistence. +func (p Persistence) GetLicenseState(ctx context.Context) ([]byte, error) { + b, err := p.Store.Q().GetLicenseState(ctx) + if errors.Is(err, store.ErrNotFound) { + return nil, license.ErrNoState + } + return b, err +} + +// PutLicenseState implements license.Persistence. +func (p Persistence) PutLicenseState(ctx context.Context, d []byte) error { + return p.Store.Q().PutLicenseState(ctx, d) +} + +// DeleteLicenseState implements license.Persistence. +func (p Persistence) DeleteLicenseState(ctx context.Context) error { + return p.Store.Q().DeleteLicenseState(ctx) +} + +// EnsureInstalledAt implements license.Persistence. +func (p Persistence) EnsureInstalledAt(ctx context.Context, now time.Time) (time.Time, error) { + return p.Store.Q().EnsureInstalledAt(ctx, now) +} + +// Core holds the components shared by the server and the CLI commands. +type Core struct { + Config config.Config + Store *store.Store + Keyring *secrets.Keyring + License *license.Manager + Service *service.Service + Log *slog.Logger +} + +// Close releases resources. +func (c *Core) Close() { + if c.Store != nil { + c.Store.Close() + } +} + +// Open connects to the database, optionally migrates it and builds the +// service layer. +func Open(ctx context.Context, cfg config.Config, log *slog.Logger, migrate bool) (*Core, error) { + kr, err := secrets.LoadKeyFile(cfg.MasterKeyFile) + if err != nil { + return nil, fmt.Errorf("master key: %w", err) + } + st, err := store.Open(ctx, cfg.Database.URL, cfg.Database.MaxConns) + if err != nil { + return nil, err + } + core := &Core{Config: cfg, Store: st, Keyring: kr, Log: log} + if migrate { + if err := st.Migrate(ctx, log); err != nil { + st.Close() + return nil, err + } + } + if err := core.initLicense(ctx); err != nil { + st.Close() + return nil, err + } + core.Service = service.New(service.Options{ + Store: st, Keyring: kr, License: core.License, ProductName: brand.DisplayName, + SessionLifetime: cfg.Session.Lifetime.Std(), SessionIdle: cfg.Session.IdleTimeout.Std(), Logger: log, + }) + return core, nil +} + +func (c *Core) initLicense(ctx context.Context) error { + trust, err := license.ResolveTrust(c.Config.License.AccountID, c.Config.License.PublicKey) + if err != nil { + return err + } + provider, err := license.NewKeygenProvider(license.KeygenConfig{ + BaseURL: c.Config.License.KeygenURL, AccountID: trust.AccountID, ProductID: c.Config.License.ProductID, + PublicKey: trust.PublicKey, + }) + if err != nil { + return err + } + fp, err := license.Fingerprint(brand.Product, c.Config.StateDir) + if err != nil { + return fmt.Errorf("machine fingerprint: %w", err) + } + host, _ := os.Hostname() + c.License = license.NewManager(license.ManagerOptions{ + Provider: provider, Persistence: Persistence{Store: c.Store}, Keyring: c.Keyring, PublicKey: trust.PublicKey, + ProductID: c.Config.License.ProductID, Fingerprint: fp, Hostname: host, + Grace: c.Config.License.GracePeriod.Std(), Revalidate: c.Config.License.RevalidateInterval.Std(), + Logger: c.Log.With(slog.String("component", "license")), + }) + if err := c.License.Load(ctx); err != nil { + return err + } + if !trust.Embedded { + c.Log.Warn("license trust anchor comes from configuration (development build)") + } + return nil +} + +// Serve runs the HTTP server and background jobs until ctx is canceled. +func Serve(ctx context.Context, core *Core) error { + cfg, log := core.Config, core.Log + secure := cfg.TLSEnabled() + if u, err := url.Parse(cfg.HTTP.PublicURL); err == nil && u.Scheme == "https" { + secure = true + } + info := buildinfo.Get() + metrics := newMetrics(core.License) + api := httpapi.New(httpapi.Options{ + Service: core.Service, PublicURL: cfg.HTTP.PublicURL, SecureCookies: secure, + ProductName: brand.DisplayName, Version: info.Version, Logger: log, + }) + srv := httpserver.New(httpserver.Options{ + Addr: cfg.HTTP.Listen, TLS: cfg.TLSEnabled(), Logger: log, UI: web.Handler(), API: api.Handler(), + Metrics: metrics.handler(), Middleware: metrics.middleware, + Readiness: map[string]httpserver.ReadinessCheck{"database": core.Store.Ping}, + }) + + jobs, stopJobs := context.WithCancel(ctx) + defer stopJobs() + go core.License.Run(jobs) + go cleanupSessions(jobs, core) + + log.Info("starting server", slog.String("version", info.Version), slog.String("commit", info.Commit), + slog.String("listen", cfg.HTTP.Listen), slog.Bool("tls", cfg.TLSEnabled()), + slog.String("license_state", string(core.License.Status().State))) + if !cfg.TLSEnabled() { + log.Warn("TLS is disabled; use only for local development or behind a TLS-terminating proxy") + } + + errCh := make(chan error, 1) + go func() { + if cfg.TLSEnabled() { + errCh <- srv.ListenAndServeTLS(cfg.HTTP.TLSCertFile, cfg.HTTP.TLSKeyFile) + } else { + errCh <- srv.ListenAndServe() + } + }() + select { + case err := <-errCh: + return fmt.Errorf("http listener: %w", err) + case <-ctx.Done(): + } + log.Info("shutting down", slog.Duration("timeout", cfg.ShutdownTimeout.Std())) + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cfg.ShutdownTimeout.Std()) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("graceful shutdown: %w", err) + } + if err := <-errCh; err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("http listener: %w", err) + } + log.Info("stopped") + return nil +} + +func cleanupSessions(ctx context.Context, core *Core) { + t := time.NewTicker(15 * time.Minute) + defer t.Stop() + for { + if n, err := core.Service.CleanupSessions(ctx); err != nil { + core.Log.Warn("session cleanup failed", slog.Any("error", err)) + } else if n > 0 { + core.Log.Debug("removed expired sessions", slog.Int64("count", n)) + } + select { + case <-ctx.Done(): + return + case <-t.C: + } + } +} diff --git a/server/internal/app/metrics.go b/server/internal/app/metrics.go new file mode 100644 index 0000000..26fbc02 --- /dev/null +++ b/server/internal/app/metrics.go @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: LicenseRef-DDI-Proprietary + +package app + +import ( + "net/http" + "strconv" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/prometheus/client_golang/prometheus/promhttp" + + "git.beugsiud.de/beug.k/DDI/internal/buildinfo" + "git.beugsiud.de/beug.k/DDI/server/internal/license" +) + +// metrics exposes Prometheus metrics (ADR 0017). Labels use the route +// pattern, never the raw path, to keep cardinality bounded. +type metrics struct { + reg *prometheus.Registry + requests *prometheus.CounterVec + duration *prometheus.HistogramVec +} + +func newMetrics(lic *license.Manager) *metrics { + reg := prometheus.NewRegistry() + reg.MustRegister(collectors.NewGoCollector(), collectors.NewProcessCollector(collectors.ProcessCollectorOpts{})) + m := &metrics{ + reg: reg, + requests: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "ddi_http_requests_total", Help: "HTTP requests by route pattern and status code.", + }, []string{"route", "code"}), + duration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "ddi_http_request_duration_seconds", Help: "HTTP request latency by route pattern.", + Buckets: prometheus.DefBuckets, + }, []string{"route"}), + } + info := buildinfo.Get() + buildGauge := prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "ddi_build_info", Help: "Build information of the server.", + }, []string{"version", "commit", "go_version"}) + buildGauge.WithLabelValues(info.Version, info.Commit, info.GoVersion).Set(1) + licenseGauge := prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Name: "ddi_license_writable", Help: "1 if the license allows management changes, 0 in read-only mode.", + }, func() float64 { + if lic != nil && lic.Writable() { + return 1 + } + return 0 + }) + reg.MustRegister(m.requests, m.duration, buildGauge, licenseGauge) + return m +} + +func (m *metrics) handler() http.Handler { + return promhttp.HandlerFor(m.reg, promhttp.HandlerOpts{}) +} + +type codeRecorder struct { + http.ResponseWriter + code int +} + +func (c *codeRecorder) WriteHeader(code int) { + if c.code == 0 { + c.code = code + } + c.ResponseWriter.WriteHeader(code) +} + +func (c *codeRecorder) Write(p []byte) (int, error) { + if c.code == 0 { + c.code = http.StatusOK + } + return c.ResponseWriter.Write(p) +} + +func (c *codeRecorder) Unwrap() http.ResponseWriter { return c.ResponseWriter } + +func (m *metrics) middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rec := &codeRecorder{ResponseWriter: w} + next.ServeHTTP(rec, r) + route := r.Pattern + if route == "" { + route = "unmatched" + } + code := rec.code + if code == 0 { + code = http.StatusOK + } + m.requests.WithLabelValues(route, strconv.Itoa(code)).Inc() + m.duration.WithLabelValues(route).Observe(time.Since(start).Seconds()) + }) +} diff --git a/server/internal/httpserver/server.go b/server/internal/httpserver/server.go index 1ef8b9a..43dbbc8 100644 --- a/server/internal/httpserver/server.go +++ b/server/internal/httpserver/server.go @@ -20,6 +20,12 @@ type Options struct { UI http.Handler // Readiness checks evaluated by /readyz, keyed by name. Readiness map[string]ReadinessCheck + // API serves everything under /api/. + API http.Handler + // Metrics serves GET /metrics (Prometheus format). + Metrics http.Handler + // Middleware wraps the routes inside the standard chain (e.g. metrics). + Middleware func(http.Handler) http.Handler } // Handler builds the routed and wrapped HTTP handler. @@ -30,9 +36,18 @@ func Handler(opts Options) http.Handler { if opts.UI != nil { mux.Handle("GET /", opts.UI) } + if opts.API != nil { + mux.Handle("/api/", opts.API) + } + if opts.Metrics != nil { + mux.Handle("GET /metrics", opts.Metrics) + } // Outermost first: request ID → access log → recover → headers → routes. var h http.Handler = mux + if opts.Middleware != nil { + h = opts.Middleware(h) + } h = SecurityHeaders(opts.TLS)(h) h = Recover(opts.Logger)(h) h = AccessLog(opts.Logger)(h) diff --git a/server/internal/httpserver/server_test.go b/server/internal/httpserver/server_test.go index 4beee21..3abe51c 100644 --- a/server/internal/httpserver/server_test.go +++ b/server/internal/httpserver/server_test.go @@ -153,3 +153,25 @@ func TestNewSetsTimeouts(t *testing.T) { t.Errorf("server not hardened: %+v", srv) } } + +func TestAPIMetricsAndMiddleware(t *testing.T) { + api := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, "api") }) + metrics := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, "metrics") }) + var seen []string + mw := func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r) + seen = append(seen, r.Pattern) + }) + } + h := Handler(Options{Logger: testLogger(nil), API: api, Metrics: metrics, Middleware: mw}) + if rec := do(t, h, http.MethodPost, "/api/v1/x", nil); rec.Body.String() != "api" { + t.Errorf("api: %q", rec.Body.String()) + } + if rec := do(t, h, http.MethodGet, "/metrics", nil); rec.Body.String() != "metrics" { + t.Errorf("metrics: %q", rec.Body.String()) + } + if len(seen) != 2 || seen[0] != "/api/" || seen[1] != "GET /metrics" { + t.Errorf("middleware saw patterns %v", seen) + } +} diff --git a/web/.prettierignore b/web/.prettierignore index 012fbaa..ef35a73 100644 --- a/web/.prettierignore +++ b/web/.prettierignore @@ -2,3 +2,4 @@ dist coverage node_modules package-lock.json +src/api/schema.d.ts diff --git a/web/eslint.config.js b/web/eslint.config.js index 742aed0..1446c3d 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -7,7 +7,7 @@ import globals from 'globals'; import tseslint from 'typescript-eslint'; export default defineConfig([ - globalIgnores(['dist', 'coverage', 'node_modules']), + globalIgnores(['dist', 'coverage', 'node_modules', 'src/api/schema.d.ts']), { files: ['**/*.{ts,tsx}'], extends: [ diff --git a/web/package-lock.json b/web/package-lock.json index 6facfd6..8d9f285 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -10,6 +10,7 @@ "license": "SEE LICENSE IN ../LICENSES/LicenseRef-DDI-Proprietary.txt", "dependencies": { "i18next": "26.4.4", + "openapi-fetch": "0.17.0", "react": "19.3.0", "react-dom": "19.3.0", "react-i18next": "17.0.16" @@ -28,6 +29,7 @@ "eslint-plugin-react-refresh": "0.5.7", "globals": "17.13.0", "jsdom": "30.1.2", + "openapi-typescript": "7.13.0", "prettier": "3.9.10", "typescript": "6.0.3", "typescript-eslint": "8.71.1", @@ -820,6 +822,89 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/ajv/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.20", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.20.tgz", + "integrity": "sha512-ypeBZ/6BKXR9+7/TtbKhbl4UgD7raHhPS12oknlKno2A8+lnFkxIwiE/Aklu6L2cd/ioH+fCWuMxi9/p3EyAPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.3.2", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.13", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", @@ -1610,6 +1695,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -1627,6 +1722,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1652,6 +1757,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -1797,6 +1909,20 @@ "node": ">=18" } }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2371,6 +2497,20 @@ "url": "https://locize.com" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/i18next": { "version": "26.4.4", "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.4.tgz", @@ -2429,6 +2569,19 @@ "node": ">=8" } }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2466,6 +2619,16 @@ "dev": true, "license": "ISC" }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2473,6 +2636,29 @@ "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsdom": { "version": "30.1.2", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.2.tgz", @@ -2997,6 +3183,42 @@ "node": ">=12.20.0" } }, + "node_modules/openapi-fetch": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz", + "integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.1.0" + } + }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript-helpers": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz", + "integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==", + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3047,6 +3269,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -3100,6 +3340,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/postcss": { "version": "8.5.29", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", @@ -3397,6 +3647,19 @@ "node": ">=8" } }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/tinybench": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", @@ -3506,6 +3769,19 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -3602,6 +3878,13 @@ "punycode": "^2.1.0" } }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, "node_modules/use-sync-external-store": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", @@ -3883,6 +4166,23 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/web/package.json b/web/package.json index d75cc7c..ba351e8 100644 --- a/web/package.json +++ b/web/package.json @@ -16,10 +16,12 @@ "lint": "eslint --max-warnings 0 .", "format": "prettier --write .", "format:check": "prettier --check .", - "test": "vitest run" + "test": "vitest run", + "gen:api": "openapi-typescript ../server/api/openapi.yaml -o src/api/schema.d.ts" }, "dependencies": { "i18next": "26.4.4", + "openapi-fetch": "0.17.0", "react": "19.3.0", "react-dom": "19.3.0", "react-i18next": "17.0.16" @@ -38,10 +40,16 @@ "eslint-plugin-react-refresh": "0.5.7", "globals": "17.13.0", "jsdom": "30.1.2", + "openapi-typescript": "7.13.0", "prettier": "3.9.10", "typescript": "6.0.3", "typescript-eslint": "8.71.1", "vite": "8.3.4", "vitest": "5.0.4" + }, + "overrides": { + "openapi-typescript": { + "typescript": "$typescript" + } } } diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts new file mode 100644 index 0000000..a536dae --- /dev/null +++ b/web/src/api/schema.d.ts @@ -0,0 +1,1967 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/system/info": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getSystemInfo"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["login"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/oidc/start": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["oidcStart"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/oidc/callback": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["oidcCallback"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/session": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getSession"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/logout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["logout"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/totp/verify": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["verifyTOTP"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["changePassword"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/totp/setup": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["setupTOTP"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/totp/enable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["enableTOTP"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/totp/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["disableTOTP"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listUsers"]; + put?: never; + post: operations["createUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/users/{id}": { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + get: operations["getUser"]; + put: operations["updateUser"]; + post?: never; + delete: operations["deleteUser"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/users/{id}/reset-password": { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["resetPassword"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/audit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listAudit"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/audit/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["exportAudit"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/license": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getLicense"]; + put: operations["setLicense"]; + post?: never; + delete: operations["removeLicense"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/license/file": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["importLicenseFile"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/license/refresh": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["refreshLicense"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/settings/oidc": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getOIDCSettings"]; + put: operations["setOIDCSettings"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/networks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listNetworks"]; + put?: never; + post: operations["createNetwork"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/networks/{id}": { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + get: operations["getNetwork"]; + /** @description Replaces all mutable fields. The CIDR is immutable and ignored. `version` must match. */ + put: operations["updateNetwork"]; + post?: never; + delete: operations["deleteNetwork"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/networks/{id}/map": { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + get: operations["getNetworkMap"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/addresses": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listAddresses"]; + put?: never; + post: operations["createAddress"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/addresses/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["exportAddresses"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/addresses/import": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * @description Creates or updates addresses from CSV (header row required; columns + * ip, type, mac, hostname, description, owner, tags with ';' as tag + * separator). All or nothing. + */ + post: operations["importAddresses"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/addresses/preview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * @description Describes the DNS/DHCP consequences of creating (only `address`), + * changing (`id` and `address`) or deleting (only `id`) an address. + */ + post: operations["previewAddress"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/addresses/bulk": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Applies one action to many addresses; all or nothing. */ + post: operations["bulkAddresses"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/addresses/{id}": { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + get: operations["getAddress"]; + /** @description Replaces the address (e.g. to switch its type). `version` must match. */ + put: operations["updateAddress"]; + post?: never; + delete: operations["deleteAddress"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/saved-filters": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listSavedFilters"]; + put?: never; + post: operations["createSavedFilter"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/saved-filters/{id}": { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["deleteSavedFilter"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + Problem: { + type: string; + title: string; + status: number; + /** @description Stable error code, translated by the UI. */ + code: string; + detail?: string; + request_id: string; + params?: { + [key: string]: string; + }; + problems?: components["schemas"]["FieldProblem"][]; + }; + FieldProblem: { + code: string; + field?: string; + params?: { + [key: string]: string; + }; + }; + /** @enum {string} */ + Role: "admin" | "operator" | "viewer"; + SystemInfo: { + product: string; + version: string; + license_state: components["schemas"]["LicenseState"]; + roles: components["schemas"]["Role"][]; + auth: { + local: boolean; + oidc: { + enabled: boolean; + display_name: string; + }; + }; + }; + LoginRequest: { + username: string; + password: string; + }; + CodeRequest: { + code: string; + }; + Session: { + user: components["schemas"]["User"]; + csrf_token: string; + mfa_required: boolean; + /** Format: date-time */ + expires_at: string; + permissions: ("ipam.read" | "ipam.write" | "license.read" | "license.admin" | "users.admin" | "audit.read" | "settings.admin" | "deploy")[]; + }; + User: { + id: string; + username: string; + display_name: string; + email?: string; + role: components["schemas"]["Role"]; + disabled: boolean; + must_change_password: boolean; + totp_enabled: boolean; + local_login: boolean; + oidc_linked: boolean; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + last_login_at?: string; + }; + AuditEntry: { + id: number; + /** Format: date-time */ + at: string; + actor_id?: string; + actor_name: string; + action: string; + object_type: string; + object_id?: string; + object_name: string; + /** @description Object before the change (any JSON). */ + before?: unknown; + /** @description Object after the change (any JSON). */ + after?: unknown; + request_id: string; + ip?: string; + }; + /** @enum {string} */ + LicenseState: "trial" | "valid" | "grace" | "read_only"; + LicenseStatus: { + state: components["schemas"]["LicenseState"]; + /** @description Translated by the UI (ok, trial, trial_expired, unreachable, expired, suspended, …). */ + reason: string; + /** @enum {string} */ + source?: "online" | "file"; + key_hint?: string; + license_id?: string; + name?: string; + /** Format: date-time */ + expires_at?: string; + /** Format: date-time */ + trial_ends_at?: string; + /** Format: date-time */ + grace_ends_at?: string; + /** Format: date-time */ + last_valid_at?: string; + /** Format: date-time */ + last_check_at?: string; + limits: { + max_agents?: number; + max_addresses?: number; + }; + features: string[]; + fingerprint: string; + usage: { + addresses: number; + agents: number; + }; + }; + OIDCSettingsBase: { + enabled: boolean; + issuer: string; + client_id: string; + display_name: string; + scopes: string[]; + username_claim: string; + role_claim: string; + role_mapping: { + [key: string]: components["schemas"]["Role"]; + }; + /** @enum {string} */ + default_role: "" | "admin" | "operator" | "viewer"; + }; + OIDCSettings: components["schemas"]["OIDCSettingsBase"] & { + client_secret_set: boolean; + }; + Pool: { + start: string; + end: string; + }; + NetworkRequest: { + /** @description Required on create; ignored on update. */ + cidr?: string; + name: string; + description?: string; + vlan?: number | null; + gateway?: string | null; + domain?: string; + search_domains?: string[]; + tags?: string[]; + dhcp_enabled?: boolean; + /** @description Seconds. */ + lease_time?: number | null; + dns_servers?: string[]; + ntp_servers?: string[]; + pools?: components["schemas"]["Pool"][]; + /** @description Required on update. */ + version?: number; + }; + Utilization: { + total: number; + used: number; + by_type: { + [key: string]: number; + }; + pool_size: number; + pool_effective: number; + pool_leased: number; + }; + Network: { + id: string; + cidr: string; + name: string; + description: string; + vlan: number | null; + gateway: string | null; + domain: string; + search_domains: string[]; + tags: string[]; + dhcp_enabled: boolean; + lease_time: number | null; + dns_servers: string[]; + ntp_servers: string[]; + pools: components["schemas"]["Pool"][]; + version: number; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + utilization?: components["schemas"]["Utilization"]; + }; + MapCell: { + ip: string; + /** @enum {string} */ + state: "free" | "pool" | "gateway" | "network" | "broadcast" | "static" | "reserved" | "manual_dhcp" | "dynamic"; + hostname?: string; + leased?: boolean; + }; + /** @enum {string} */ + AddressType: "static" | "reserved" | "manual_dhcp" | "dynamic"; + AddressRequest: { + /** @description Optional; must match the network containing the IP. */ + network_id?: string; + ip: string; + type: string; + mac?: string; + hostname?: string; + description?: string; + owner?: string; + tags?: string[]; + /** @description Required on update. */ + version?: number; + }; + Address: { + id: string; + network_id: string; + network_name: string; + network_cidr: string; + ip: string; + type: components["schemas"]["AddressType"]; + mac: string; + hostname: string; + fqdn: string; + description: string; + owner: string; + tags: string[]; + /** Format: date-time */ + first_seen: string | null; + /** Format: date-time */ + last_seen: string | null; + lease_state: string; + /** Format: date-time */ + lease_expires_at: string | null; + version: number; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + Effect: { + code: string; + params?: { + [key: string]: string; + }; + }; + BulkResult: { + changed: number; + problems: { + id: string; + ip?: string; + problems: components["schemas"]["FieldProblem"][]; + }[]; + }; + ImportResult: { + dry_run: boolean; + created: number; + updated: number; + problems: { + line: number; + ip?: string; + problems: components["schemas"]["FieldProblem"][]; + }[]; + }; + SavedFilter: { + id: string; + name: string; + query: Record; + /** Format: date-time */ + created_at: string; + }; + }; + responses: { + /** @description Error (RFC 9457 problem document). */ + Problem: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + parameters: { + ID: string; + AuditFrom: string; + AuditTo: string; + NetworkFilter: string; + /** @description Repeat or comma-separate to select several types. */ + TypeFilter: string[]; + TagFilter: string; + LeaseFilter: string; + /** @description Matches IP prefix, MAC, hostname, FQDN, description and owner. */ + Search: string; + Sort: "ip" | "type" | "mac" | "hostname" | "fqdn" | "network" | "last_seen" | "lease" | "updated_at"; + Order: "asc" | "desc"; + }; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getSystemInfo: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Product, version and available login methods. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SystemInfo"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + login: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LoginRequest"]; + }; + }; + responses: { + /** @description Logged in; the session cookie is set. With TOTP the session is MFA-pending. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Session"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + oidcStart: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Redirect to the identity provider (or back to /login with an error). */ + 302: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Problem"]; + }; + }; + oidcCallback: { + parameters: { + query?: { + code?: string; + state?: string; + error?: string; + error_description?: string; + iss?: string; + session_state?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Redirect to the application (session cookie set) or to /login with an error. */ + 302: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Problem"]; + }; + }; + getSession: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The current session. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Session"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + logout: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Logged out. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Problem"]; + }; + }; + verifyTOTP: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CodeRequest"]; + }; + }; + responses: { + /** @description Second factor accepted. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Session"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + changePassword: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + current_password: string; + new_password: string; + }; + }; + }; + responses: { + /** @description Password changed; other sessions ended. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Problem"]; + }; + }; + setupTOTP: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description New (not yet enabled) TOTP secret. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + secret: string; + uri: string; + }; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + enableTOTP: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CodeRequest"]; + }; + }; + responses: { + /** @description TOTP enabled. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Problem"]; + }; + }; + disableTOTP: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + password: string; + }; + }; + }; + responses: { + /** @description TOTP disabled. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Problem"]; + }; + }; + listUsers: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description All users. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items: components["schemas"]["User"][]; + }; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + createUser: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + username: string; + display_name?: string; + email?: string; + role: components["schemas"]["Role"]; + /** @description Empty generates a one-time password. */ + password?: string; + disabled?: boolean; + }; + }; + }; + responses: { + /** @description Created. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + user: components["schemas"]["User"]; + initial_password?: string; + }; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + getUser: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The user. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["User"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + updateUser: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + display_name?: string; + email?: string; + role?: components["schemas"]["Role"]; + disabled?: boolean; + }; + }; + }; + responses: { + /** @description Updated. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["User"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + deleteUser: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deleted. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Problem"]; + }; + }; + resetPassword: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description New one-time password; all sessions of the user ended. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + password: string; + }; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + listAudit: { + parameters: { + query?: { + from?: components["parameters"]["AuditFrom"]; + to?: components["parameters"]["AuditTo"]; + actor_id?: string; + object_type?: string; + object_id?: string; + action?: string; + q?: string; + limit?: number; + before_id?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Entries, newest first. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items: components["schemas"]["AuditEntry"][]; + next_before_id: number | null; + }; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + exportAudit: { + parameters: { + query?: { + from?: components["parameters"]["AuditFrom"]; + to?: components["parameters"]["AuditTo"]; + actor_id?: string; + object_type?: string; + object_id?: string; + action?: string; + q?: string; + format?: "csv" | "json"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description CSV or JSON Lines download. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/csv": string; + "application/x-ndjson": string; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + getLicense: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description License status and usage. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LicenseStatus"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + setLicense: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + key: string; + }; + }; + }; + responses: { + /** @description Key validated and activated. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LicenseStatus"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + removeLicense: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description License removed and machine released. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Problem"]; + }; + }; + importLicenseFile: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + certificate: string; + /** @description Needed for encrypted license files. */ + key?: string; + }; + }; + }; + responses: { + /** @description Offline license installed. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LicenseStatus"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + refreshLicense: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Revalidated now. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LicenseStatus"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + getOIDCSettings: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OIDC settings without the client secret. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OIDCSettings"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + setOIDCSettings: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["OIDCSettingsBase"] & { + /** @description Omit to keep the stored secret; empty string removes it. */ + client_secret?: string; + /** @description Ignored. */ + client_secret_set?: boolean; + }; + }; + }; + responses: { + /** @description Stored. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OIDCSettings"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + listNetworks: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description All networks with utilization. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items: components["schemas"]["Network"][]; + }; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + createNetwork: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NetworkRequest"]; + }; + }; + responses: { + /** @description Created. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Network"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + getNetwork: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The network with utilization. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Network"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + updateNetwork: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NetworkRequest"]; + }; + }; + responses: { + /** @description Updated. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Network"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + deleteNetwork: { + parameters: { + query?: { + /** @description Also delete all addresses of the network. */ + force?: boolean; + }; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deleted. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Problem"]; + }; + }; + getNetworkMap: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description State of every address (IPv4 networks up to /22). */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + cells: components["schemas"]["MapCell"][]; + }; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + listAddresses: { + parameters: { + query?: { + network_id?: components["parameters"]["NetworkFilter"]; + /** @description Repeat or comma-separate to select several types. */ + type?: components["parameters"]["TypeFilter"]; + tag?: components["parameters"]["TagFilter"]; + lease_state?: components["parameters"]["LeaseFilter"]; + /** @description Matches IP prefix, MAC, hostname, FQDN, description and owner. */ + q?: components["parameters"]["Search"]; + sort?: components["parameters"]["Sort"]; + order?: components["parameters"]["Order"]; + limit?: number; + offset?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description One page of matching addresses. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items: components["schemas"]["Address"][]; + total: number; + }; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + createAddress: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AddressRequest"]; + }; + }; + responses: { + /** @description Created. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Address"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + exportAddresses: { + parameters: { + query?: { + network_id?: components["parameters"]["NetworkFilter"]; + /** @description Repeat or comma-separate to select several types. */ + type?: components["parameters"]["TypeFilter"]; + tag?: components["parameters"]["TagFilter"]; + lease_state?: components["parameters"]["LeaseFilter"]; + /** @description Matches IP prefix, MAC, hostname, FQDN, description and owner. */ + q?: components["parameters"]["Search"]; + sort?: components["parameters"]["Sort"]; + order?: components["parameters"]["Order"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description CSV download of all matching addresses. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/csv": string; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + importAddresses: { + parameters: { + query?: { + dry_run?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "text/csv": string; + }; + }; + responses: { + /** @description Imported (or validated, for a dry run). */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ImportResult"]; + }; + }; + /** @description Problems found; nothing was imported. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ImportResult"]; + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + previewAddress: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + id?: string; + address?: components["schemas"]["AddressRequest"]; + }; + }; + }; + responses: { + /** @description The effects. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + effects: components["schemas"]["Effect"][]; + }; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + bulkAddresses: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + ids: string[]; + delete?: boolean; + set_type?: components["schemas"]["AddressType"]; + set_owner?: string; + add_tags?: string[]; + remove_tags?: string[]; + }; + }; + }; + responses: { + /** @description Applied. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BulkResult"]; + }; + }; + /** @description Problems found; nothing was changed. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BulkResult"]; + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + getAddress: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The address. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Address"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + updateAddress: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AddressRequest"]; + }; + }; + responses: { + /** @description Updated. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Address"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + deleteAddress: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deleted. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Problem"]; + }; + }; + listSavedFilters: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The caller's saved filters. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items: components["schemas"]["SavedFilter"][]; + }; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + createSavedFilter: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name: string; + query: Record; + }; + }; + }; + responses: { + /** @description Saved. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SavedFilter"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + deleteSavedFilter: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deleted. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Problem"]; + }; + }; +}