feat(server): wire database, licensing, API and metrics into ddi-server
ci / Web (lint, test, audit, build) (push) Successful in 32s
ci / Shell and licence headers (push) Successful in 5s
ci / Release build (dry run) (push) Successful in 1m43s
ci / Development environment (compose) (push) Canceled after 5s
ci / Go (lint, test, vuln) (push) Failing after 3m7s
ci / Protobuf (push) Successful in 6s

- 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 <noreply@anthropic.com>
This commit is contained in:
beug.k@beugsiud.de
2026-10-12 21:08:59 +02:00
co-authored by Claude Opus 5.5
parent 1417e8f578
commit 1aefed471c
16 changed files with 2921 additions and 122 deletions
+3 -1
View File
@@ -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
+3 -2
View File
@@ -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
+7
View File
@@ -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
+22
View File
@@ -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=
+2 -2
View File
@@ -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 }
+121 -69
View File
@@ -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 <command>
_, _ = fmt.Fprintf(w, `Usage: %[1]s <command> [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
}
+145 -46
View File
@@ -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")
}
}
+206
View File
@@ -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:
}
}
}
+97
View File
@@ -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())
})
}
+15
View File
@@ -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)
+22
View File
@@ -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)
}
}
+1
View File
@@ -2,3 +2,4 @@ dist
coverage
node_modules
package-lock.json
src/api/schema.d.ts
+1 -1
View File
@@ -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: [
+300
View File
@@ -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",
+9 -1
View File
@@ -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"
}
}
}
Generated Vendored
+1967
View File
File diff suppressed because it is too large Load Diff