feat: TreatVault encrypted secret manager with Docker Swarm sync and console plugin
This commit is contained in:
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
.git
|
||||
ui/node_modules
|
||||
16
Dockerfile
Normal file
16
Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM golang:1.24-alpine AS build
|
||||
WORKDIR /src
|
||||
RUN apk add --no-cache git
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY cmd ./cmd
|
||||
COPY internal ./internal
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /treatvault ./cmd/treatvault
|
||||
|
||||
FROM alpine:3.21
|
||||
RUN apk add --no-cache ca-certificates docker-cli age
|
||||
COPY --from=build /treatvault /usr/local/bin/treatvault
|
||||
EXPOSE 9090
|
||||
ENTRYPOINT ["/usr/local/bin/treatvault"]
|
||||
CMD ["serve", "--file", "/var/lib/treatvault/secrets.age", "--identity", "/run/secrets/barkstack_treatvault_identity", "--listen", ":9090"]
|
||||
56
README.md
56
README.md
@@ -1,2 +1,56 @@
|
||||
# treatvault
|
||||
# TreatVault
|
||||
|
||||
TreatVault manages Barkstack's namespaced secrets. It keeps an age-encrypted source-of-truth file on disk, watches it for changes, and reconciles its contents with Docker Swarm secrets.
|
||||
|
||||
## Model
|
||||
|
||||
- The Barkfile `treatvault` block names the encrypted file (a bind-mounted path) and the bootstrap Docker secret holding the age X25519 identity (`barkstack_<reference>`).
|
||||
- The encrypted file is safe to commit and back up: contents are age-encrypted with the X25519 identity; only the identity secret itself stays outside source control.
|
||||
- Each logical secret is stored as a versioned record. Sync creates one immutable Docker secret per record revision, named `barkstack_tv_<vaultId>_<revision>`, labeled with the logical name and vault ID.
|
||||
- Consumers mount secrets at the stable target path `barkstack_<name>`; rotations swap the mounted source and update the service, leaving a rollback path.
|
||||
- Obsolete Barkstack-managed secret objects are deleted; objects still mounted by a managed service are reported in the sync status and refused for deletion via the API until their consumers are updated.
|
||||
|
||||
## CLI
|
||||
|
||||
```sh
|
||||
treatvault keygen --identity ./treatvault-identity.txt
|
||||
treatvault init --file ./secrets.age --identity ./treatvault-identity.txt
|
||||
treatvault set --name database_password --file ./secrets.age --identity ./treatvault-identity.txt # value on stdin
|
||||
treatvault list --file ./secrets.age --identity ./treatvault-identity.txt
|
||||
treatvault delete --name database_password --file ./secrets.age --identity ./treatvault-identity.txt
|
||||
treatvault serve --file ./secrets.age --identity ./treatvault-identity.txt --listen :9090
|
||||
```
|
||||
|
||||
## HTTP API
|
||||
|
||||
- `GET /barkstack/ui/manifest.json` - Barkstack plugin manifest (id `treatvault`, mount `/treatvault`).
|
||||
- `GET /barkstack/api/secrets` - secret names, sync state, consumers; never returns values.
|
||||
- `PUT /barkstack/api/secrets/{name}` - create or rotate (`{"value":"..."}`); triggers an immediate sync.
|
||||
- `DELETE /barkstack/api/secrets/{name}` - delete; `409 Conflict` while a managed service still mounts the secret.
|
||||
- `GET /healthz` - ready/degraded sync state.
|
||||
|
||||
## Barkstack deployment
|
||||
|
||||
Add a `treatvault` block to the Barkfile and run `barkstack init` on a Swarm manager:
|
||||
|
||||
```text
|
||||
treatvault {
|
||||
file ./secrets.age
|
||||
identity_secret treatvault_identity
|
||||
}
|
||||
```
|
||||
|
||||
`barkstack init` provisions the `barkstack-treatvault` service (manager-constrained) with the encrypted file bind-mounted read-write at its host path, the Docker socket, the `barkstack_treatvault_identity` secret mounted at `/run/secrets/barkstack_treatvault_identity`, and no published ports; the console reaches it over the overlay network at `:9090`.
|
||||
|
||||
Services that consume vault secrets carry the `io.barkstack.treatvault.secrets=true` label plus an `io.barkstack.treatvault.names` label listing the logical names they need; `barkstack init` labels the PawSQL service automatically. Each sync mounts the current revision at the stable `/run/secrets/barkstack_<name>` target, so consumers read the same path across rotations.
|
||||
|
||||
The Barkstack console automatically gains a TreatVault page (plugin id `treatvault`) for creating, rotating, and deleting secrets. Deletion is refused with `409 Conflict` while a labeled service still mounts the secret.
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
cd ui && bun install && bun run build && cd ..
|
||||
go build -o treatvault ./cmd/treatvault
|
||||
docker build -t treatvault .
|
||||
```
|
||||
|
||||
|
||||
221
cmd/treatvault/main.go
Normal file
221
cmd/treatvault/main.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/adminui"
|
||||
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/manager"
|
||||
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/provider"
|
||||
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/vault"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
if err := run(ctx, os.Args[1:], os.Stdin, os.Stdout, os.Stderr); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "treatvault:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
|
||||
if len(args) == 0 {
|
||||
return usage(stderr)
|
||||
}
|
||||
switch args[0] {
|
||||
case "keygen":
|
||||
return runKeygen(args[1:], stdout, stderr)
|
||||
case "init":
|
||||
return runInit(args[1:], stderr)
|
||||
case "set":
|
||||
return runSet(args[1:], stdin, stderr)
|
||||
case "delete":
|
||||
return runDelete(args[1:], stderr)
|
||||
case "list":
|
||||
return runList(args[1:], stdout, stderr)
|
||||
case "serve":
|
||||
return runServe(ctx, args[1:], stderr)
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runKeygen(args []string, stdout, stderr io.Writer) error {
|
||||
flags := flag.NewFlagSet("keygen", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
identityPath := flags.String("identity", "treatvault-identity.txt", "age identity output path")
|
||||
if err := parseNoArguments(flags, args); err != nil {
|
||||
return err
|
||||
}
|
||||
recipient, err := vault.GenerateIdentity(*identityPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = fmt.Fprintf(stdout, "Identity written to %s\nRecipient: %s\n", *identityPath, recipient)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runInit(args []string, stderr io.Writer) error {
|
||||
flags, filePath, identityPath := fileFlags("init", stderr)
|
||||
if err := parseNoArguments(flags, args); err != nil {
|
||||
return err
|
||||
}
|
||||
identity, err := vault.ReadIdentity(*identityPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return vault.Initialize(*filePath, identity)
|
||||
}
|
||||
|
||||
func runSet(args []string, stdin io.Reader, stderr io.Writer) error {
|
||||
flags, filePath, identityPath := fileFlags("set", stderr)
|
||||
name := flags.String("name", "", "short secret name")
|
||||
valuePath := flags.String("value-file", "-", "secret value file, or - for stdin")
|
||||
if err := parseNoArguments(flags, args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *name == "" {
|
||||
return errors.New("--name is required")
|
||||
}
|
||||
value, err := readValue(*valuePath, stdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store, err := vault.Open(*filePath, *identityPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = store.Set(*name, value)
|
||||
return err
|
||||
}
|
||||
|
||||
func runDelete(args []string, stderr io.Writer) error {
|
||||
flags, filePath, identityPath := fileFlags("delete", stderr)
|
||||
name := flags.String("name", "", "short secret name")
|
||||
if err := parseNoArguments(flags, args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *name == "" {
|
||||
return errors.New("--name is required")
|
||||
}
|
||||
store, err := vault.Open(*filePath, *identityPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = store.Delete(*name)
|
||||
return err
|
||||
}
|
||||
|
||||
func runList(args []string, stdout, stderr io.Writer) error {
|
||||
flags, filePath, identityPath := fileFlags("list", stderr)
|
||||
if err := parseNoArguments(flags, args); err != nil {
|
||||
return err
|
||||
}
|
||||
store, err := vault.Open(*filePath, *identityPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snapshot, err := store.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
names := make([]string, 0, len(snapshot.Secrets))
|
||||
for name := range snapshot.Secrets {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
_, _ = fmt.Fprintln(stdout, name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runServe(ctx context.Context, args []string, stderr io.Writer) error {
|
||||
flags, filePath, identityPath := fileFlags("serve", stderr)
|
||||
listen := flags.String("listen", ":9090", "HTTP listen address")
|
||||
dockerPath := flags.String("docker", "docker", "Docker CLI path")
|
||||
if err := parseNoArguments(flags, args); err != nil {
|
||||
return err
|
||||
}
|
||||
store, err := vault.Open(*filePath, *identityPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secretManager := manager.New(store, provider.Docker{Runner: provider.ExecRunner{Path: *dockerPath}}, 500*time.Millisecond)
|
||||
go secretManager.Run(ctx)
|
||||
listener, err := net.Listen("tcp", *listen)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen on %s: %w", *listen, err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
logger.Info("TreatVault listening", "address", listener.Addr().String())
|
||||
server := &http.Server{Handler: adminui.Handler(secretManager), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second}
|
||||
result := make(chan error, 1)
|
||||
go func() { result <- server.Serve(listener) }()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(shutdownContext); err != nil {
|
||||
return fmt.Errorf("shut down HTTP server: %w", err)
|
||||
}
|
||||
err := <-result
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
case err := <-result:
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func fileFlags(name string, stderr io.Writer) (*flag.FlagSet, *string, *string) {
|
||||
flags := flag.NewFlagSet(name, flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
filePath := flags.String("file", "secrets.age", "encrypted secret file")
|
||||
identityPath := flags.String("identity", "treatvault-identity.txt", "age identity file")
|
||||
return flags, filePath, identityPath
|
||||
}
|
||||
|
||||
func parseNoArguments(flags *flag.FlagSet, args []string) error {
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return fmt.Errorf("unexpected arguments: %v", flags.Args())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readValue(path string, stdin io.Reader) ([]byte, error) {
|
||||
if path == "-" {
|
||||
return io.ReadAll(io.LimitReader(stdin, 500*1024+1))
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
func usage(stderr io.Writer) error {
|
||||
_, _ = fmt.Fprintln(stderr, "usage:")
|
||||
_, _ = fmt.Fprintln(stderr, " treatvault keygen [--identity FILE]")
|
||||
_, _ = fmt.Fprintln(stderr, " treatvault init [--file FILE] [--identity FILE]")
|
||||
_, _ = fmt.Fprintln(stderr, " treatvault set --name NAME [--value-file FILE|-] [--file FILE] [--identity FILE]")
|
||||
_, _ = fmt.Fprintln(stderr, " treatvault delete --name NAME [--file FILE] [--identity FILE]")
|
||||
_, _ = fmt.Fprintln(stderr, " treatvault list [--file FILE] [--identity FILE]")
|
||||
_, _ = fmt.Fprintln(stderr, " treatvault serve [--file FILE] [--identity FILE] [--listen :9090] [--docker docker]")
|
||||
return errors.New("a command is required")
|
||||
}
|
||||
86
cmd/treatvault/main_test.go
Normal file
86
cmd/treatvault/main_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/vault"
|
||||
"filippo.io/age"
|
||||
)
|
||||
|
||||
func TestCLIEndToEndWithoutDocker(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
filePath := filepath.Join(directory, "secrets.age")
|
||||
identityPath := filepath.Join(directory, "identity.txt")
|
||||
|
||||
var stdout, stderr strings.Builder
|
||||
mustRun(t, []string{"keygen", "--identity", identityPath}, nil, &stdout, &stderr)
|
||||
if !strings.Contains(stdout.String(), "Recipient: age1") {
|
||||
t.Fatalf("keygen output = %q", stdout.String())
|
||||
}
|
||||
mustRun(t, []string{"init", "--file", filePath, "--identity", identityPath}, nil, &strings.Builder{}, &strings.Builder{})
|
||||
mustRun(t, []string{"set", "--name", "database_password", "--value-file", "-", "--file", filePath, "--identity", identityPath}, strings.NewReader("correct horse battery staple"), &strings.Builder{}, &strings.Builder{})
|
||||
var list strings.Builder
|
||||
mustRun(t, []string{"list", "--file", filePath, "--identity", identityPath}, nil, &list, &strings.Builder{})
|
||||
if got := strings.TrimSpace(list.String()); got != "database_password" {
|
||||
t.Fatalf("list = %q", got)
|
||||
}
|
||||
|
||||
snapshot, err := vault.Open(filePath, identityPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := snapshot.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(loaded.Secrets["database_password"].Value) != "correct horse battery staple" {
|
||||
t.Fatalf("stored value = %q", loaded.Secrets["database_password"].Value)
|
||||
}
|
||||
|
||||
mustRun(t, []string{"delete", "--name", "database_password", "--file", filePath, "--identity", identityPath}, nil, &strings.Builder{}, &strings.Builder{})
|
||||
loaded, err = snapshot.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(loaded.Secrets) != 0 {
|
||||
t.Fatalf("secrets after delete = %#v", loaded.Secrets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRejectsEmptyValue(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
filePath := filepath.Join(directory, "secrets.age")
|
||||
identityPath := filepath.Join(directory, "identity.txt")
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(identityPath, []byte(identity.String()), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := vault.Initialize(filePath, identity); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stderr strings.Builder
|
||||
err = run(context.Background(), []string{"set", "--name", "database_password", "--value-file", "-", "--file", filePath, "--identity", identityPath}, strings.NewReader(""), &strings.Builder{}, &stderr)
|
||||
if err == nil || !strings.Contains(err.Error(), "must not be empty") {
|
||||
t.Fatalf("set empty value error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRun(t *testing.T, args []string, stdin *strings.Reader, stdout, stderr *strings.Builder) {
|
||||
t.Helper()
|
||||
var reader io.Reader = bytes.NewReader(nil)
|
||||
if stdin != nil {
|
||||
reader = stdin
|
||||
}
|
||||
if err := run(context.Background(), args, reader, stdout, stderr); err != nil {
|
||||
t.Fatalf("run(%v) error = %v", args, err)
|
||||
}
|
||||
}
|
||||
13
go.mod
Normal file
13
go.mod
Normal file
@@ -0,0 +1,13 @@
|
||||
module cloud.campbellwireless.net/git/barkstack/treatvault
|
||||
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2 v2.1.0
|
||||
filippo.io/age v1.2.1
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/crypto v0.24.0 // indirect
|
||||
golang.org/x/sys v0.21.0 // indirect
|
||||
)
|
||||
10
go.sum
Normal file
10
go.sum
Normal file
@@ -0,0 +1,10 @@
|
||||
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0=
|
||||
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w=
|
||||
cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2 v2.1.0 h1:kqEalBOysM2w2ET7ISh1LblIsvo6dT2rOk9dqaQjDMQ=
|
||||
cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2 v2.1.0/go.mod h1:um5p/9XtFqZqhh087AlPB2nr4NxhrQxw5sxe32uMbzw=
|
||||
filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o=
|
||||
filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004=
|
||||
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
3414
internal/adminui/dist/assets/entry.js
vendored
Normal file
3414
internal/adminui/dist/assets/entry.js
vendored
Normal file
File diff suppressed because one or more lines are too long
208
internal/adminui/handler.go
Normal file
208
internal/adminui/handler.go
Normal file
@@ -0,0 +1,208 @@
|
||||
// Package adminui serves TreatVault's write-only Barkstack plugin and API.
|
||||
package adminui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
barkfile "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2"
|
||||
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/manager"
|
||||
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/vault"
|
||||
)
|
||||
|
||||
//go:embed dist
|
||||
var embeddedUI embed.FS
|
||||
|
||||
const entryPath = "/barkstack/ui/assets/entry.js"
|
||||
|
||||
type SecretManager interface {
|
||||
Status() manager.Status
|
||||
Set(context.Context, string, []byte) error
|
||||
Delete(context.Context, string) error
|
||||
}
|
||||
|
||||
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 handler struct {
|
||||
assets fs.FS
|
||||
manager SecretManager
|
||||
}
|
||||
|
||||
func Handler(secretManager SecretManager) http.Handler {
|
||||
assets, err := fs.Sub(embeddedUI, "dist")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return &handler{assets: assets, manager: secretManager}
|
||||
}
|
||||
|
||||
func (h *handler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
|
||||
switch {
|
||||
case request.URL.Path == "/barkstack/ui/manifest.json":
|
||||
h.serveManifest(response, request)
|
||||
case request.URL.Path == "/barkstack/api/secrets":
|
||||
h.serveSecretList(response, request)
|
||||
case strings.HasPrefix(request.URL.Path, "/barkstack/api/secrets/"):
|
||||
h.serveSecret(response, request)
|
||||
case request.URL.Path == "/healthz":
|
||||
h.serveHealth(response, request)
|
||||
default:
|
||||
h.serveAsset(response, request)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) serveManifest(response http.ResponseWriter, request *http.Request) {
|
||||
if !allow(response, request, http.MethodGet, http.MethodHead) {
|
||||
return
|
||||
}
|
||||
h.serveJSON(response, request, http.StatusOK, manifest{
|
||||
APIVersion: "barkstack.dev/ui/v1", ID: "treatvault", Name: "TreatVault",
|
||||
Description: "Encrypted secret management for Barkstack", Icon: "key", Mount: "/treatvault",
|
||||
Entry: entryPath, Element: "barkstack-treatvault",
|
||||
Navigation: []navigationItem{{Label: "TreatVault", Path: "/treatvault", Icon: "key"}},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) serveSecretList(response http.ResponseWriter, request *http.Request) {
|
||||
if !allow(response, request, http.MethodGet, http.MethodHead) {
|
||||
return
|
||||
}
|
||||
h.serveJSON(response, request, http.StatusOK, h.manager.Status())
|
||||
}
|
||||
|
||||
func (h *handler) serveSecret(response http.ResponseWriter, request *http.Request) {
|
||||
name := strings.TrimPrefix(request.URL.Path, "/barkstack/api/secrets/")
|
||||
if !barkfile.ValidSecretReference(name) || strings.Contains(name, "/") {
|
||||
h.serveError(response, http.StatusBadRequest, "invalid secret name")
|
||||
return
|
||||
}
|
||||
switch request.Method {
|
||||
case http.MethodPut:
|
||||
var body struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
decoder := json.NewDecoder(io.LimitReader(request.Body, 512*1024+1))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&body); err != nil {
|
||||
h.serveError(response, http.StatusBadRequest, "invalid JSON request")
|
||||
return
|
||||
}
|
||||
if body.Value == "" {
|
||||
h.serveError(response, http.StatusBadRequest, "secret value must not be empty")
|
||||
return
|
||||
}
|
||||
if err := h.manager.Set(request.Context(), name, []byte(body.Value)); err != nil {
|
||||
h.serveError(response, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
case http.MethodDelete:
|
||||
if err := h.manager.Delete(request.Context(), name); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, vault.ErrSecretNotFound):
|
||||
h.serveError(response, http.StatusNotFound, "secret not found")
|
||||
case manager.IsSecretInUse(err):
|
||||
h.serveError(response, http.StatusConflict, "secret is still referenced by a managed service")
|
||||
default:
|
||||
h.serveError(response, http.StatusBadGateway, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
response.Header().Set("Allow", "PUT, DELETE")
|
||||
h.serveError(response, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) serveHealth(response http.ResponseWriter, request *http.Request) {
|
||||
if !allow(response, request, http.MethodGet, http.MethodHead) {
|
||||
return
|
||||
}
|
||||
status := h.manager.Status()
|
||||
code := http.StatusOK
|
||||
if status.State != "ready" {
|
||||
code = http.StatusServiceUnavailable
|
||||
}
|
||||
h.serveJSON(response, request, code, map[string]string{"status": status.State})
|
||||
}
|
||||
|
||||
func (h *handler) serveAsset(response http.ResponseWriter, request *http.Request) {
|
||||
if !allow(response, request, http.MethodGet, http.MethodHead) {
|
||||
return
|
||||
}
|
||||
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 {
|
||||
_, _ = response.Write(contents)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) serveJSON(response http.ResponseWriter, request *http.Request, code int, value any) {
|
||||
response.Header().Set("Content-Type", "application/json")
|
||||
response.Header().Set("Cache-Control", "no-store")
|
||||
response.WriteHeader(code)
|
||||
if request.Method != http.MethodHead {
|
||||
_ = json.NewEncoder(response).Encode(value)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) serveError(response http.ResponseWriter, code int, message string) {
|
||||
response.Header().Set("Content-Type", "application/json")
|
||||
response.Header().Set("Cache-Control", "no-store")
|
||||
response.WriteHeader(code)
|
||||
_ = json.NewEncoder(response).Encode(map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func allow(response http.ResponseWriter, request *http.Request, methods ...string) bool {
|
||||
for _, method := range methods {
|
||||
if request.Method == method {
|
||||
return true
|
||||
}
|
||||
}
|
||||
response.Header().Set("Allow", strings.Join(methods, ", "))
|
||||
http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return false
|
||||
}
|
||||
96
internal/adminui/handler_test.go
Normal file
96
internal/adminui/handler_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package adminui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/manager"
|
||||
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/provider"
|
||||
)
|
||||
|
||||
func TestManifestDescribesTreatVaultPlugin(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
Handler(&fakeManager{}).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/barkstack/ui/manifest.json", nil))
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
var got manifest
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ID != "treatvault" || got.Element != "barkstack-treatvault" || got.Entry != entryPath {
|
||||
t.Fatalf("manifest = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListNeverReturnsSecretValues(t *testing.T) {
|
||||
manager := &fakeManager{status: manager.Status{State: "ready", Secrets: []manager.Secret{{Name: "database_password", InUse: true, Synced: true}}}}
|
||||
response := httptest.NewRecorder()
|
||||
Handler(manager).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/barkstack/api/secrets", nil))
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
if strings.Contains(response.Body.String(), "secret-value") || !strings.Contains(response.Body.String(), "database_password") {
|
||||
t.Fatalf("body = %s", response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutSetsSecretAndReturnsNoValue(t *testing.T) {
|
||||
manager := &fakeManager{}
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPut, "/barkstack/api/secrets/database_password", strings.NewReader(`{"value":"secret-value"}`))
|
||||
Handler(manager).ServeHTTP(response, request)
|
||||
if response.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
if manager.setName != "database_password" || string(manager.setValue) != "secret-value" {
|
||||
t.Fatalf("set = %q %q", manager.setName, manager.setValue)
|
||||
}
|
||||
if strings.Contains(response.Body.String(), "secret-value") {
|
||||
t.Fatal("response disclosed the secret value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteReturnsConflictForInUseSecret(t *testing.T) {
|
||||
manager := &fakeManager{deleteErr: fmtInUse("database_password")}
|
||||
response := httptest.NewRecorder()
|
||||
Handler(manager).ServeHTTP(response, httptest.NewRequest(http.MethodDelete, "/barkstack/api/secrets/database_password", nil))
|
||||
if response.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidSecretNameIsRejected(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
Handler(&fakeManager{}).ServeHTTP(response, httptest.NewRequest(http.MethodPut, "/barkstack/api/secrets/UPPERCASE", strings.NewReader(`{"value":"value"}`)))
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeManager struct {
|
||||
status manager.Status
|
||||
setName string
|
||||
setValue []byte
|
||||
setErr error
|
||||
deleteErr error
|
||||
}
|
||||
|
||||
func (m *fakeManager) Status() manager.Status { return m.status }
|
||||
|
||||
func (m *fakeManager) Set(_ context.Context, name string, value []byte) error {
|
||||
m.setName = name
|
||||
m.setValue = append([]byte(nil), value...)
|
||||
return m.setErr
|
||||
}
|
||||
|
||||
func (m *fakeManager) Delete(context.Context, string) error { return m.deleteErr }
|
||||
|
||||
func fmtInUse(name string) error {
|
||||
return errors.Join(provider.ErrSecretInUse, errors.New(name))
|
||||
}
|
||||
170
internal/manager/manager.go
Normal file
170
internal/manager/manager.go
Normal file
@@ -0,0 +1,170 @@
|
||||
// Package manager coordinates encrypted file mutations and provider reconciliation.
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/provider"
|
||||
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/vault"
|
||||
)
|
||||
|
||||
type Provider interface {
|
||||
Sync(context.Context, vault.Snapshot) ([]provider.SecretState, error)
|
||||
InUse(context.Context, string) (bool, error)
|
||||
}
|
||||
|
||||
type Secret struct {
|
||||
Name string `json:"name"`
|
||||
InUse bool `json:"inUse"`
|
||||
Synced bool `json:"synced"`
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
State string `json:"state"`
|
||||
Secrets []Secret `json:"secrets"`
|
||||
LastSync time.Time `json:"lastSync,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
store *vault.Store
|
||||
provider Provider
|
||||
pollInterval time.Duration
|
||||
|
||||
operationMu sync.Mutex
|
||||
statusMu sync.RWMutex
|
||||
status Status
|
||||
}
|
||||
|
||||
func New(store *vault.Store, secretProvider Provider, pollInterval time.Duration) *Manager {
|
||||
if pollInterval <= 0 {
|
||||
pollInterval = 500 * time.Millisecond
|
||||
}
|
||||
return &Manager{
|
||||
store: store, provider: secretProvider, pollInterval: pollInterval,
|
||||
status: Status{State: "starting", Secrets: []Secret{}},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Status() Status {
|
||||
m.statusMu.RLock()
|
||||
defer m.statusMu.RUnlock()
|
||||
status := m.status
|
||||
status.Secrets = append([]Secret(nil), m.status.Secrets...)
|
||||
return status
|
||||
}
|
||||
|
||||
func (m *Manager) Sync(ctx context.Context) error {
|
||||
m.operationMu.Lock()
|
||||
defer m.operationMu.Unlock()
|
||||
snapshot, err := m.store.Load()
|
||||
if err != nil {
|
||||
m.setFailure(err)
|
||||
return err
|
||||
}
|
||||
return m.syncSnapshot(ctx, snapshot)
|
||||
}
|
||||
|
||||
func (m *Manager) Set(ctx context.Context, name string, value []byte) error {
|
||||
m.operationMu.Lock()
|
||||
defer m.operationMu.Unlock()
|
||||
snapshot, err := m.store.Set(name, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.syncSnapshot(ctx, snapshot)
|
||||
}
|
||||
|
||||
func (m *Manager) Delete(ctx context.Context, name string) error {
|
||||
m.operationMu.Lock()
|
||||
defer m.operationMu.Unlock()
|
||||
inUse, err := m.provider.InUse(ctx, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check secret consumers: %w", err)
|
||||
}
|
||||
if inUse {
|
||||
return fmt.Errorf("%w: %s", provider.ErrSecretInUse, name)
|
||||
}
|
||||
snapshot, err := m.store.Delete(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.syncSnapshot(ctx, snapshot)
|
||||
}
|
||||
|
||||
func (m *Manager) Run(ctx context.Context) {
|
||||
_ = m.Sync(ctx)
|
||||
fingerprint, _ := fileFingerprint(m.store.Path())
|
||||
ticker := time.NewTicker(m.pollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
next, err := fileFingerprint(m.store.Path())
|
||||
status := m.Status()
|
||||
if err != nil {
|
||||
m.setFailure(err)
|
||||
continue
|
||||
}
|
||||
if next == fingerprint && status.Error == "" {
|
||||
continue
|
||||
}
|
||||
fingerprint = next
|
||||
_ = m.Sync(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) syncSnapshot(ctx context.Context, snapshot vault.Snapshot) error {
|
||||
states, err := m.provider.Sync(ctx, snapshot)
|
||||
secrets := make([]Secret, 0, len(snapshot.Secrets))
|
||||
stateByName := make(map[string]provider.SecretState, len(states))
|
||||
for _, state := range states {
|
||||
stateByName[state.Name] = state
|
||||
}
|
||||
for name := range snapshot.Secrets {
|
||||
state, synced := stateByName[name]
|
||||
secrets = append(secrets, Secret{Name: name, InUse: state.InUse, Synced: synced && err == nil})
|
||||
}
|
||||
sort.Slice(secrets, func(i, j int) bool { return secrets[i].Name < secrets[j].Name })
|
||||
status := Status{State: "ready", Secrets: secrets, LastSync: time.Now().UTC()}
|
||||
if err != nil {
|
||||
status.State = "degraded"
|
||||
status.Error = err.Error()
|
||||
}
|
||||
m.statusMu.Lock()
|
||||
m.status = status
|
||||
m.statusMu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) setFailure(err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
m.statusMu.Lock()
|
||||
m.status.State = "degraded"
|
||||
m.status.Error = err.Error()
|
||||
m.statusMu.Unlock()
|
||||
}
|
||||
|
||||
func fileFingerprint(path string) ([sha256.Size]byte, error) {
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return [sha256.Size]byte{}, fmt.Errorf("read encrypted file: %w", err)
|
||||
}
|
||||
return sha256.Sum256(contents), nil
|
||||
}
|
||||
|
||||
func IsSecretInUse(err error) bool {
|
||||
return errors.Is(err, provider.ErrSecretInUse)
|
||||
}
|
||||
99
internal/manager/manager_test.go
Normal file
99
internal/manager/manager_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/provider"
|
||||
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/vault"
|
||||
"filippo.io/age"
|
||||
)
|
||||
|
||||
func TestRunSynchronizesExternalEncryptedFileChanges(t *testing.T) {
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := t.TempDir() + "/secrets.age"
|
||||
if err := vault.Initialize(path, identity); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := vault.New(path, identity)
|
||||
provider := &recordingProvider{synced: make(chan vault.Snapshot, 4)}
|
||||
manager := New(store, provider, 10*time.Millisecond)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go manager.Run(ctx)
|
||||
|
||||
initial := waitSnapshot(t, provider.synced)
|
||||
if len(initial.Secrets) != 0 {
|
||||
t.Fatalf("initial secrets = %#v", initial.Secrets)
|
||||
}
|
||||
if _, err := store.Set("database_password", []byte("changed outside manager")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changed := waitSnapshot(t, provider.synced)
|
||||
if string(changed.Secrets["database_password"].Value) != "changed outside manager" {
|
||||
t.Fatalf("changed snapshot = %#v", changed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRefusesSecretInUse(t *testing.T) {
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := t.TempDir() + "/secrets.age"
|
||||
if err := vault.Initialize(path, identity); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := vault.New(path, identity)
|
||||
if _, err := store.Set("database_password", []byte("value")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager := New(store, &recordingProvider{inUse: true}, time.Second)
|
||||
if err := manager.Delete(context.Background(), "database_password"); !IsSecretInUse(err) {
|
||||
t.Fatalf("Delete() error = %v", err)
|
||||
}
|
||||
loaded, err := store.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := loaded.Secrets["database_password"]; !exists {
|
||||
t.Fatal("in-use secret was removed from encrypted file")
|
||||
}
|
||||
}
|
||||
|
||||
type recordingProvider struct {
|
||||
mu sync.Mutex
|
||||
synced chan vault.Snapshot
|
||||
inUse bool
|
||||
}
|
||||
|
||||
func (p *recordingProvider) Sync(_ context.Context, snapshot vault.Snapshot) ([]provider.SecretState, error) {
|
||||
if p.synced != nil {
|
||||
p.synced <- snapshot
|
||||
}
|
||||
states := make([]provider.SecretState, 0, len(snapshot.Secrets))
|
||||
for name, record := range snapshot.Secrets {
|
||||
states = append(states, provider.SecretState{Name: name, Revision: record.Revision, InUse: p.inUse})
|
||||
}
|
||||
return states, nil
|
||||
}
|
||||
|
||||
func (p *recordingProvider) InUse(context.Context, string) (bool, error) {
|
||||
return p.inUse, nil
|
||||
}
|
||||
|
||||
func waitSnapshot(t *testing.T, snapshots <-chan vault.Snapshot) vault.Snapshot {
|
||||
t.Helper()
|
||||
select {
|
||||
case snapshot := <-snapshots:
|
||||
return snapshot
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for synchronization")
|
||||
return vault.Snapshot{}
|
||||
}
|
||||
}
|
||||
312
internal/provider/docker.go
Normal file
312
internal/provider/docker.go
Normal file
@@ -0,0 +1,312 @@
|
||||
// Package provider reconciles TreatVault snapshots with platform secret providers.
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
barkfile "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2"
|
||||
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/vault"
|
||||
)
|
||||
|
||||
var ErrSecretInUse = errors.New("secret is in use")
|
||||
|
||||
type Runner interface {
|
||||
Run(context.Context, []byte, ...string) (string, error)
|
||||
}
|
||||
|
||||
type ExecRunner struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
func (r ExecRunner) Run(ctx context.Context, input []byte, args ...string) (string, error) {
|
||||
path := r.Path
|
||||
if path == "" {
|
||||
path = "docker"
|
||||
}
|
||||
command := exec.CommandContext(ctx, path, args...)
|
||||
if input != nil {
|
||||
command.Stdin = strings.NewReader(string(input))
|
||||
}
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
return string(output), fmt.Errorf("docker %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
type SecretState struct {
|
||||
Name string
|
||||
Revision string
|
||||
PhysicalName string
|
||||
InUse bool
|
||||
}
|
||||
|
||||
type Docker struct {
|
||||
Runner Runner
|
||||
}
|
||||
|
||||
// Sync reconciles snapshot with Docker Swarm: it creates one immutable secret
|
||||
// object per record revision, rotates mounts on labeled consumer services, and
|
||||
// removes obsolete objects. Names listed in a service's
|
||||
// io.barkstack.treatvault.names label are mounted at barkstack_<name> targets.
|
||||
func (d Docker) Sync(ctx context.Context, snapshot vault.Snapshot) ([]SecretState, error) {
|
||||
if d.Runner == nil {
|
||||
return nil, errors.New("Docker runner is required")
|
||||
}
|
||||
managed, err := d.managedSecrets(ctx, snapshot.VaultID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for name, record := range snapshot.Secrets {
|
||||
physical := physicalName(snapshot.VaultID, record.Revision)
|
||||
if containsSecret(managed, physical) {
|
||||
continue
|
||||
}
|
||||
args := []string{
|
||||
"secret", "create",
|
||||
"--label", barkfile.TreatVaultManagedLabel + "=true",
|
||||
"--label", barkfile.TreatVaultSecretNameLabel + "=" + name,
|
||||
"--label", barkfile.TreatVaultVaultIDLabel + "=" + snapshot.VaultID,
|
||||
"--label", barkfile.TreatVaultRevisionLabel + "=" + record.Revision,
|
||||
physical, "-",
|
||||
}
|
||||
if _, err := d.Runner.Run(ctx, record.Value, args...); err != nil {
|
||||
return nil, fmt.Errorf("create Docker secret for %q: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
services, err := d.managedServices(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inUse := make(map[string]bool)
|
||||
missing := make(map[string]struct{})
|
||||
desiredTargets := make(map[string]struct{}, len(snapshot.Secrets))
|
||||
for name := range snapshot.Secrets {
|
||||
desiredTargets[barkfile.DockerSecretName(name)] = struct{}{}
|
||||
}
|
||||
for _, service := range services {
|
||||
args := []string{"service", "update"}
|
||||
changed := false
|
||||
mountedByTarget := make(map[string]serviceSecret, len(service.Secrets))
|
||||
for _, mounted := range service.Secrets {
|
||||
mountedByTarget[mounted.Target] = mounted
|
||||
if _, managedTarget := desiredTargets[mounted.Target]; managedTarget {
|
||||
inUse[strings.TrimPrefix(mounted.Target, barkfile.DockerSecretPrefix)] = true
|
||||
}
|
||||
}
|
||||
for _, name := range service.DesiredNames {
|
||||
record, stored := snapshot.Secrets[name]
|
||||
if !stored {
|
||||
missing[name] = struct{}{}
|
||||
continue
|
||||
}
|
||||
inUse[name] = true
|
||||
desiredPhysical := physicalName(snapshot.VaultID, record.Revision)
|
||||
target := barkfile.DockerSecretName(name)
|
||||
mounted, isMounted := mountedByTarget[target]
|
||||
if isMounted && mounted.Source == desiredPhysical {
|
||||
continue
|
||||
}
|
||||
if isMounted {
|
||||
args = append(args, "--secret-rm", mounted.Source)
|
||||
}
|
||||
args = append(args, "--secret-add", secretMount(desiredPhysical, target))
|
||||
changed = true
|
||||
}
|
||||
for _, mounted := range service.Secrets {
|
||||
logical, managedTarget := strings.CutPrefix(mounted.Target, barkfile.DockerSecretPrefix)
|
||||
if !managedTarget {
|
||||
continue
|
||||
}
|
||||
if _, desired := desiredTargets[mounted.Target]; desired {
|
||||
continue
|
||||
}
|
||||
// Mounted under a Barkstack target but absent from the vault:
|
||||
// keep the mount so consumers keep working, surface the gap.
|
||||
missing[logical] = struct{}{}
|
||||
}
|
||||
if changed {
|
||||
args = append(args, service.Name)
|
||||
if _, err := d.Runner.Run(ctx, nil, args...); err != nil {
|
||||
return nil, fmt.Errorf("rotate secrets on service %q: %w", service.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
desiredPhysical := make(map[string]struct{}, len(snapshot.Secrets))
|
||||
for _, record := range snapshot.Secrets {
|
||||
desiredPhysical[physicalName(snapshot.VaultID, record.Revision)] = struct{}{}
|
||||
}
|
||||
for _, secret := range managed {
|
||||
if _, desired := desiredPhysical[secret.Name]; desired {
|
||||
continue
|
||||
}
|
||||
if _, err := d.Runner.Run(ctx, nil, "secret", "rm", secret.Name); err != nil && !isInUseError(err) {
|
||||
return nil, fmt.Errorf("remove obsolete Docker secret %q: %w", secret.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
states := make([]SecretState, 0, len(snapshot.Secrets))
|
||||
for name, record := range snapshot.Secrets {
|
||||
states = append(states, SecretState{
|
||||
Name: name, Revision: record.Revision,
|
||||
PhysicalName: physicalName(snapshot.VaultID, record.Revision), InUse: inUse[name],
|
||||
})
|
||||
}
|
||||
sort.Slice(states, func(i, j int) bool { return states[i].Name < states[j].Name })
|
||||
if len(missing) != 0 {
|
||||
names := make([]string, 0, len(missing))
|
||||
for name := range missing {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return states, fmt.Errorf("%w: %s", ErrSecretInUse, strings.Join(names, ", "))
|
||||
}
|
||||
return states, nil
|
||||
}
|
||||
|
||||
// InUse reports whether any managed service currently mounts the secret at its
|
||||
// barkstack_<name> target.
|
||||
func (d Docker) InUse(ctx context.Context, name string) (bool, error) {
|
||||
services, err := d.managedServices(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
target := barkfile.DockerSecretName(name)
|
||||
for _, service := range services {
|
||||
for _, secret := range service.Secrets {
|
||||
if secret.Target == target {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
type dockerSecret struct {
|
||||
Name string
|
||||
LogicalName string
|
||||
Revision string
|
||||
VaultID string
|
||||
}
|
||||
|
||||
func containsSecret(secrets []dockerSecret, name string) bool {
|
||||
for _, secret := range secrets {
|
||||
if secret.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (d Docker) managedSecrets(ctx context.Context, vaultID string) ([]dockerSecret, error) {
|
||||
output, err := d.Runner.Run(ctx, nil,
|
||||
"secret", "ls", "--quiet",
|
||||
"--filter", "label="+barkfile.TreatVaultManagedLabel+"=true",
|
||||
"--filter", "label="+barkfile.TreatVaultVaultIDLabel+"="+vaultID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list managed Docker secrets: %w", err)
|
||||
}
|
||||
ids := strings.Fields(output)
|
||||
secrets := make([]dockerSecret, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
output, err := d.Runner.Run(ctx, nil, "secret", "inspect", "--format", "{{json .Spec}}", id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect Docker secret %q: %w", id, err)
|
||||
}
|
||||
var spec struct {
|
||||
Name string `json:"Name"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(output), &spec); err != nil {
|
||||
return nil, fmt.Errorf("decode Docker secret %q: %w", id, err)
|
||||
}
|
||||
if spec.Labels[barkfile.TreatVaultManagedLabel] != "true" || spec.Labels[barkfile.TreatVaultVaultIDLabel] != vaultID {
|
||||
continue
|
||||
}
|
||||
secrets = append(secrets, dockerSecret{
|
||||
Name: spec.Name, LogicalName: spec.Labels[barkfile.TreatVaultSecretNameLabel],
|
||||
Revision: spec.Labels[barkfile.TreatVaultRevisionLabel], VaultID: vaultID,
|
||||
})
|
||||
}
|
||||
return secrets, nil
|
||||
}
|
||||
|
||||
type serviceSecret struct {
|
||||
Source string
|
||||
Target string
|
||||
}
|
||||
|
||||
type dockerService struct {
|
||||
Name string
|
||||
DesiredNames []string
|
||||
Secrets []serviceSecret
|
||||
}
|
||||
|
||||
func (d Docker) managedServices(ctx context.Context) ([]dockerService, error) {
|
||||
output, err := d.Runner.Run(ctx, nil,
|
||||
"service", "ls", "--quiet", "--filter", "label="+barkfile.TreatVaultConsumerLabel+"=true",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list TreatVault consumer services: %w", err)
|
||||
}
|
||||
ids := strings.Fields(output)
|
||||
services := make([]dockerService, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
output, err := d.Runner.Run(ctx, nil, "service", "inspect", "--format", "{{json .Spec}}", id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect Docker service %q: %w", id, err)
|
||||
}
|
||||
var spec struct {
|
||||
Name string `json:"Name"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
TaskTemplate struct {
|
||||
ContainerSpec struct {
|
||||
Secrets []struct {
|
||||
SecretName string `json:"SecretName"`
|
||||
File *struct {
|
||||
Name string `json:"Name"`
|
||||
} `json:"File"`
|
||||
} `json:"Secrets"`
|
||||
} `json:"ContainerSpec"`
|
||||
} `json:"TaskTemplate"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(output), &spec); err != nil {
|
||||
return nil, fmt.Errorf("decode Docker service %q: %w", id, err)
|
||||
}
|
||||
names := spec.Labels[barkfile.TreatVaultNamesLabel]
|
||||
var desiredNames []string
|
||||
if names != "" {
|
||||
desiredNames = strings.Split(names, ",")
|
||||
}
|
||||
service := dockerService{Name: spec.Name, DesiredNames: desiredNames}
|
||||
for _, secret := range spec.TaskTemplate.ContainerSpec.Secrets {
|
||||
if secret.File != nil {
|
||||
service.Secrets = append(service.Secrets, serviceSecret{Source: secret.SecretName, Target: secret.File.Name})
|
||||
}
|
||||
}
|
||||
services = append(services, service)
|
||||
}
|
||||
return services, nil
|
||||
}
|
||||
|
||||
func physicalName(vaultID, revision string) string {
|
||||
return barkfile.DockerSecretPrefix + "tv_" + vaultID + "_" + revision
|
||||
}
|
||||
|
||||
func secretMount(source, target string) string {
|
||||
return "source=" + source + ",target=" + target
|
||||
}
|
||||
|
||||
func isInUseError(err error) bool {
|
||||
message := strings.ToLower(err.Error())
|
||||
return strings.Contains(message, "in use") || strings.Contains(message, "used by")
|
||||
}
|
||||
184
internal/provider/docker_test.go
Normal file
184
internal/provider/docker_test.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/vault"
|
||||
)
|
||||
|
||||
const (
|
||||
testVaultID = "0011223344556677"
|
||||
testRevision = "00112233445566778899aabbccddeeff"
|
||||
)
|
||||
|
||||
func serviceSpecJSON(names string, secrets string) string {
|
||||
return `{"Name":"barkstack-pawsql","Labels":{"io.barkstack.treatvault.secrets":"true","io.barkstack.treatvault.names":"` + names + `"},"TaskTemplate":{"ContainerSpec":{"Secrets":[` + secrets + `]}}}`
|
||||
}
|
||||
|
||||
func TestSyncCreatesVersionedDockerSecret(t *testing.T) {
|
||||
runner := &fakeRunner{responses: []fakeResponse{{}, {}, {}}}
|
||||
provider := Docker{Runner: runner}
|
||||
snapshot := vault.Snapshot{
|
||||
Version: 1, VaultID: testVaultID,
|
||||
Secrets: map[string]vault.Record{"database_password": {Revision: testRevision, Value: []byte("super-secret")}},
|
||||
}
|
||||
states, err := provider.Sync(context.Background(), snapshot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const physical = "barkstack_tv_" + testVaultID + "_" + testRevision
|
||||
if len(states) != 1 || states[0].PhysicalName != physical {
|
||||
t.Fatalf("states = %#v", states)
|
||||
}
|
||||
create := runner.callContaining(t, "secret create")
|
||||
for _, want := range []string{
|
||||
"--label io.barkstack.treatvault=true",
|
||||
"--label io.barkstack.treatvault.name=database_password",
|
||||
physical + " -",
|
||||
} {
|
||||
if !strings.Contains(create.args, want) {
|
||||
t.Errorf("create args = %q, missing %q", create.args, want)
|
||||
}
|
||||
}
|
||||
if string(create.input) != "super-secret" || strings.Contains(create.args, "super-secret") {
|
||||
t.Fatalf("secret was not passed exclusively on stdin: %#v", create)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncRotatesMountedSecretWithoutChangingTarget(t *testing.T) {
|
||||
const (
|
||||
oldRevision = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
oldPhysical = "barkstack_tv_" + testVaultID + "_" + oldRevision
|
||||
newPhysical = "barkstack_tv_" + testVaultID + "_" + testRevision
|
||||
)
|
||||
runner := &fakeRunner{responses: []fakeResponse{
|
||||
{output: "old-id\n"}, // secret ls
|
||||
{output: secretSpecJSON("database_password", oldRevision, oldPhysical, testVaultID)}, // secret inspect
|
||||
{}, // secret create (new revision)
|
||||
{output: "service-id\n"}, // service ls
|
||||
{output: serviceSpecJSON("database_password", mountJSON(oldPhysical, "barkstack_database_password"))}, // service inspect
|
||||
{}, // service update
|
||||
{err: errors.New("secret is in use by old task")}, // secret rm
|
||||
}}
|
||||
provider := Docker{Runner: runner}
|
||||
states, err := provider.Sync(context.Background(), vault.Snapshot{
|
||||
Version: 1, VaultID: testVaultID,
|
||||
Secrets: map[string]vault.Record{"database_password": {Revision: testRevision, Value: []byte("rotated")}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(states) != 1 || !states[0].InUse {
|
||||
t.Fatalf("states = %#v", states)
|
||||
}
|
||||
update := runner.callContaining(t, "service update")
|
||||
for _, want := range []string{
|
||||
"--secret-rm " + oldPhysical,
|
||||
"--secret-add source=" + newPhysical + ",target=barkstack_database_password",
|
||||
"barkstack-pawsql",
|
||||
} {
|
||||
if !strings.Contains(update.args, want) {
|
||||
t.Errorf("update args = %q, missing %q", update.args, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncMountsMissingDesiredSecretOnConsumer(t *testing.T) {
|
||||
const physical = "barkstack_tv_" + testVaultID + "_" + testRevision
|
||||
runner := &fakeRunner{responses: []fakeResponse{
|
||||
{}, // secret ls (empty vault listing)
|
||||
{}, // secret create
|
||||
{output: "service-id\n"},
|
||||
{output: serviceSpecJSON("database_password", "")},
|
||||
{}, // service update
|
||||
}}
|
||||
provider := Docker{Runner: runner}
|
||||
states, err := provider.Sync(context.Background(), vault.Snapshot{
|
||||
Version: 1, VaultID: testVaultID,
|
||||
Secrets: map[string]vault.Record{"database_password": {Revision: testRevision, Value: []byte("value")}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
update := runner.callContaining(t, "service update")
|
||||
if want := "--secret-add source=" + physical + ",target=barkstack_database_password"; !strings.Contains(update.args, want) {
|
||||
t.Errorf("update args = %q, missing %q", update.args, want)
|
||||
}
|
||||
if !states[0].InUse {
|
||||
t.Fatalf("states = %#v, want InUse", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncReportsConfiguredSecretMissingFromVault(t *testing.T) {
|
||||
runner := &fakeRunner{responses: []fakeResponse{
|
||||
{}, // secret ls (empty)
|
||||
{output: "service-id\n"},
|
||||
{output: serviceSpecJSON("database_password", "")},
|
||||
}}
|
||||
_, err := (Docker{Runner: runner}).Sync(context.Background(), vault.Snapshot{Version: 1, VaultID: testVaultID, Secrets: map[string]vault.Record{}})
|
||||
if !errors.Is(err, ErrSecretInUse) {
|
||||
t.Fatalf("Sync() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncKeepsOrphanedMountAndReportsGap(t *testing.T) {
|
||||
const oldPhysical = "barkstack_tv_" + testVaultID + "_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
runner := &fakeRunner{responses: []fakeResponse{
|
||||
{output: "old-id\n"},
|
||||
{output: secretSpecJSON("removed_secret", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", oldPhysical, testVaultID)},
|
||||
{output: "service-id\n"},
|
||||
{output: serviceSpecJSON("database_password", mountJSON(oldPhysical, "barkstack_database_password"))},
|
||||
{err: errors.New("secret is in use")},
|
||||
}}
|
||||
_, err := (Docker{Runner: runner}).Sync(context.Background(), vault.Snapshot{Version: 1, VaultID: testVaultID, Secrets: map[string]vault.Record{}})
|
||||
if !errors.Is(err, ErrSecretInUse) {
|
||||
t.Fatalf("Sync() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func secretSpecJSON(name, revision, physical, vaultID string) string {
|
||||
return `{"Name":"` + physical + `","Labels":{"io.barkstack.treatvault":"true","io.barkstack.treatvault.name":"` + name + `","io.barkstack.treatvault.vault":"` + vaultID + `","io.barkstack.treatvault.revision":"` + revision + `"}}`
|
||||
}
|
||||
|
||||
func mountJSON(source, target string) string {
|
||||
return `{"SecretName":"` + source + `","File":{"Name":"` + target + `"}}`
|
||||
}
|
||||
|
||||
type fakeResponse struct {
|
||||
output string
|
||||
err error
|
||||
}
|
||||
|
||||
type fakeCall struct {
|
||||
args string
|
||||
input []byte
|
||||
}
|
||||
|
||||
type fakeRunner struct {
|
||||
responses []fakeResponse
|
||||
calls []fakeCall
|
||||
}
|
||||
|
||||
func (r *fakeRunner) Run(_ context.Context, input []byte, args ...string) (string, error) {
|
||||
r.calls = append(r.calls, fakeCall{args: strings.Join(args, " "), input: append([]byte(nil), input...)})
|
||||
if len(r.responses) == 0 {
|
||||
return "", errors.New("unexpected Docker command")
|
||||
}
|
||||
response := r.responses[0]
|
||||
r.responses = r.responses[1:]
|
||||
return response.output, response.err
|
||||
}
|
||||
|
||||
func (r *fakeRunner) callContaining(t *testing.T, prefix string) fakeCall {
|
||||
t.Helper()
|
||||
for _, call := range r.calls {
|
||||
if strings.HasPrefix(call.args, prefix) {
|
||||
return call
|
||||
}
|
||||
}
|
||||
t.Fatalf("calls = %#v, missing %q", r.calls, prefix)
|
||||
return fakeCall{}
|
||||
}
|
||||
292
internal/vault/store.go
Normal file
292
internal/vault/store.go
Normal file
@@ -0,0 +1,292 @@
|
||||
// Package vault owns TreatVault's encrypted source-of-truth file.
|
||||
package vault
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
barkfile "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2"
|
||||
"filippo.io/age"
|
||||
)
|
||||
|
||||
const (
|
||||
documentVersion = 1
|
||||
maxSecretBytes = 500 * 1024
|
||||
maxDocumentBytes = 16 * 1024 * 1024
|
||||
)
|
||||
|
||||
var ErrSecretNotFound = errors.New("secret not found")
|
||||
|
||||
type Record struct {
|
||||
Revision string `json:"revision"`
|
||||
Value []byte `json:"value"`
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Version int `json:"version"`
|
||||
VaultID string `json:"vaultId"`
|
||||
Secrets map[string]Record `json:"secrets"`
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
Name string
|
||||
Revision string
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
path string
|
||||
identity *age.X25519Identity
|
||||
recipient age.Recipient
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func Open(path, identityPath string) (*Store, error) {
|
||||
identity, err := ReadIdentity(identityPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store := &Store{path: path, identity: identity, recipient: identity.Recipient()}
|
||||
if _, err := store.Load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func New(path string, identity *age.X25519Identity) *Store {
|
||||
return &Store{path: path, identity: identity, recipient: identity.Recipient()}
|
||||
}
|
||||
|
||||
func ReadIdentity(path string) (*age.X25519Identity, error) {
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read age identity: %w", err)
|
||||
}
|
||||
identity, err := age.ParseX25519Identity(strings.TrimSpace(string(contents)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse age identity: %w", err)
|
||||
}
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func GenerateIdentity(path string) (string, error) {
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate age identity: %w", err)
|
||||
}
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return "", fmt.Errorf("identity file %q already exists", path)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
if err := writeFileAtomic(path, []byte(identity.String()+"\n"), 0o600); err != nil {
|
||||
return "", fmt.Errorf("write age identity: %w", err)
|
||||
}
|
||||
return identity.Recipient().String(), nil
|
||||
}
|
||||
|
||||
func Initialize(path string, identity *age.X25519Identity) error {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return fmt.Errorf("encrypted file %q already exists", path)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
store := New(path, identity)
|
||||
vaultID, err := randomHex(8)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return store.save(Snapshot{Version: documentVersion, VaultID: vaultID, Secrets: map[string]Record{}})
|
||||
}
|
||||
|
||||
func (s *Store) Path() string { return s.path }
|
||||
|
||||
func (s *Store) Load() (Snapshot, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.load()
|
||||
}
|
||||
|
||||
func (s *Store) Set(name string, value []byte) (Snapshot, error) {
|
||||
if !barkfile.ValidSecretReference(name) {
|
||||
return Snapshot{}, fmt.Errorf("invalid secret name %q", name)
|
||||
}
|
||||
if len(value) == 0 {
|
||||
return Snapshot{}, errors.New("secret value must not be empty")
|
||||
}
|
||||
if len(value) > maxSecretBytes {
|
||||
return Snapshot{}, fmt.Errorf("secret value exceeds Docker's %d-byte limit", maxSecretBytes)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
snapshot, err := s.load()
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if existing, ok := snapshot.Secrets[name]; ok && bytes.Equal(existing.Value, value) {
|
||||
return snapshot, nil
|
||||
}
|
||||
revision, err := randomHex(16)
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
snapshot.Secrets[name] = Record{Revision: revision, Value: append([]byte(nil), value...)}
|
||||
if err := s.save(snapshot); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func (s *Store) Delete(name string) (Snapshot, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
snapshot, err := s.load()
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if _, ok := snapshot.Secrets[name]; !ok {
|
||||
return Snapshot{}, ErrSecretNotFound
|
||||
}
|
||||
delete(snapshot.Secrets, name)
|
||||
if err := s.save(snapshot); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func (s *Store) load() (Snapshot, error) {
|
||||
encrypted, err := os.Open(s.path)
|
||||
if err != nil {
|
||||
return Snapshot{}, fmt.Errorf("open encrypted file: %w", err)
|
||||
}
|
||||
defer encrypted.Close()
|
||||
plaintext, err := age.Decrypt(encrypted, s.identity)
|
||||
if err != nil {
|
||||
return Snapshot{}, fmt.Errorf("decrypt encrypted file: %w", err)
|
||||
}
|
||||
decoder := json.NewDecoder(io.LimitReader(plaintext, maxDocumentBytes+1))
|
||||
decoder.DisallowUnknownFields()
|
||||
var snapshot Snapshot
|
||||
if err := decoder.Decode(&snapshot); err != nil {
|
||||
return Snapshot{}, fmt.Errorf("decode encrypted file: %w", err)
|
||||
}
|
||||
if err := validateSnapshot(snapshot); err != nil {
|
||||
return Snapshot{}, fmt.Errorf("validate encrypted file: %w", err)
|
||||
}
|
||||
return cloneSnapshot(snapshot), nil
|
||||
}
|
||||
|
||||
func (s *Store) save(snapshot Snapshot) error {
|
||||
if err := validateSnapshot(snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
var encrypted bytes.Buffer
|
||||
writer, err := age.Encrypt(&encrypted, s.recipient)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize encryption: %w", err)
|
||||
}
|
||||
encoder := json.NewEncoder(writer)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(snapshot); err != nil {
|
||||
return fmt.Errorf("encode encrypted file: %w", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return fmt.Errorf("finish encryption: %w", err)
|
||||
}
|
||||
if err := writeFileAtomic(s.path, encrypted.Bytes(), 0o600); err != nil {
|
||||
return fmt.Errorf("replace encrypted file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSnapshot(snapshot Snapshot) error {
|
||||
if snapshot.Version != documentVersion {
|
||||
return fmt.Errorf("unsupported document version %d", snapshot.Version)
|
||||
}
|
||||
if len(snapshot.VaultID) != 16 {
|
||||
return errors.New("vault ID must be 16 hexadecimal characters")
|
||||
}
|
||||
if _, err := hex.DecodeString(snapshot.VaultID); err != nil {
|
||||
return errors.New("vault ID must be hexadecimal")
|
||||
}
|
||||
if snapshot.Secrets == nil {
|
||||
return errors.New("secrets map is required")
|
||||
}
|
||||
for name, record := range snapshot.Secrets {
|
||||
if !barkfile.ValidSecretReference(name) {
|
||||
return fmt.Errorf("invalid secret name %q", name)
|
||||
}
|
||||
if len(record.Value) == 0 || len(record.Value) > maxSecretBytes {
|
||||
return fmt.Errorf("secret %q has invalid value length", name)
|
||||
}
|
||||
if len(record.Revision) != 32 {
|
||||
return fmt.Errorf("secret %q has invalid revision", name)
|
||||
}
|
||||
if _, err := hex.DecodeString(record.Revision); err != nil {
|
||||
return fmt.Errorf("secret %q has invalid revision", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneSnapshot(snapshot Snapshot) Snapshot {
|
||||
clone := Snapshot{Version: snapshot.Version, VaultID: snapshot.VaultID, Secrets: make(map[string]Record, len(snapshot.Secrets))}
|
||||
for name, record := range snapshot.Secrets {
|
||||
record.Value = append([]byte(nil), record.Value...)
|
||||
clone.Secrets[name] = record
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func randomHex(bytesCount int) (string, error) {
|
||||
value := make([]byte, bytesCount)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
return "", fmt.Errorf("generate random identifier: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(value), nil
|
||||
}
|
||||
|
||||
func writeFileAtomic(path string, contents []byte, mode os.FileMode) error {
|
||||
directory := filepath.Dir(path)
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
temporary, err := os.CreateTemp(directory, ".treatvault-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
defer os.Remove(temporaryPath)
|
||||
if err := temporary.Chmod(mode); err != nil {
|
||||
temporary.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := temporary.Write(contents); err != nil {
|
||||
temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(temporaryPath, path); err != nil {
|
||||
return err
|
||||
}
|
||||
directoryHandle, err := os.Open(directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer directoryHandle.Close()
|
||||
return directoryHandle.Sync()
|
||||
}
|
||||
108
internal/vault/store_test.go
Normal file
108
internal/vault/store_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
)
|
||||
|
||||
func TestStoreEncryptsUpdatesAndDeletesSecrets(t *testing.T) {
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "vault", "secrets.age")
|
||||
if err := Initialize(path, identity); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := New(path, identity)
|
||||
first, err := store.Set("database_password", []byte("correct horse battery staple"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record := first.Secrets["database_password"]
|
||||
if record.Revision == "" || string(record.Value) != "correct horse battery staple" {
|
||||
t.Fatalf("record = %#v", record)
|
||||
}
|
||||
encrypted, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(encrypted, []byte("correct horse battery staple")) || bytes.Contains(encrypted, []byte("database_password")) {
|
||||
t.Fatal("encrypted file contains plaintext secret material")
|
||||
}
|
||||
|
||||
unchanged, err := store.Set("database_password", []byte("correct horse battery staple"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if unchanged.Secrets["database_password"].Revision != record.Revision {
|
||||
t.Fatal("setting the same value changed its revision")
|
||||
}
|
||||
rotated, err := store.Set("database_password", []byte("new value"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rotated.Secrets["database_password"].Revision == record.Revision {
|
||||
t.Fatal("rotating a value did not change its revision")
|
||||
}
|
||||
if _, err := store.Delete("database_password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := store.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(loaded.Secrets) != 0 {
|
||||
t.Fatalf("secrets = %#v", loaded.Secrets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRejectsWrongIdentityAndInvalidValues(t *testing.T) {
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "secrets.age")
|
||||
if err := Initialize(path, identity); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := New(path, identity)
|
||||
if _, err := store.Set("../unsafe", []byte("value")); err == nil {
|
||||
t.Fatal("Set() accepted unsafe name")
|
||||
}
|
||||
if _, err := store.Set("empty", nil); err == nil {
|
||||
t.Fatal("Set() accepted empty value")
|
||||
}
|
||||
wrongIdentity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := New(path, wrongIdentity).Load(); err == nil {
|
||||
t.Fatal("Load() succeeded with wrong identity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateIdentityDoesNotOverwrite(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "identity.txt")
|
||||
recipient, err := GenerateIdentity(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recipient == "" {
|
||||
t.Fatal("recipient is empty")
|
||||
}
|
||||
if _, err := GenerateIdentity(path); err == nil {
|
||||
t.Fatal("GenerateIdentity() overwrote existing identity")
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("identity permissions = %o", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
2
ui/.gitignore
vendored
Normal file
2
ui/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
350
ui/bun.lock
Normal file
350
ui/bun.lock
Normal file
@@ -0,0 +1,350 @@
|
||||
{
|
||||
"lockfileVersion": 2,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@barkstack/treatvault-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/treatvault-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"
|
||||
}
|
||||
}
|
||||
226
ui/src/TreatVault.svelte
Normal file
226
ui/src/TreatVault.svelte
Normal file
@@ -0,0 +1,226 @@
|
||||
<svelte:options customElement={{ tag: 'barkstack-treatvault', shadow: 'open', props: { route: { type: 'String', reflect: true } } }} />
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { dispatchConfirm, dispatchNavigate, dispatchNotify } from './events';
|
||||
|
||||
type Secret = { name: string; inUse: boolean; synced: boolean };
|
||||
type Status = { state: string; secrets: Secret[]; lastSync?: string; error?: string };
|
||||
|
||||
let { route = '/' }: { route?: string } = $props();
|
||||
let root: HTMLElement;
|
||||
let status = $state<Status | undefined>();
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let newName = $state('');
|
||||
let newValue = $state('');
|
||||
let rotateTarget = $state('');
|
||||
let rotateValue = $state('');
|
||||
let busy = $state(false);
|
||||
|
||||
const NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
|
||||
const API = '/plugins/treatvault/api/secrets';
|
||||
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const response = await fetch(API, { headers: { Accept: 'application/json' }, cache: 'no-store' });
|
||||
if (!response.ok) throw new Error(`API returned ${response.status}`);
|
||||
status = await response.json() as Status;
|
||||
error = '';
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'TreatVault is unavailable';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function navigate(path: string) { dispatchNavigate(root, path); }
|
||||
|
||||
function validName(name: string): boolean {
|
||||
return NAME_PATTERN.test(name) && name.length <= 54;
|
||||
}
|
||||
|
||||
function notify(level: 'success' | 'info' | 'warning' | 'error', message: string) {
|
||||
dispatchNotify(root, level, message);
|
||||
}
|
||||
|
||||
async function createSecret() {
|
||||
if (!validName(newName)) { notify('error', 'Name must be lowercase letters, digits, or underscores'); return; }
|
||||
if (!newValue) { notify('error', 'Enter a secret value'); return; }
|
||||
await submit(async () => {
|
||||
const response = await fetch(`${API}/${newName}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newValue }) });
|
||||
if (!response.ok) throw new Error(await errorMessage(response));
|
||||
notify('success', `Secret ${newName} stored and synced`);
|
||||
newName = ''; newValue = '';
|
||||
});
|
||||
}
|
||||
|
||||
async function rotateSecret(name: string) {
|
||||
if (!rotateValue) { notify('error', 'Enter a new value'); return; }
|
||||
await submit(async () => {
|
||||
const response = await fetch(`${API}/${name}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: rotateValue }) });
|
||||
if (!response.ok) throw new Error(await errorMessage(response));
|
||||
notify('success', `Secret ${name} rotated`);
|
||||
rotateTarget = ''; rotateValue = '';
|
||||
});
|
||||
}
|
||||
|
||||
function confirmDelete(name: string) {
|
||||
dispatchConfirm(root, `Delete secret ${name}? Services still mounting it will keep their last mounted copy until restarted.`, async (confirmed) => {
|
||||
if (!confirmed) return;
|
||||
await submit(async () => {
|
||||
const response = await fetch(`${API}/${name}`, { method: 'DELETE' });
|
||||
if (!response.ok) throw new Error(await errorMessage(response));
|
||||
notify('success', `Secret ${name} deleted`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function submit(action: () => Promise<void>) {
|
||||
busy = true;
|
||||
try {
|
||||
await action();
|
||||
await loadStatus();
|
||||
} catch (cause) {
|
||||
notify('error', cause instanceof Error ? cause.message : 'Operation failed');
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function errorMessage(response: Response): Promise<string> {
|
||||
try {
|
||||
const body = await response.json() as { error?: string };
|
||||
if (body.error) return body.error;
|
||||
} catch { /* fall through */ }
|
||||
return `Request returned ${response.status}`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const timer = setInterval(loadStatus, 5000);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
|
||||
onMount(loadStatus);
|
||||
</script>
|
||||
|
||||
<section bind:this={root}>
|
||||
<div class="intro">
|
||||
<div>
|
||||
<span class="eyebrow">Secret management</span>
|
||||
<h2>TreatVault</h2>
|
||||
<p>Namespaced secrets, encrypted at rest, synced to Docker Swarm.</p>
|
||||
</div>
|
||||
<div class="service-status" class:offline={!!error || status?.state !== 'ready'}>
|
||||
<span></span><div><small>Sync state</small><strong>{error ? 'Degraded' : (status?.state ?? 'Connecting')}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if route !== '/'}
|
||||
<button class="back" onclick={() => navigate('/treatvault')}>← TreatVault 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}
|
||||
{#if status?.error}
|
||||
<div class="warning" role="alert"><strong>Sync degraded</strong><p>{status.error}</p></div>
|
||||
{/if}
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-heading"><div><span class="eyebrow">Add secret</span><h3>New namespaced secret</h3></div></div>
|
||||
<div class="form">
|
||||
<label>Name<input type="text" bind:value={newName} placeholder="database_password" autocomplete="off" spellcheck="false" /></label>
|
||||
<label>Value<input type="password" bind:value={newValue} placeholder="Secret value" autocomplete="new-password" /></label>
|
||||
<button disabled={busy} onclick={createSecret}>Store secret</button>
|
||||
</div>
|
||||
<p class="hint">Values are sent to TreatVault and never displayed again. The encrypted file is the source of truth.</p>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-heading"><div><span class="eyebrow">Vault contents</span><h3>{route.startsWith('/audit') ? 'All managed secrets' : 'Managed secrets'}</h3></div>{#if route === '/'}<button onclick={() => navigate('/treatvault/audit')}>View audit →</button>{/if}</div>
|
||||
{#if !status?.secrets.length}
|
||||
<div class="empty">No secrets are stored yet.</div>
|
||||
{:else}
|
||||
<div class="table" role="table" aria-label="Managed secrets">
|
||||
<div class="row header" role="row"><span>Name</span><span>Sync</span><span>Consumers</span><span>Actions</span></div>
|
||||
{#each status.secrets as secret (secret.name)}
|
||||
<div class="row" role="row">
|
||||
<strong>{secret.name}</strong>
|
||||
<span class:ready={secret.synced} class:pending={!secret.synced}><i></i>{secret.synced ? 'Synced' : 'Pending'}</span>
|
||||
<span>{secret.inUse ? 'In use' : 'Unreferenced'}</span>
|
||||
<span class="actions">
|
||||
{#if rotateTarget === secret.name}
|
||||
<input type="password" bind:value={rotateValue} placeholder="New value" autocomplete="new-password" />
|
||||
<button disabled={busy} onclick={() => rotateSecret(secret.name)}>Save</button>
|
||||
<button class="quiet" disabled={busy} onclick={() => { rotateTarget = ''; rotateValue = ''; }}>Cancel</button>
|
||||
{:else}
|
||||
<button class="quiet" disabled={busy} onclick={() => { rotateTarget = secret.name; rotateValue = ''; }}>Rotate</button>
|
||||
<button class="danger" disabled={busy} onclick={() => confirmDelete(secret.name)}>Delete</button>
|
||||
{/if}
|
||||
</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 { 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; }
|
||||
.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); }
|
||||
.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); cursor: pointer; }
|
||||
.form { display: flex; gap: 1rem; align-items: end; padding: var(--bark-space-5, 1.5rem); flex-wrap: wrap; }
|
||||
.form label { display: grid; gap: .4rem; font-size: .72rem; color: var(--bark-text-muted, #91a1b7); text-transform: uppercase; letter-spacing: .07em; }
|
||||
.form input { min-width: 14rem; padding: .55rem .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); font-family: inherit; }
|
||||
.form button { padding: .55rem .9rem; border: 1px solid var(--bark-primary, #6ee7b7); border-radius: var(--bark-radius-sm, .45rem); background: color-mix(in srgb, var(--bark-primary, #6ee7b7) 15%, transparent); color: var(--bark-text, #edf3fb); cursor: pointer; }
|
||||
.form button:disabled { opacity: .5; cursor: default; }
|
||||
.hint { padding: 0 var(--bark-space-5, 1.5rem) var(--bark-space-4, 1rem); font-size: .78rem; }
|
||||
.row { display: grid; grid-template-columns: 1.4fr .8fr .9fr 1.8fr; gap: 1rem; align-items: center; min-height: 3.4rem; 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; }
|
||||
.ready { color: var(--bark-success, #4ade80); }
|
||||
.pending { color: var(--bark-warning, #fbbf24); }
|
||||
.ready i, .pending i { display: inline-block; width: .4rem; height: .4rem; margin-right: .4rem; border-radius: 50%; background: currentColor; }
|
||||
.actions { display: flex; gap: .5rem; align-items: center; }
|
||||
.actions input { flex: 1; min-width: 8rem; padding: .4rem .6rem; 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); }
|
||||
.actions button { padding: .4rem .65rem; 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; }
|
||||
.actions button.quiet { background: transparent; }
|
||||
.actions button.danger { border-color: color-mix(in srgb, var(--bark-danger, #fb7185) 50%, var(--bark-border, #27354a)); color: var(--bark-danger, #fb7185); }
|
||||
.actions button:disabled { opacity: .5; cursor: default; }
|
||||
.empty, .error, .warning { padding: 2.5rem var(--bark-space-5, 1.5rem); text-align: center; color: var(--bark-text-muted, #91a1b7); }
|
||||
.warning { border: 1px solid color-mix(in srgb, var(--bark-warning, #fbbf24) 35%, var(--bark-border, #27354a)); border-radius: var(--bark-radius-lg, 1rem); background: var(--bark-surface, #101827); text-align: left; }
|
||||
.warning strong { color: var(--bark-warning, #fbbf24); }
|
||||
.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 button, .error p { margin: .5rem auto 0; }
|
||||
.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; }
|
||||
.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; }
|
||||
.row { grid-template-columns: 1fr; gap: .35rem; padding: 1rem; }
|
||||
.row.header { display: none; }
|
||||
.form { flex-direction: column; align-items: stretch; }
|
||||
}
|
||||
</style>
|
||||
1
ui/src/entry.ts
Normal file
1
ui/src/entry.ts
Normal file
@@ -0,0 +1 @@
|
||||
import './TreatVault.svelte';
|
||||
33
ui/src/events.test.ts
Normal file
33
ui/src/events.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { dispatchConfirm, 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, '/treatvault/audit');
|
||||
const event = listener.mock.calls[0][0] as CustomEvent;
|
||||
expect(event.detail).toEqual({ path: '/treatvault/audit' });
|
||||
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, 'warning', 'Rotation failed');
|
||||
expect(detail).toEqual({ level: 'warning', message: 'Rotation failed' });
|
||||
});
|
||||
|
||||
it('requests confirmation with a resolve callback', () => {
|
||||
const target = new EventTarget();
|
||||
let detail: { message: string; resolve: (confirmed: boolean) => void } | undefined;
|
||||
target.addEventListener('barkstack:confirm', (event) => { detail = (event as CustomEvent).detail; });
|
||||
const resolve = vi.fn();
|
||||
dispatchConfirm(target, 'Delete secret?', resolve);
|
||||
expect(detail?.message).toBe('Delete secret?');
|
||||
detail?.resolve(true);
|
||||
expect(resolve).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
23
ui/src/events.ts
Normal file
23
ui/src/events.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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 }
|
||||
}));
|
||||
}
|
||||
|
||||
export function dispatchConfirm(target: EventTarget, message: string, resolve: (confirmed: boolean) => void): void {
|
||||
target.dispatchEvent(new CustomEvent('barkstack:confirm', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: { message, resolve }
|
||||
}));
|
||||
}
|
||||
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