Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
3a631f18f6
|
|||
|
7d7133d6cc
|
|||
|
cae9419cd5
|
|||
|
e67698f3c2
|
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
.git
|
||||
pawsql
|
||||
ui/node_modules
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
/Barkfile
|
||||
/pawsql
|
||||
/ui/node_modules
|
||||
|
||||
11
Dockerfile
11
Dockerfile
@@ -1,4 +1,11 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM oven/bun:1.4.0-alpine AS ui
|
||||
WORKDIR /src/ui
|
||||
COPY ui/package.json ui/bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
COPY ui ./
|
||||
RUN bun run build
|
||||
|
||||
FROM golang:1.24-alpine AS build
|
||||
WORKDIR /src
|
||||
RUN apk add --no-cache git
|
||||
@@ -6,10 +13,12 @@ COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY cmd ./cmd
|
||||
COPY internal ./internal
|
||||
COPY --from=ui /src/internal/adminui/dist ./internal/adminui/dist
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /pawsql ./cmd/pawsql
|
||||
|
||||
FROM alpine:3.21
|
||||
RUN apk add --no-cache ca-certificates docker-cli
|
||||
COPY --from=build /pawsql /usr/local/bin/pawsql
|
||||
EXPOSE 5432
|
||||
ENTRYPOINT ["/usr/local/bin/pawsql"]
|
||||
CMD ["--config", "/etc/pawsql/Barkfile"]
|
||||
CMD ["--config", "/etc/pawsql/Barkfile", "--admin-listen", ":9090"]
|
||||
|
||||
33
README.md
33
README.md
@@ -6,14 +6,18 @@ PawSQL is a TLS-terminating PostgreSQL router. It accepts PostgreSQL clients on
|
||||
|
||||
- Go 1.24 or later to build and run PawSQL natively.
|
||||
- Docker Engine and a usable `docker` CLI to build the PawSQL image. PawSQL also needs them in its own execution environment when it manages PostgreSQL containers.
|
||||
- A TLS certificate and private key readable by PawSQL. The certificate must cover every hostname clients use for SNI routing.
|
||||
- Docker Engine access for each `postgres` route. Managed database images are limited to `postgres:16`, `postgres:17`, and `postgres:18`.
|
||||
- Optional: a TLS certificate and private key readable by PawSQL. Omit the `tls` block to serve plaintext PostgreSQL; with TLS, the certificate must cover every hostname clients use for SNI routing.
|
||||
- Docker Engine access for each `postgres` route. Managed database images are limited to `postgres:16`, `postgres:17`, and `postgres:18`; each route also requires a mounted Docker secret file.
|
||||
|
||||
## Build, configure, and run
|
||||
|
||||
Build a native binary:
|
||||
Build the independently owned Svelte plugin, then the native binary:
|
||||
|
||||
```sh
|
||||
cd ui
|
||||
bun install
|
||||
bun run build
|
||||
cd ..
|
||||
go build -o pawsql ./cmd/pawsql
|
||||
```
|
||||
|
||||
@@ -21,10 +25,10 @@ Create a `Barkfile` and validate it before starting:
|
||||
|
||||
```sh
|
||||
./pawsql validate --config Barkfile
|
||||
./pawsql --config Barkfile
|
||||
./pawsql --config Barkfile --admin-listen :9090
|
||||
```
|
||||
|
||||
`--config` defaults to `Barkfile`. The listener, TLS material, and at least one database route are required.
|
||||
`--config` defaults to `Barkfile`. The PostgreSQL listener, TLS material, and at least one database route are required. `--admin-listen` defaults to `:9090` and serves PawSQL's embedded Barkstack plugin at `/barkstack/ui/`; pass an empty value to disable it. The admin port is intended for Barkstack over localhost or a private overlay network, not direct publication.
|
||||
|
||||
To build and run the PawSQL container image for routes reachable from that container:
|
||||
|
||||
@@ -34,10 +38,11 @@ docker run --rm --publish 5432:5432 \
|
||||
--volume "$PWD/Barkfile:/etc/pawsql/Barkfile:ro" \
|
||||
--volume "$PWD/tls:/etc/pawsql/tls:ro" \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
--volume "$PWD/secrets:/run/secrets:ro" \
|
||||
pawsql
|
||||
```
|
||||
|
||||
The supplied image includes the Docker CLI so managed `postgres` routes can create, start, and stop their containers through the mounted Docker socket. The socket grants PawSQL root-equivalent control of the Docker host; mount it only for trusted Barkfiles and trusted administrators.
|
||||
The supplied image includes the compiled PawSQL Svelte plugin and Docker CLI; production does not run Node or Bun. The Docker CLI lets managed `postgres` routes create, start, and stop containers through the mounted Docker socket. The socket grants PawSQL root-equivalent control of the Docker host; mount it only for trusted Barkfiles and trusted administrators. Publish PostgreSQL port 5432 as needed, but leave admin port 9090 unpublished and let Barkstack proxy the UI.
|
||||
|
||||
## Barkfile
|
||||
|
||||
@@ -62,7 +67,7 @@ pawsql {
|
||||
postgres {
|
||||
image postgres:17
|
||||
volume pawsql-application-data
|
||||
password_env APPLICATION_POSTGRES_PASSWORD
|
||||
password_secret application_postgres_password
|
||||
idle_timeout 10m
|
||||
traffic_idle_timeout 1h
|
||||
}
|
||||
@@ -72,7 +77,15 @@ pawsql {
|
||||
|
||||
`listen` is PawSQL's TCP address. `cert` and `key` identify the client-facing TLS certificate and key. `hostname` is optional; it is used only for SNI routing. `upstream` is the address of an existing PostgreSQL server.
|
||||
|
||||
For a managed `postgres` route, `image`, `volume`, and `password_env` are required. On first use, PawSQL reads the named environment variable to create the database container and configures the database and PostgreSQL user with the route's database name. The named Docker volume preserves its data. Set the password environment variable in PawSQL's environment, not in the Barkfile.
|
||||
For a managed `postgres` route, `image`, `volume`, and `password_secret` are required. `password_secret` is a short lowercase reference; PawSQL reads it from `/run/secrets/barkstack_<reference>`. The example therefore reads `/run/secrets/barkstack_application_postgres_password`. On first use, PawSQL uses that value to configure the database and PostgreSQL user with the route's database name. The named Docker volume preserves its data.
|
||||
|
||||
Create the Docker Swarm secret before running `barkstack init`; Barkstack verifies that every referenced secret exists and mounts it into the PawSQL service:
|
||||
|
||||
```sh
|
||||
docker secret create barkstack_application_postgres_password /secure/path/application-postgres-password
|
||||
```
|
||||
|
||||
For a standalone PawSQL container, mount a directory containing the equivalently named file at `/run/secrets`, as shown above. Never put the password value in the Barkfile.
|
||||
|
||||
See [`examples/Barkfile`](examples/Barkfile) and its accompanying [`examples/docker-compose.yml`](examples/docker-compose.yml) for a two-route external PostgreSQL example with locally generated development certificates:
|
||||
|
||||
@@ -83,13 +96,15 @@ docker compose up --build
|
||||
|
||||
## Routing and TLS
|
||||
|
||||
PawSQL requires PostgreSQL's SSL negotiation and terminates client TLS before proxying PostgreSQL bytes to the selected upstream.
|
||||
When the Barkfile configures `tls`, PawSQL handles PostgreSQL's SSL negotiation and terminates client TLS before proxying PostgreSQL bytes to the selected upstream:
|
||||
|
||||
- **With SNI:** PawSQL uses the TLS server name to select an exact configured `hostname` match. Hostname matching is case-insensitive and ignores a trailing dot. An unknown SNI name is rejected; PawSQL does not fall back to a database-name route when SNI is present.
|
||||
- **Without SNI:** After TLS is established, PawSQL reads the PostgreSQL startup message and selects the route whose `database` name exactly matches the requested PostgreSQL database. This makes a route without `hostname` usable by non-SNI clients.
|
||||
|
||||
Use a certificate trusted by clients and containing the SNI hostname they present. Clients that do not send SNI must request the configured database route name.
|
||||
|
||||
Without a `tls` block, PawSQL serves plaintext PostgreSQL: clients connect without SSL negotiation, and routes are selected only by database name. Hostname routing is unavailable because it relies on TLS SNI.
|
||||
|
||||
## Managed PostgreSQL lifecycle
|
||||
|
||||
Managed PostgreSQL is lazy: PawSQL creates or starts its `pawsql-<database>` container only when a client selects that route, waits for PostgreSQL to accept connections, then proxies the session. PawSQL stops managed containers but does not remove their data volumes.
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
config "cloud.campbellwireless.net/git/barkstack/barkfile-parser"
|
||||
config "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2"
|
||||
"github.com/barkstack/pawsql/internal/adminui"
|
||||
"github.com/barkstack/pawsql/internal/postgres"
|
||||
"github.com/barkstack/pawsql/internal/router"
|
||||
"github.com/barkstack/pawsql/internal/server"
|
||||
@@ -26,7 +30,7 @@ func main() {
|
||||
}
|
||||
|
||||
func run(args []string, logger *slog.Logger) error {
|
||||
validateOnly, configPath, err := parseArguments(args)
|
||||
validateOnly, configPath, adminListen, err := parseArguments(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -37,9 +41,15 @@ func run(args []string, logger *slog.Logger) error {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid configuration: %w", err)
|
||||
}
|
||||
certificate, err := tls.LoadX509KeyPair(cfg.TLS.CertFile, cfg.TLS.KeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load TLS certificate and key: %w", err)
|
||||
var tlsConfig *tls.Config
|
||||
if cfg.TLS.CertFile != "" || cfg.TLS.KeyFile != "" {
|
||||
certificate, err := tls.LoadX509KeyPair(cfg.TLS.CertFile, cfg.TLS.KeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load TLS certificate and key: %w", err)
|
||||
}
|
||||
tlsConfig = &tls.Config{Certificates: []tls.Certificate{certificate}}
|
||||
} else {
|
||||
logger.Info("TLS is not configured; clients connect without SSL and routes are selected by database name")
|
||||
}
|
||||
if validateOnly {
|
||||
logger.Info("configuration is valid", "config", configPath)
|
||||
@@ -51,7 +61,7 @@ func run(args []string, logger *slog.Logger) error {
|
||||
return fmt.Errorf("build route resolver: %w", err)
|
||||
}
|
||||
resolver := postgres.NewResolver(staticResolver, cfg.Databases, postgres.NewProvisioner(logger))
|
||||
routingServer, err := server.New(&tls.Config{Certificates: []tls.Certificate{certificate}}, resolver, logger)
|
||||
routingServer, err := server.New(tlsConfig, resolver, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -61,30 +71,83 @@ func run(args []string, logger *slog.Logger) error {
|
||||
}
|
||||
logger.Info("pawsql listening", "address", listener.Addr().String())
|
||||
|
||||
type serveResult struct {
|
||||
name string
|
||||
err error
|
||||
}
|
||||
serveErrors := make(chan serveResult, 2)
|
||||
serverCount := 1
|
||||
go func() { serveErrors <- serveResult{name: "PostgreSQL", err: routingServer.Serve(listener)} }()
|
||||
|
||||
var adminServer *http.Server
|
||||
if adminListen != "" {
|
||||
routes := make([]adminui.RouteSummary, 0, len(cfg.Databases))
|
||||
for _, database := range cfg.Databases {
|
||||
routes = append(routes, adminui.RouteSummary{Name: database.Name, Hostname: database.Hostname, Managed: database.Postgres != nil})
|
||||
}
|
||||
adminListener, err := net.Listen("tcp", adminListen)
|
||||
if err != nil {
|
||||
_ = routingServer.Shutdown()
|
||||
return fmt.Errorf("listen for admin HTTP on %s: %w", adminListen, err)
|
||||
}
|
||||
adminServer = &http.Server{
|
||||
Handler: adminui.Handler(routes),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
serverCount++
|
||||
logger.Info("pawsql admin UI listening", "address", adminListener.Addr().String())
|
||||
go func() {
|
||||
err := adminServer.Serve(adminListener)
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
err = nil
|
||||
}
|
||||
serveErrors <- serveResult{name: "admin HTTP", err: err}
|
||||
}()
|
||||
}
|
||||
|
||||
shutdown := func() error {
|
||||
if err := routingServer.Shutdown(); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
return fmt.Errorf("stop PostgreSQL listener: %w", err)
|
||||
}
|
||||
if adminServer != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := adminServer.Shutdown(ctx); err != nil {
|
||||
return fmt.Errorf("stop admin HTTP listener: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
signals := make(chan os.Signal, 1)
|
||||
signal.Notify(signals, os.Interrupt, syscall.SIGTERM)
|
||||
defer signal.Stop(signals)
|
||||
serveErrors := make(chan error, 1)
|
||||
go func() { serveErrors <- routingServer.Serve(listener) }()
|
||||
|
||||
select {
|
||||
case received := <-signals:
|
||||
logger.Info("shutdown signal received", "signal", received.String())
|
||||
if err := routingServer.Shutdown(); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
return fmt.Errorf("stop listener: %w", err)
|
||||
}
|
||||
if err := <-serveErrors; err != nil {
|
||||
if err := shutdown(); err != nil {
|
||||
return err
|
||||
}
|
||||
for range serverCount {
|
||||
result := <-serveErrors
|
||||
if result.err != nil {
|
||||
return fmt.Errorf("%s server: %w", result.name, result.err)
|
||||
}
|
||||
}
|
||||
routingServer.Wait()
|
||||
logger.Info("pawsql shutdown complete")
|
||||
return nil
|
||||
case err := <-serveErrors:
|
||||
return err
|
||||
case result := <-serveErrors:
|
||||
_ = shutdown()
|
||||
if result.err == nil {
|
||||
return fmt.Errorf("%s server stopped unexpectedly", result.name)
|
||||
}
|
||||
return fmt.Errorf("%s server: %w", result.name, result.err)
|
||||
}
|
||||
}
|
||||
|
||||
func parseArguments(args []string) (validateOnly bool, configPath string, err error) {
|
||||
func parseArguments(args []string) (validateOnly bool, configPath, adminListen string, err error) {
|
||||
if len(args) > 0 && args[0] == "validate" {
|
||||
validateOnly = true
|
||||
args = args[1:]
|
||||
@@ -92,12 +155,14 @@ func parseArguments(args []string) (validateOnly bool, configPath string, err er
|
||||
flags := flag.NewFlagSet("pawsql", flag.ContinueOnError)
|
||||
flags.SetOutput(os.Stderr)
|
||||
configPath = "Barkfile"
|
||||
adminListen = ":9090"
|
||||
flags.StringVar(&configPath, "config", configPath, "path to Barkfile")
|
||||
flags.StringVar(&adminListen, "admin-listen", adminListen, "admin HTTP listen address (empty disables)")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return false, "", err
|
||||
return false, "", "", err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return false, "", fmt.Errorf("unexpected arguments: %v", flags.Args())
|
||||
return false, "", "", fmt.Errorf("unexpected arguments: %v", flags.Args())
|
||||
}
|
||||
return validateOnly, configPath, nil
|
||||
return validateOnly, configPath, adminListen, nil
|
||||
}
|
||||
|
||||
2
go.mod
2
go.mod
@@ -2,4 +2,4 @@ module github.com/barkstack/pawsql
|
||||
|
||||
go 1.24
|
||||
|
||||
require cloud.campbellwireless.net/git/barkstack/barkfile-parser v1.0.0
|
||||
require cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2 v2.0.0
|
||||
|
||||
4
go.sum
4
go.sum
@@ -1,2 +1,2 @@
|
||||
cloud.campbellwireless.net/git/barkstack/barkfile-parser v1.0.0 h1:81V2fr9ln2WNqA2JHb40fvav0aW+5OQAOsnuhMkuRes=
|
||||
cloud.campbellwireless.net/git/barkstack/barkfile-parser v1.0.0/go.mod h1:UnKTlB8ifO3cmsrkh2LDAM+Y2ipaCrBeiURwrSRPPe4=
|
||||
cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2 v2.0.0 h1:oXVNd8wwR06OaBMDPCVr4zAWZw6L0VBCODkXaHeMI/I=
|
||||
cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2 v2.0.0/go.mod h1:um5p/9XtFqZqhh087AlPB2nr4NxhrQxw5sxe32uMbzw=
|
||||
|
||||
3203
internal/adminui/dist/assets/entry.js
vendored
Normal file
3203
internal/adminui/dist/assets/entry.js
vendored
Normal file
File diff suppressed because one or more lines are too long
118
internal/adminui/handler.go
Normal file
118
internal/adminui/handler.go
Normal file
@@ -0,0 +1,118 @@
|
||||
// Package adminui serves PawSQL's Barkstack UI plugin and small read-only status API.
|
||||
package adminui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed dist
|
||||
var embeddedUI embed.FS
|
||||
|
||||
const entryPath = "/barkstack/ui/assets/entry.js"
|
||||
|
||||
type manifest struct {
|
||||
APIVersion string `json:"apiVersion"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
Mount string `json:"mount"`
|
||||
Entry string `json:"entry"`
|
||||
Element string `json:"element"`
|
||||
Navigation []navigationItem `json:"navigation"`
|
||||
}
|
||||
|
||||
type navigationItem struct {
|
||||
Label string `json:"label"`
|
||||
Path string `json:"path"`
|
||||
Icon string `json:"icon"`
|
||||
}
|
||||
|
||||
type RouteSummary struct {
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
Managed bool `json:"managed"`
|
||||
}
|
||||
|
||||
type statusResponse struct {
|
||||
Status string `json:"status"`
|
||||
Routes []RouteSummary `json:"routes"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
assets fs.FS
|
||||
routes []RouteSummary
|
||||
}
|
||||
|
||||
func Handler(routes []RouteSummary) http.Handler {
|
||||
assets, err := fs.Sub(embeddedUI, "dist")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
copyRoutes := append([]RouteSummary(nil), routes...)
|
||||
return &handler{assets: assets, routes: copyRoutes}
|
||||
}
|
||||
|
||||
func (h *handler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodGet && request.Method != http.MethodHead {
|
||||
response.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
switch request.URL.Path {
|
||||
case "/barkstack/ui/manifest.json":
|
||||
h.serveJSON(response, request, manifest{
|
||||
APIVersion: "barkstack.dev/ui/v1", ID: "pawsql", Name: "PawSQL",
|
||||
Description: "PostgreSQL routing for Barkstack", Icon: "database", Mount: "/pawsql",
|
||||
Entry: entryPath, Element: "barkstack-pawsql",
|
||||
Navigation: []navigationItem{{Label: "PawSQL", Path: "/pawsql", Icon: "database"}},
|
||||
})
|
||||
case "/barkstack/ui/status.json":
|
||||
h.serveJSON(response, request, statusResponse{Status: "running", Routes: h.routes, Endpoint: "*.pawsql.barkstack.dev:5432"})
|
||||
default:
|
||||
h.serveAsset(response, request)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) serveJSON(response http.ResponseWriter, request *http.Request, value any) {
|
||||
response.Header().Set("Content-Type", "application/json")
|
||||
response.Header().Set("Cache-Control", "no-store")
|
||||
if request.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(response).Encode(value)
|
||||
}
|
||||
|
||||
func (h *handler) serveAsset(response http.ResponseWriter, request *http.Request) {
|
||||
const prefix = "/barkstack/ui/assets/"
|
||||
if !strings.HasPrefix(request.URL.Path, prefix) {
|
||||
http.NotFound(response, request)
|
||||
return
|
||||
}
|
||||
assetName := strings.TrimPrefix(request.URL.Path, "/barkstack/ui/")
|
||||
if assetName == "" || !fs.ValidPath(assetName) || path.Clean(assetName) != assetName || strings.Contains(assetName, "\\") {
|
||||
http.NotFound(response, request)
|
||||
return
|
||||
}
|
||||
contents, err := fs.ReadFile(h.assets, assetName)
|
||||
if err != nil {
|
||||
http.NotFound(response, request)
|
||||
return
|
||||
}
|
||||
if contentType := mime.TypeByExtension(path.Ext(assetName)); contentType != "" {
|
||||
response.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
response.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
response.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
if request.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
_, _ = response.Write(contents)
|
||||
}
|
||||
62
internal/adminui/handler_test.go
Normal file
62
internal/adminui/handler_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package adminui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestManifestEndpointDescribesPawSQLPlugin(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
Handler(nil).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/barkstack/ui/manifest.json", nil))
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
if got := response.Header().Get("Content-Type"); got != "application/json" {
|
||||
t.Fatalf("content type = %q", got)
|
||||
}
|
||||
var got manifest
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.APIVersion != "barkstack.dev/ui/v1" || got.ID != "pawsql" || got.Entry != entryPath || got.Element != "barkstack-pawsql" {
|
||||
t.Fatalf("manifest = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedEntryAssetIsServedWithJavaScriptContentType(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
Handler(nil).ServeHTTP(response, httptest.NewRequest(http.MethodGet, entryPath, nil))
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
if got := response.Header().Get("Content-Type"); !strings.Contains(got, "javascript") {
|
||||
t.Fatalf("content type = %q", got)
|
||||
}
|
||||
if response.Body.Len() == 0 {
|
||||
t.Fatal("embedded entry asset is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusEndpointReportsConfiguredRoutes(t *testing.T) {
|
||||
routes := []RouteSummary{{Name: "reporting", Hostname: "reports.example.com", Managed: true}}
|
||||
response := httptest.NewRecorder()
|
||||
Handler(routes).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/barkstack/ui/status.json", nil))
|
||||
var got statusResponse
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != "running" || len(got.Routes) != 1 || got.Routes[0] != routes[0] {
|
||||
t.Fatalf("status response = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownAssetReturnsNotFound(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
Handler(nil).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/barkstack/ui/assets/missing.js", nil))
|
||||
if response.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", response.Code)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -13,7 +15,10 @@ const (
|
||||
sslRequestCode uint32 = 80877103
|
||||
)
|
||||
|
||||
var ErrNotSSLRequest = errors.New("expected PostgreSQL SSLRequest")
|
||||
var (
|
||||
ErrNotSSLRequest = errors.New("expected PostgreSQL SSLRequest")
|
||||
ErrNotNegotiation = errors.New("not a PostgreSQL SSL or GSSENC negotiation request")
|
||||
)
|
||||
|
||||
// ReadSSLRequest validates the eight-byte PostgreSQL SSL negotiation request.
|
||||
func ReadSSLRequest(reader io.Reader) error {
|
||||
@@ -30,6 +35,42 @@ func ReadSSLRequest(reader io.Reader) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// NegotiatePlainPostgreSQL handles a plaintext server's first protocol exchange
|
||||
// and returns the connection positioned at the StartupMessage. When the client
|
||||
// opens with an SSLRequest or GSSENCRequest it answers 'N', the standard
|
||||
// PostgreSQL decline, so the client retries without encryption.
|
||||
func NegotiatePlainPostgreSQL(connection net.Conn) (net.Conn, error) {
|
||||
var preamble [8]byte
|
||||
if err := connection.SetReadDeadline(time.Now().Add(15 * time.Second)); err != nil {
|
||||
return connection, fmt.Errorf("set negotiation deadline: %w", err)
|
||||
}
|
||||
if _, err := io.ReadFull(connection, preamble[:]); err != nil {
|
||||
return connection, fmt.Errorf("read PostgreSQL negotiation preamble: %w", err)
|
||||
}
|
||||
if err := connection.SetReadDeadline(time.Time{}); err != nil {
|
||||
return connection, fmt.Errorf("clear negotiation deadline: %w", err)
|
||||
}
|
||||
length := binary.BigEndian.Uint32(preamble[0:4])
|
||||
code := binary.BigEndian.Uint32(preamble[4:8])
|
||||
if length == sslRequestLength && (code == sslRequestCode || code == gssEncryptionCode) {
|
||||
if err := declineSSL(connection); err != nil {
|
||||
return connection, err
|
||||
}
|
||||
return connection, nil
|
||||
}
|
||||
if length < 8 || length > maxStartupMessageSize {
|
||||
return connection, fmt.Errorf("%w: invalid length %d", ErrInvalidStartupMessage, length)
|
||||
}
|
||||
return Replay(connection, preamble[:]), nil
|
||||
}
|
||||
|
||||
func declineSSL(connection net.Conn) error {
|
||||
if _, err := connection.Write([]byte{'N'}); err != nil {
|
||||
return fmt.Errorf("write PostgreSQL SSL decline: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SSLRequest returns the exact client preamble used to request TLS from PostgreSQL.
|
||||
// It exists to make protocol-level tests and clients unambiguous.
|
||||
func SSLRequest() [8]byte {
|
||||
|
||||
@@ -11,7 +11,8 @@ import (
|
||||
|
||||
const (
|
||||
startupProtocolVersion uint32 = 196608
|
||||
maxStartupMessageSize = 64 << 10
|
||||
maxStartupMessageSize uint32 = 64 << 10
|
||||
gssEncryptionCode uint32 = 80877104
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -9,10 +9,11 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
config "cloud.campbellwireless.net/git/barkstack/barkfile-parser"
|
||||
config "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2"
|
||||
)
|
||||
|
||||
const managedDatabaseLabel = "io.barkstack.pawsql.database"
|
||||
@@ -20,8 +21,9 @@ const managedDatabaseLabel = "io.barkstack.pawsql.database"
|
||||
// Provisioner ensures configured PostgreSQL containers exist and exposes their
|
||||
// loopback-published PostgreSQL address to PawSQL's static router.
|
||||
type Provisioner struct {
|
||||
DockerPath string
|
||||
Logger *slog.Logger
|
||||
DockerPath string
|
||||
SecretDirectory string
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewProvisioner creates a Docker CLI-backed provisioner.
|
||||
@@ -29,7 +31,7 @@ func NewProvisioner(logger *slog.Logger) *Provisioner {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Provisioner{DockerPath: "docker", Logger: logger}
|
||||
return &Provisioner{DockerPath: "docker", SecretDirectory: "/run/secrets", Logger: logger}
|
||||
}
|
||||
|
||||
// Ensure provisions each PostgreSQL-backed database and returns configuration
|
||||
@@ -65,13 +67,13 @@ func (p *Provisioner) EnsureDatabase(ctx context.Context, database string, postg
|
||||
return "", err
|
||||
}
|
||||
if !exists {
|
||||
password, ok := os.LookupEnv(postgres.PasswordEnv)
|
||||
if !ok || password == "" {
|
||||
return "", fmt.Errorf("environment variable %q is required to create the container", postgres.PasswordEnv)
|
||||
password, err := p.readSecret(postgres.PasswordSecret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p.Logger.Info("creating PostgreSQL container", "database", database, "container", name, "image", postgres.Image, "volume", postgres.Volume)
|
||||
mount := postgresDataMount(postgres.Image)
|
||||
_, err := p.run(ctx, []string{"POSTGRES_PASSWORD=" + password},
|
||||
_, err = p.run(ctx, []string{"POSTGRES_PASSWORD=" + password},
|
||||
"container", "create",
|
||||
"--name", name,
|
||||
"--label", managedDatabaseLabel+"="+database,
|
||||
@@ -110,6 +112,26 @@ func (p *Provisioner) EnsureDatabase(ctx context.Context, database string, postg
|
||||
return address, nil
|
||||
}
|
||||
|
||||
func (p *Provisioner) readSecret(reference string) (string, error) {
|
||||
if !config.ValidSecretReference(reference) {
|
||||
return "", fmt.Errorf("invalid password secret reference %q", reference)
|
||||
}
|
||||
directory := p.SecretDirectory
|
||||
if directory == "" {
|
||||
directory = "/run/secrets"
|
||||
}
|
||||
name := config.DockerSecretName(reference)
|
||||
contents, err := os.ReadFile(filepath.Join(directory, name))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read Docker secret %q: %w", name, err)
|
||||
}
|
||||
password := strings.TrimRight(string(contents), "\r\n")
|
||||
if password == "" {
|
||||
return "", fmt.Errorf("Docker secret %q is empty", name)
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
|
||||
// StopDatabase stops a managed PostgreSQL container without removing its data volume.
|
||||
func (p *Provisioner) StopDatabase(ctx context.Context, database string) error {
|
||||
name := containerName(database)
|
||||
|
||||
@@ -5,20 +5,26 @@ package postgres
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
config "cloud.campbellwireless.net/git/barkstack/barkfile-parser"
|
||||
config "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2"
|
||||
)
|
||||
|
||||
func TestProvisionerCreatesPersistentPostgres18(t *testing.T) {
|
||||
const (
|
||||
database = "pawsql_integration_test"
|
||||
volume = "pawsql-integration-test-data"
|
||||
envName = "PAWSQL_INTEGRATION_POSTGRES_PASSWORD"
|
||||
database = "pawsql_integration_test"
|
||||
volume = "pawsql-integration-test-data"
|
||||
secretRef = "integration_postgres_password"
|
||||
)
|
||||
t.Setenv(envName, "integration-test-password")
|
||||
secretDirectory := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(secretDirectory, config.DockerSecretName(secretRef)), []byte("integration-test-password"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provisioner := NewProvisioner(nil)
|
||||
provisioner.SecretDirectory = secretDirectory
|
||||
defer func() {
|
||||
_, _ = provisioner.run(context.Background(), nil, "container", "rm", "--force", containerName(database))
|
||||
_, _ = provisioner.run(context.Background(), nil, "volume", "rm", "--force", volume)
|
||||
@@ -27,9 +33,9 @@ func TestProvisionerCreatesPersistentPostgres18(t *testing.T) {
|
||||
cfg := config.Config{Databases: []config.DatabaseConfig{{
|
||||
Name: database,
|
||||
Postgres: &config.PostgresConfig{
|
||||
Image: "postgres:18",
|
||||
Volume: volume,
|
||||
PasswordEnv: envName,
|
||||
Image: "postgres:18",
|
||||
Volume: volume,
|
||||
PasswordSecret: secretRef,
|
||||
},
|
||||
}}}
|
||||
resolved, err := provisioner.Ensure(context.Background(), cfg)
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
package postgres
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
config "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2"
|
||||
)
|
||||
|
||||
func TestPostgresDataMount(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -17,3 +24,41 @@ func TestPostgresDataMount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSecretUsesPrefixedDockerName(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
path := filepath.Join(directory, config.DockerSecretName("application_password"))
|
||||
if err := os.WriteFile(path, []byte("correct horse battery staple\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provisioner := &Provisioner{SecretDirectory: directory}
|
||||
password, err := provisioner.readSecret("application_password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if password != "correct horse battery staple" {
|
||||
t.Fatalf("password = %q", password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSecretRejectsUnsafeReference(t *testing.T) {
|
||||
provisioner := &Provisioner{SecretDirectory: t.TempDir()}
|
||||
_, err := provisioner.readSecret("../../etc/passwd")
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid password secret reference") {
|
||||
t.Fatalf("readSecret() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSecretRejectsMissingAndEmptySecret(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
provisioner := &Provisioner{SecretDirectory: directory}
|
||||
if _, err := provisioner.readSecret("missing"); err == nil || !strings.Contains(err.Error(), "barkstack_missing") {
|
||||
t.Fatalf("missing secret error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(directory, config.DockerSecretName("empty")), []byte("\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := provisioner.readSecret("empty"); err == nil || !strings.Contains(err.Error(), "is empty") {
|
||||
t.Fatalf("empty secret error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
config "cloud.campbellwireless.net/git/barkstack/barkfile-parser"
|
||||
config "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2"
|
||||
"github.com/barkstack/pawsql/internal/router"
|
||||
)
|
||||
|
||||
|
||||
@@ -6,14 +6,14 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
config "cloud.campbellwireless.net/git/barkstack/barkfile-parser"
|
||||
config "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2"
|
||||
"github.com/barkstack/pawsql/internal/router"
|
||||
)
|
||||
|
||||
func TestResolverDefersManagedDatabaseStartup(t *testing.T) {
|
||||
routes, err := router.NewStaticResolver([]config.DatabaseConfig{
|
||||
{Name: "external", Upstream: "192.0.2.1:5432"},
|
||||
{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordEnv: "MANAGED_PASSWORD"}},
|
||||
{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordSecret: "managed_password"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -21,7 +21,7 @@ func TestResolverDefersManagedDatabaseStartup(t *testing.T) {
|
||||
ensurer := &fakeEnsurer{addresses: map[string]string{"managed": "127.0.0.1:55432"}}
|
||||
resolver := NewResolver(routes, []config.DatabaseConfig{
|
||||
{Name: "external", Upstream: "192.0.2.1:5432"},
|
||||
{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordEnv: "MANAGED_PASSWORD"}},
|
||||
{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordSecret: "managed_password"}},
|
||||
}, ensurer)
|
||||
|
||||
backend, err := resolver.ResolveDatabase(context.Background(), "external")
|
||||
@@ -42,12 +42,12 @@ func TestResolverDefersManagedDatabaseStartup(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolverSerializesManagedStartup(t *testing.T) {
|
||||
routes, err := router.NewStaticResolver([]config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordEnv: "MANAGED_PASSWORD"}}})
|
||||
routes, err := router.NewStaticResolver([]config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordSecret: "managed_password"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ensurer := &fakeEnsurer{addresses: map[string]string{"managed": "127.0.0.1:55432"}, gate: make(chan struct{}), started: make(chan struct{})}
|
||||
resolver := NewResolver(routes, []config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordEnv: "MANAGED_PASSWORD"}}}, ensurer)
|
||||
resolver := NewResolver(routes, []config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordSecret: "managed_password"}}}, ensurer)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for range 2 {
|
||||
@@ -69,12 +69,12 @@ func TestResolverSerializesManagedStartup(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolverStopsDatabaseAfterIdleTimeout(t *testing.T) {
|
||||
routes, err := router.NewStaticResolver([]config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordEnv: "MANAGED_PASSWORD", IdleTimeout: 20 * time.Millisecond}}})
|
||||
routes, err := router.NewStaticResolver([]config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordSecret: "managed_password", IdleTimeout: 20 * time.Millisecond}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
controller := &fakeEnsurer{addresses: map[string]string{"managed": "127.0.0.1:55432"}, stopped: make(chan string, 1)}
|
||||
resolver := NewResolver(routes, []config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordEnv: "MANAGED_PASSWORD", IdleTimeout: 20 * time.Millisecond}}}, controller)
|
||||
resolver := NewResolver(routes, []config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordSecret: "managed_password", IdleTimeout: 20 * time.Millisecond}}}, controller)
|
||||
|
||||
if _, err := resolver.ResolveDatabase(context.Background(), "managed"); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -91,12 +91,12 @@ func TestResolverStopsDatabaseAfterIdleTimeout(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolverCancelsIdleStopForNewConnection(t *testing.T) {
|
||||
routes, err := router.NewStaticResolver([]config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordEnv: "MANAGED_PASSWORD", IdleTimeout: 40 * time.Millisecond}}})
|
||||
routes, err := router.NewStaticResolver([]config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordSecret: "managed_password", IdleTimeout: 40 * time.Millisecond}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
controller := &fakeEnsurer{addresses: map[string]string{"managed": "127.0.0.1:55432"}, stopped: make(chan string, 1)}
|
||||
resolver := NewResolver(routes, []config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordEnv: "MANAGED_PASSWORD", IdleTimeout: 40 * time.Millisecond}}}, controller)
|
||||
resolver := NewResolver(routes, []config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordSecret: "managed_password", IdleTimeout: 40 * time.Millisecond}}}, controller)
|
||||
|
||||
if _, err := resolver.ResolveDatabase(context.Background(), "managed"); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -122,7 +122,7 @@ func TestResolverMetersTrafficAndStopsTrafficIdleSession(t *testing.T) {
|
||||
postgres := &config.PostgresConfig{
|
||||
Image: "postgres:18",
|
||||
Volume: "managed-data",
|
||||
PasswordEnv: "MANAGED_PASSWORD",
|
||||
PasswordSecret: "managed_password",
|
||||
TrafficIdleTimeout: 100 * time.Millisecond,
|
||||
}
|
||||
routes, err := router.NewStaticResolver([]config.DatabaseConfig{{Name: "managed", Postgres: postgres}})
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
config "cloud.campbellwireless.net/git/barkstack/barkfile-parser"
|
||||
config "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
config "cloud.campbellwireless.net/git/barkstack/barkfile-parser"
|
||||
config "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2"
|
||||
)
|
||||
|
||||
func TestStaticResolverRoutesCaseInsensitiveHostnames(t *testing.T) {
|
||||
|
||||
@@ -36,22 +36,24 @@ type Server struct {
|
||||
handlers sync.WaitGroup
|
||||
}
|
||||
|
||||
// New validates server dependencies and applies safe protocol defaults.
|
||||
// New validates server dependencies and applies safe protocol defaults. A nil
|
||||
// TLS configuration selects plaintext mode: PostgreSQL SSL negotiation is not
|
||||
// offered and routes are selected by database name only.
|
||||
func New(tlsConfig *tls.Config, resolver router.BackendResolver, logger *slog.Logger) (*Server, error) {
|
||||
if tlsConfig == nil {
|
||||
return nil, errors.New("TLS configuration is required")
|
||||
}
|
||||
if resolver == nil {
|
||||
return nil, errors.New("backend resolver is required")
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
copy := tlsConfig.Clone()
|
||||
if copy.MinVersion == 0 {
|
||||
copy.MinVersion = tls.VersionTLS12
|
||||
if tlsConfig != nil {
|
||||
copy := tlsConfig.Clone()
|
||||
if copy.MinVersion == 0 {
|
||||
copy.MinVersion = tls.VersionTLS12
|
||||
}
|
||||
tlsConfig = copy
|
||||
}
|
||||
return &Server{TLSConfig: copy, Resolver: resolver, Logger: logger, HandshakeTimeout: defaultHandshakeTimeout, ResolveTimeout: defaultResolveTimeout, DialTimeout: defaultDialTimeout}, nil
|
||||
return &Server{TLSConfig: tlsConfig, Resolver: resolver, Logger: logger, HandshakeTimeout: defaultHandshakeTimeout, ResolveTimeout: defaultResolveTimeout, DialTimeout: defaultDialTimeout}, nil
|
||||
}
|
||||
|
||||
// Serve accepts connections until Shutdown closes its listener.
|
||||
@@ -114,26 +116,28 @@ func (s *Server) handle(connection net.Conn) {
|
||||
handshakeTimeout = defaultHandshakeTimeout
|
||||
}
|
||||
_ = connection.SetDeadline(time.Now().Add(handshakeTimeout))
|
||||
if err := pgwire.ReadSSLRequest(connection); err != nil {
|
||||
s.Logger.Warn("connection rejected", "remote_address", remote, "result", "invalid_ssl_request", "error", err)
|
||||
return
|
||||
}
|
||||
if _, err := connection.Write([]byte{'S'}); err != nil {
|
||||
s.Logger.Warn("connection rejected", "remote_address", remote, "result", "ssl_response_failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
tlsConnection := tls.Server(connection, s.TLSConfig)
|
||||
if err := tlsConnection.Handshake(); err != nil {
|
||||
s.Logger.Warn("connection rejected", "remote_address", remote, "result", "tls_handshake_failed", "error", err)
|
||||
return
|
||||
}
|
||||
_ = tlsConnection.SetDeadline(time.Time{})
|
||||
sni = normalizeHostname(tlsConnection.ConnectionState().ServerName)
|
||||
var (
|
||||
proxyClient net.Conn = tlsConnection
|
||||
proxyClient net.Conn = connection
|
||||
err error
|
||||
)
|
||||
if s.TLSConfig != nil {
|
||||
if err := pgwire.ReadSSLRequest(connection); err != nil {
|
||||
s.Logger.Warn("connection rejected", "remote_address", remote, "result", "invalid_ssl_request", "error", err)
|
||||
return
|
||||
}
|
||||
if _, err := connection.Write([]byte{'S'}); err != nil {
|
||||
s.Logger.Warn("connection rejected", "remote_address", remote, "result", "ssl_response_failed", "error", err)
|
||||
return
|
||||
}
|
||||
tlsConnection := tls.Server(connection, s.TLSConfig)
|
||||
if err := tlsConnection.Handshake(); err != nil {
|
||||
s.Logger.Warn("connection rejected", "remote_address", remote, "result", "tls_handshake_failed", "error", err)
|
||||
return
|
||||
}
|
||||
_ = tlsConnection.SetDeadline(time.Time{})
|
||||
sni = normalizeHostname(tlsConnection.ConnectionState().ServerName)
|
||||
proxyClient = tlsConnection
|
||||
}
|
||||
resolveTimeout := s.ResolveTimeout
|
||||
if resolveTimeout <= 0 {
|
||||
resolveTimeout = defaultResolveTimeout
|
||||
@@ -141,6 +145,14 @@ func (s *Server) handle(connection net.Conn) {
|
||||
resolveContext, cancelResolve := context.WithTimeout(context.Background(), resolveTimeout)
|
||||
defer cancelResolve()
|
||||
|
||||
if s.TLSConfig == nil {
|
||||
negotiated, negotiateErr := pgwire.NegotiatePlainPostgreSQL(connection)
|
||||
if negotiateErr != nil {
|
||||
s.Logger.Warn("connection rejected", "remote_address", remote, "result", "invalid_negotiation", "error", negotiateErr)
|
||||
return
|
||||
}
|
||||
proxyClient = negotiated
|
||||
}
|
||||
if sni != "" {
|
||||
backend, err = s.Resolver.Resolve(resolveContext, sni)
|
||||
if err != nil {
|
||||
@@ -148,7 +160,7 @@ func (s *Server) handle(connection net.Conn) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
startup, startupErr := pgwire.ReadStartupMessage(tlsConnection)
|
||||
startup, startupErr := pgwire.ReadStartupMessage(proxyClient)
|
||||
if startupErr != nil {
|
||||
s.Logger.Warn("connection rejected", "remote_address", remote, "result", "invalid_startup_message", "error", startupErr)
|
||||
return
|
||||
@@ -158,7 +170,7 @@ func (s *Server) handle(connection net.Conn) {
|
||||
s.Logger.Warn("connection rejected", "remote_address", remote, "result", "unknown_database", "error", err)
|
||||
return
|
||||
}
|
||||
proxyClient = pgwire.Replay(tlsConnection, startup.Bytes())
|
||||
proxyClient = pgwire.Replay(proxyClient, startup.Bytes())
|
||||
}
|
||||
|
||||
if leases, ok := s.Resolver.(router.ConnectionLeaseManager); ok {
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
config "cloud.campbellwireless.net/git/barkstack/barkfile-parser"
|
||||
config "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2"
|
||||
"github.com/barkstack/pawsql/internal/pgwire"
|
||||
"github.com/barkstack/pawsql/internal/router"
|
||||
)
|
||||
@@ -195,6 +195,141 @@ func TestServerRoutesNoSNIByConfiguredDatabase(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerRoutesPlaintextConnectionsByDatabase(t *testing.T) {
|
||||
backendListener := listen(t)
|
||||
defer backendListener.Close()
|
||||
backendDatabase := make(chan string, 1)
|
||||
go func() {
|
||||
connection, err := backendListener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer connection.Close()
|
||||
startup, err := pgwire.ReadStartupMessage(connection)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
backendDatabase <- startup.Database
|
||||
_, _ = connection.Write([]byte("backend"))
|
||||
}()
|
||||
|
||||
staticResolver, err := router.NewStaticResolver([]config.DatabaseConfig{{
|
||||
Name: "analytics",
|
||||
Upstream: backendListener.Addr().String(),
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
routingServer, err := New(nil, staticResolver, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listener := listen(t)
|
||||
defer func() {
|
||||
_ = routingServer.Shutdown()
|
||||
routingServer.Wait()
|
||||
_ = listener.Close()
|
||||
}()
|
||||
go func() { _ = routingServer.Serve(listener) }()
|
||||
|
||||
connection, err := net.Dial("tcp", listener.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer connection.Close()
|
||||
if _, err := connection.Write(startupMessage("analytics")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response := make([]byte, len("backend"))
|
||||
if _, err := io.ReadFull(connection, response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(response) != "backend" {
|
||||
t.Fatalf("proxied response = %q", response)
|
||||
}
|
||||
select {
|
||||
case database := <-backendDatabase:
|
||||
if database != "analytics" {
|
||||
t.Errorf("backend database = %q, want analytics", database)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("backend did not receive the plaintext StartupMessage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerDeclinesSSLNegotiationInPlaintextMode(t *testing.T) {
|
||||
backendListener := listen(t)
|
||||
defer backendListener.Close()
|
||||
backendDatabase := make(chan string, 1)
|
||||
go func() {
|
||||
connection, err := backendListener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer connection.Close()
|
||||
startup, err := pgwire.ReadStartupMessage(connection)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
backendDatabase <- startup.Database
|
||||
_, _ = connection.Write([]byte("backend"))
|
||||
}()
|
||||
|
||||
staticResolver, err := router.NewStaticResolver([]config.DatabaseConfig{{
|
||||
Name: "analytics",
|
||||
Upstream: backendListener.Addr().String(),
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
routingServer, err := New(nil, staticResolver, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listener := listen(t)
|
||||
defer func() {
|
||||
_ = routingServer.Shutdown()
|
||||
routingServer.Wait()
|
||||
_ = listener.Close()
|
||||
}()
|
||||
go func() { _ = routingServer.Serve(listener) }()
|
||||
|
||||
connection, err := net.Dial("tcp", listener.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer connection.Close()
|
||||
request := pgwire.SSLRequest()
|
||||
if _, err := connection.Write(request[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response := make([]byte, 1)
|
||||
if _, err := io.ReadFull(connection, response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response[0] != 'N' {
|
||||
t.Fatalf("SSL response = %q, want N", response)
|
||||
}
|
||||
if _, err := connection.Write(startupMessage("analytics")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response = make([]byte, len("backend"))
|
||||
if _, err := io.ReadFull(connection, response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(response) != "backend" {
|
||||
t.Fatalf("proxied response = %q", response)
|
||||
}
|
||||
select {
|
||||
case database := <-backendDatabase:
|
||||
if database != "analytics" {
|
||||
t.Errorf("backend database = %q, want analytics", database)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("backend did not receive the StartupMessage after SSL decline")
|
||||
}
|
||||
}
|
||||
|
||||
func startupMessage(database string) []byte {
|
||||
body := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(body, 196608)
|
||||
|
||||
350
ui/bun.lock
Normal file
350
ui/bun.lock
Normal file
@@ -0,0 +1,350 @@
|
||||
{
|
||||
"lockfileVersion": 2,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@barkstack/pawsql-ui",
|
||||
"dependencies": {
|
||||
"svelte": "^5.38.10",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||
"jsdom": "^27.0.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.5",
|
||||
"vitest": "^3.2.4",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="],
|
||||
|
||||
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.1.2", "", { "dependencies": { "@csstools/css-calc": "^3.0.0", "@csstools/css-color-parser": "^4.0.1", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.2.5" } }, "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg=="],
|
||||
|
||||
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.8.1", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.6" } }, "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ=="],
|
||||
|
||||
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
|
||||
|
||||
"@csstools/color-helpers": ["@csstools/color-helpers@6.1.1", "", {}, "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w=="],
|
||||
|
||||
"@csstools/css-calc": ["@csstools/css-calc@3.4.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg=="],
|
||||
|
||||
"@csstools/css-color-parser": ["@csstools/css-color-parser@4.2.3", "", { "dependencies": { "@csstools/color-helpers": "^6.1.1", "@csstools/css-calc": "^3.4.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA=="],
|
||||
|
||||
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="],
|
||||
|
||||
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.14", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ=="],
|
||||
|
||||
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
|
||||
|
||||
"@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.63.3", "", { "os": "android", "cpu": "arm" }, "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.63.3", "", { "os": "android", "cpu": "arm64" }, "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.63.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.63.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.63.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.63.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.63.3", "", { "os": "linux", "cpu": "arm" }, "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.63.3", "", { "os": "linux", "cpu": "arm" }, "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.63.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.63.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.63.3", "", { "os": "linux", "cpu": "none" }, "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.63.3", "", { "os": "linux", "cpu": "none" }, "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.63.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.63.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.63.3", "", { "os": "linux", "cpu": "none" }, "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.63.3", "", { "os": "linux", "cpu": "none" }, "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.63.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.63.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.63.3", "", { "os": "linux", "cpu": "x64" }, "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.63.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.63.3", "", { "os": "none", "cpu": "arm64" }, "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.63.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.63.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.63.3", "", { "os": "win32", "cpu": "x64" }, "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.63.3", "", { "os": "win32", "cpu": "x64" }, "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA=="],
|
||||
|
||||
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.13", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ=="],
|
||||
|
||||
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA=="],
|
||||
|
||||
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.2", "", { "dependencies": { "obug": "^2.1.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@3.2.7", "", { "dependencies": { "@vitest/spy": "3.2.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA=="],
|
||||
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@3.2.7", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA=="],
|
||||
|
||||
"@vitest/runner": ["@vitest/runner@3.2.7", "", { "dependencies": { "@vitest/utils": "3.2.7", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA=="],
|
||||
|
||||
"@vitest/snapshot": ["@vitest/snapshot@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@3.2.7", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw=="],
|
||||
|
||||
"acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||
|
||||
"bidi-js": ["bidi-js@1.1.0", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg=="],
|
||||
|
||||
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
||||
|
||||
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
||||
|
||||
"check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
|
||||
|
||||
"cssstyle": ["cssstyle@5.3.7", "", { "dependencies": { "@asamuzakjp/css-color": "^4.1.1", "@csstools/css-syntax-patches-for-csstree": "^1.0.21", "css-tree": "^3.1.0", "lru-cache": "^11.2.4" } }, "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ=="],
|
||||
|
||||
"data-urls": ["data-urls@6.0.1", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^15.1.0" } }, "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||
|
||||
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
|
||||
|
||||
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||
|
||||
"devalue": ["devalue@5.9.2", "", {}, "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w=="],
|
||||
|
||||
"entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
|
||||
|
||||
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
|
||||
|
||||
"esrap": ["esrap@2.3.7", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-n2nf7fZR3c9yXf0BPEuHuXqT+KW0SJVj4cN5FMEkpCZ3scLjOQWpiccyCxVzCC2q1wubTghuEGzngJY/7Ah0Ow=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
|
||||
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
|
||||
|
||||
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
||||
|
||||
"js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
|
||||
|
||||
"jsdom": ["jsdom@27.4.0", "", { "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", "@exodus/bytes": "^1.6.0", "cssstyle": "^5.3.4", "data-urls": "^6.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.0", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.1.0", "ws": "^8.18.3", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ=="],
|
||||
|
||||
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
||||
|
||||
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
|
||||
|
||||
"lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
|
||||
|
||||
"obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="],
|
||||
|
||||
"parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
||||
|
||||
"postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"rollup": ["rollup@4.63.3", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.3", "@rollup/rollup-android-arm64": "4.63.3", "@rollup/rollup-darwin-arm64": "4.63.3", "@rollup/rollup-darwin-x64": "4.63.3", "@rollup/rollup-freebsd-arm64": "4.63.3", "@rollup/rollup-freebsd-x64": "4.63.3", "@rollup/rollup-linux-arm-gnueabihf": "4.63.3", "@rollup/rollup-linux-arm-musleabihf": "4.63.3", "@rollup/rollup-linux-arm64-gnu": "4.63.3", "@rollup/rollup-linux-arm64-musl": "4.63.3", "@rollup/rollup-linux-loong64-gnu": "4.63.3", "@rollup/rollup-linux-loong64-musl": "4.63.3", "@rollup/rollup-linux-ppc64-gnu": "4.63.3", "@rollup/rollup-linux-ppc64-musl": "4.63.3", "@rollup/rollup-linux-riscv64-gnu": "4.63.3", "@rollup/rollup-linux-riscv64-musl": "4.63.3", "@rollup/rollup-linux-s390x-gnu": "4.63.3", "@rollup/rollup-linux-x64-gnu": "4.63.3", "@rollup/rollup-linux-x64-musl": "4.63.3", "@rollup/rollup-openbsd-x64": "4.63.3", "@rollup/rollup-openharmony-arm64": "4.63.3", "@rollup/rollup-win32-arm64-msvc": "4.63.3", "@rollup/rollup-win32-ia32-msvc": "4.63.3", "@rollup/rollup-win32-x64-gnu": "4.63.3", "@rollup/rollup-win32-x64-msvc": "4.63.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw=="],
|
||||
|
||||
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
|
||||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||
|
||||
"strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
|
||||
|
||||
"svelte": ["svelte@5.57.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-NdbDn7fl4be1ViUG0oq/lvG6OZy3oENolV2ONjiqqsfVoeAfzaQAKUcEX3MrQod/Bebv1PgwET9rfXhgn9s4Kg=="],
|
||||
|
||||
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
|
||||
|
||||
"tinyspy": ["tinyspy@4.0.6", "", {}, "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg=="],
|
||||
|
||||
"tldts": ["tldts@7.4.13", "", { "dependencies": { "tldts-core": "^7.4.13" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-iHtaIWWIbMDkCeJdTBzZFGgbluE5J+oHlb2g7+oAz1S1gpuVpabRZdQyd471Vl8UUkcz2vXSL8xZH2kyCe8tfA=="],
|
||||
|
||||
"tldts-core": ["tldts-core@7.4.13", "", {}, "sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg=="],
|
||||
|
||||
"tough-cookie": ["tough-cookie@6.0.2", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA=="],
|
||||
|
||||
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
|
||||
|
||||
"vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
|
||||
|
||||
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
|
||||
|
||||
"vitest": ["vitest@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.7", "@vitest/mocker": "3.2.7", "@vitest/pretty-format": "^3.2.7", "@vitest/runner": "3.2.7", "@vitest/snapshot": "3.2.7", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.7", "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg=="],
|
||||
|
||||
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@15.1.0", "", { "dependencies": { "tr46": "^6.0.0", "webidl-conversions": "^8.0.0" } }, "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g=="],
|
||||
|
||||
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||
|
||||
"ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
|
||||
|
||||
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
|
||||
|
||||
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
|
||||
|
||||
"zimmerframe": ["zimmerframe@1.1.5", "", {}, "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA=="],
|
||||
|
||||
"data-urls/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
|
||||
}
|
||||
}
|
||||
20
ui/package.json
Normal file
20
ui/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@barkstack/pawsql-ui",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"svelte": "^5.38.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||
"jsdom": "^27.0.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.5",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
113
ui/src/PawSQL.svelte
Normal file
113
ui/src/PawSQL.svelte
Normal file
@@ -0,0 +1,113 @@
|
||||
<svelte:options customElement={{ tag: 'barkstack-pawsql', shadow: 'open', props: { route: { type: 'String', reflect: true } } }} />
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { dispatchNavigate, dispatchNotify } from './events';
|
||||
|
||||
type Route = { name: string; hostname?: string; managed: boolean };
|
||||
type Status = { status: string; routes: Route[]; endpoint: string };
|
||||
|
||||
let { route = '/' }: { route?: string } = $props();
|
||||
let root: HTMLElement;
|
||||
let status = $state<Status | undefined>();
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
async function loadStatus() {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetch('/plugins/pawsql/ui/status.json', { headers: { Accept: 'application/json' }, cache: 'no-store' });
|
||||
if (!response.ok) throw new Error(`Status returned ${response.status}`);
|
||||
status = await response.json() as Status;
|
||||
error = '';
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Status is unavailable';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function navigate(path: string) { dispatchNavigate(root, path); }
|
||||
|
||||
async function copyEndpoint() {
|
||||
const endpoint = status?.endpoint ?? '*.pawsql.barkstack.dev:5432';
|
||||
try {
|
||||
await navigator.clipboard.writeText(endpoint);
|
||||
dispatchNotify(root, 'success', 'Connection endpoint copied');
|
||||
} catch {
|
||||
dispatchNotify(root, 'info', endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(loadStatus);
|
||||
</script>
|
||||
|
||||
<section bind:this={root}>
|
||||
<div class="intro">
|
||||
<div>
|
||||
<span class="eyebrow">PostgreSQL routing</span>
|
||||
<h2>PawSQL</h2>
|
||||
<p>Secure, service-aware PostgreSQL routing for Barkstack workloads.</p>
|
||||
</div>
|
||||
<div class="service-status" class:offline={!!error}>
|
||||
<span></span><div><small>Status</small><strong>{error ? 'Degraded' : (status?.status ?? 'Connecting')}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if route !== '/'}
|
||||
<button class="back" onclick={() => navigate('/pawsql')}>← PawSQL overview</button>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="loading"><span></span><span></span><span></span></div>
|
||||
{:else if error}
|
||||
<div class="error" role="alert"><strong>Live status is unavailable</strong><p>{error}</p><button onclick={loadStatus}>Try again</button></div>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
<article class="endpoint-card">
|
||||
<span class="label">Connection endpoint</span>
|
||||
<div class="endpoint"><code>{status?.endpoint}</code><button onclick={copyEndpoint} title="Copy endpoint">Copy</button></div>
|
||||
<p>Use a configured database hostname or database name to select a route.</p>
|
||||
</article>
|
||||
<article>
|
||||
<span class="label">Configured routes</span><strong class="metric">{status?.routes.length ?? 0}</strong><p>Routes loaded from this PawSQL release configuration.</p>
|
||||
</article>
|
||||
<article>
|
||||
<span class="label">Router state</span><strong class="metric healthy">Running</strong><p>PostgreSQL listener and management UI are online.</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="routes-panel">
|
||||
<div class="panel-heading"><div><span class="eyebrow">Routing table</span><h3>{route.startsWith('/routes') ? 'All configured routes' : 'Active configuration'}</h3></div>{#if route === '/'}<button onclick={() => navigate('/pawsql/routes')}>View routes →</button>{/if}</div>
|
||||
{#if !status?.routes.length}
|
||||
<div class="empty">No database routes are configured.</div>
|
||||
{:else}
|
||||
<div class="table" role="table" aria-label="Configured PostgreSQL routes">
|
||||
<div class="row header" role="row"><span>Database</span><span>Hostname</span><span>Backend</span><span>Status</span></div>
|
||||
{#each status.routes as configuredRoute (configuredRoute.name)}
|
||||
<div class="row" role="row"><strong>{configuredRoute.name}</strong><code>{configuredRoute.hostname || 'Database name'}</code><span>{configuredRoute.managed ? 'Managed PostgreSQL' : 'External upstream'}</span><span class="ready"><i></i>Ready</span></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
:host { display: block; color: var(--bark-text, #edf3fb); font-family: var(--bark-font-sans, system-ui, sans-serif); }
|
||||
* { box-sizing: border-box; }
|
||||
section { display: grid; gap: var(--bark-space-5, 1.5rem); }
|
||||
.intro { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--bark-space-5, 1.5rem); }
|
||||
.eyebrow, .label { color: var(--bark-primary, #6ee7b7); font-size: .69rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
|
||||
h2 { margin: .55rem 0 .35rem; font-size: 2.25rem; letter-spacing: -.05em; } h3 { margin: .4rem 0 0; font-size: 1.15rem; }
|
||||
p { margin: 0; color: var(--bark-text-muted, #91a1b7); line-height: 1.6; }
|
||||
.service-status { display: flex; align-items: center; gap: .65rem; padding: .65rem .9rem; border: 1px solid var(--bark-border, #27354a); border-radius: var(--bark-radius-md, .75rem); background: var(--bark-surface, #101827); }.service-status > span { width: .55rem; height: .55rem; border-radius: 50%; background: var(--bark-success, #4ade80); box-shadow: 0 0 0 5px color-mix(in srgb, var(--bark-success, #4ade80) 12%, transparent); }.service-status.offline > span { background: var(--bark-danger, #fb7185); }.service-status small, .service-status strong { display: block; }.service-status small { color: var(--bark-text-muted, #91a1b7); font-size: .66rem; text-transform: uppercase; letter-spacing: .08em; }.service-status strong { margin-top: .15rem; font-size: .82rem; }
|
||||
.back { justify-self: start; border: 0; background: none; color: var(--bark-text-muted, #91a1b7); cursor: pointer; padding: 0; }.back:hover { color: var(--bark-text, #edf3fb); }
|
||||
.grid { display: grid; grid-template-columns: 1.5fr .75fr .75fr; gap: var(--bark-space-4, 1rem); }.grid article { min-height: 10rem; padding: var(--bark-space-5, 1.5rem); border: 1px solid var(--bark-border, #27354a); border-radius: var(--bark-radius-lg, 1rem); background: var(--bark-surface, #101827); }.grid article p { margin-top: .8rem; font-size: .82rem; }.metric { display: block; margin-top: .9rem; font-size: 1.65rem; letter-spacing: -.04em; }.metric.healthy { color: var(--bark-success, #4ade80); }
|
||||
.endpoint { display: flex; align-items: center; gap: .7rem; margin-top: 1rem; }.endpoint code { flex: 1; overflow: hidden; color: var(--bark-text, #edf3fb); font-family: var(--bark-font-mono, monospace); font-size: .86rem; text-overflow: ellipsis; }.endpoint button, .panel-heading button, .error button { padding: .45rem .7rem; border: 1px solid var(--bark-border, #27354a); border-radius: var(--bark-radius-sm, .45rem); background: var(--bark-surface-raised, #172235); color: var(--bark-text, #edf3fb); cursor: pointer; }
|
||||
.routes-panel { border: 1px solid var(--bark-border, #27354a); border-radius: var(--bark-radius-lg, 1rem); background: var(--bark-surface, #101827); overflow: hidden; }.panel-heading { display: flex; align-items: center; justify-content: space-between; padding: var(--bark-space-5, 1.5rem); border-bottom: 1px solid var(--bark-border, #27354a); }.panel-heading button { border: 0; background: transparent; color: var(--bark-primary, #6ee7b7); }
|
||||
.row { display: grid; grid-template-columns: 1fr 1.5fr 1.25fr .6fr; gap: 1rem; align-items: center; min-height: 3.7rem; padding: .75rem var(--bark-space-5, 1.5rem); border-bottom: 1px solid var(--bark-border, #27354a); font-size: .82rem; }.row:last-child { border-bottom: 0; }.row.header { min-height: 2.8rem; color: var(--bark-text-muted, #91a1b7); background: var(--bark-surface-raised, #172235); font-size: .68rem; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; }.row code { color: var(--bark-text-muted, #91a1b7); font-family: var(--bark-font-mono, monospace); }.ready { color: var(--bark-success, #4ade80); }.ready i { display: inline-block; width: .4rem; height: .4rem; margin-right: .4rem; border-radius: 50%; background: currentColor; }
|
||||
.empty, .error { padding: 3rem; text-align: center; color: var(--bark-text-muted, #91a1b7); }.error { border: 1px solid color-mix(in srgb, var(--bark-danger, #fb7185) 30%, var(--bark-border, #27354a)); border-radius: var(--bark-radius-lg, 1rem); background: var(--bark-surface, #101827); }.error strong { color: var(--bark-text, #edf3fb); }.error p { margin: .5rem 0 1rem; }
|
||||
.loading { display: grid; gap: .8rem; padding: 2rem; border: 1px solid var(--bark-border, #27354a); border-radius: var(--bark-radius-lg, 1rem); background: var(--bark-surface, #101827); }.loading span { height: 1rem; border-radius: .3rem; background: var(--bark-surface-raised, #172235); animation: pulse 1.4s infinite; }.loading span:nth-child(2) { width: 65%; }.loading span:nth-child(3) { width: 40%; } @keyframes pulse { 50% { opacity: .4; } }
|
||||
@media (max-width: 850px) { .intro { flex-direction: column; }.grid { grid-template-columns: 1fr; }.row { grid-template-columns: 1fr; gap: .35rem; padding: 1rem; }.row.header { display: none; }.endpoint { align-items: stretch; flex-direction: column; } }
|
||||
</style>
|
||||
1
ui/src/entry.ts
Normal file
1
ui/src/entry.ts
Normal file
@@ -0,0 +1 @@
|
||||
import './PawSQL.svelte';
|
||||
22
ui/src/events.test.ts
Normal file
22
ui/src/events.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { dispatchNavigate, dispatchNotify } from './events';
|
||||
|
||||
describe('Barkstack host events', () => {
|
||||
it('requests host-owned navigation with a composed custom event', () => {
|
||||
const target = new EventTarget();
|
||||
const listener = vi.fn();
|
||||
target.addEventListener('barkstack:navigate', listener);
|
||||
dispatchNavigate(target, '/pawsql/routes');
|
||||
const event = listener.mock.calls[0][0] as CustomEvent;
|
||||
expect(event.detail).toEqual({ path: '/pawsql/routes' });
|
||||
expect(event.composed).toBe(true);
|
||||
});
|
||||
|
||||
it('requests a global notification', () => {
|
||||
const target = new EventTarget();
|
||||
let detail: unknown;
|
||||
target.addEventListener('barkstack:notify', (event) => { detail = (event as CustomEvent).detail; });
|
||||
dispatchNotify(target, 'success', 'Endpoint copied');
|
||||
expect(detail).toEqual({ level: 'success', message: 'Endpoint copied' });
|
||||
});
|
||||
});
|
||||
15
ui/src/events.ts
Normal file
15
ui/src/events.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export function dispatchNavigate(target: EventTarget, path: string): void {
|
||||
target.dispatchEvent(new CustomEvent('barkstack:navigate', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: { path }
|
||||
}));
|
||||
}
|
||||
|
||||
export function dispatchNotify(target: EventTarget, level: 'success' | 'info' | 'warning' | 'error', message: string): void {
|
||||
target.dispatchEvent(new CustomEvent('barkstack:notify', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: { level, message }
|
||||
}));
|
||||
}
|
||||
17
ui/tsconfig.json
Normal file
17
ui/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"target": "ES2022"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"]
|
||||
}
|
||||
18
ui/vite.config.ts
Normal file
18
ui/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
import { defineConfig } from 'vite';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte({ compilerOptions: { customElement: true } })],
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/entry.ts'),
|
||||
formats: ['es'],
|
||||
fileName: () => 'assets/entry.js'
|
||||
},
|
||||
outDir: '../internal/adminui/dist',
|
||||
emptyOutDir: true,
|
||||
target: 'es2022'
|
||||
},
|
||||
test: { environment: 'jsdom' }
|
||||
});
|
||||
Reference in New Issue
Block a user