diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8a2c00..4444ea5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,10 @@ jobs: env: GW_TEST_PG_URL: postgres://postgres:gwtest@127.0.0.1:5432/gw GW_TEST_REDIS_URL: redis://127.0.0.1:6379 + CP_TEST_PG_URL: postgres://postgres:gwtest@127.0.0.1:5432/gw?sslmode=disable + CP_TEST_REDIS_URL: redis://127.0.0.1:6379/0 + # built by the cargo test step; drives the tenant-token full-chain E2E + CP_TEST_GW_BIN: ${{ github.workspace }}/target/debug/gw steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -45,6 +49,35 @@ jobs: run: cargo clippy --workspace --all-targets -- -D warnings - name: Test run: cargo test --workspace + - uses: actions/setup-go@v6 + with: + go-version-file: control-plane/go.mod + cache-dependency-path: control-plane/go.sum + - name: Control-plane Go gates + working-directory: control-plane + run: | + GOWORK=off go vet ./... + GOWORK=off go test ./... + GOWORK=off go test -tags=integration -count=1 ./internal/integration + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: control-plane/web/package-lock.json + - name: Control-plane web gates + working-directory: control-plane/web + run: | + npm ci + npm test + npm run build + - name: Install browser + working-directory: control-plane/web + run: npx playwright install --with-deps chromium + - name: Control-plane browser E2E + working-directory: control-plane/web + run: npm run e2e + - name: Control-plane image builds + run: docker build control-plane/ deny: runs-on: ubuntu-latest diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7459a0a..e30291a 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -36,3 +36,16 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + - uses: docker/metadata-action@v5 + id: meta-cp + with: + images: ghcr.io/${{ github.repository }}-control-plane + - uses: docker/build-push-action@v6 + with: + context: control-plane + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta-cp.outputs.tags }} + labels: ${{ steps.meta-cp.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/release-control-plane.yml b/.github/workflows/release-control-plane.yml new file mode 100644 index 0000000..763329e --- /dev/null +++ b/.github/workflows/release-control-plane.yml @@ -0,0 +1,47 @@ +name: Release control plane + +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + binaries: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v6 + with: + go-version-file: control-plane/go.mod + cache-dependency-path: control-plane/go.sum + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: control-plane/web/package-lock.json + - name: Build web assets + working-directory: control-plane/web + run: | + npm ci + npm run build + - name: Build and package binaries + working-directory: control-plane + run: | + for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do + os="${target%/*}" arch="${target#*/}" + out="dist/control-plane_${GITHUB_REF_NAME}_${os}_${arch}" + mkdir -p "$out" + GOWORK=off CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \ + go build -trimpath -ldflags "-s -w" -o "$out/control-plane" ./cmd/control-plane + cp -r web/dist "$out/web-dist" + tar -czf "$out.tar.gz" -C dist "$(basename "$out")" + done + - name: Publish release assets + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 \ + || gh release create "$GITHUB_REF_NAME" --generate-notes + gh release upload "$GITHUB_REF_NAME" control-plane/dist/*.tar.gz --clobber diff --git a/.gitignore b/.gitignore index ea8c4bf..02b1a10 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ /target +/control-plane/web/node_modules +/control-plane/web/dist +/control-plane/web/playwright-report +/control-plane/web/test-results +*.tsbuildinfo diff --git a/Makefile b/Makefile index 54034b7..a819b63 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ BIN := gw -.PHONY: all build release test lint fmt fmt-check deny dist dist-plan docker run clean +.PHONY: all build release test lint fmt fmt-check deny dist dist-plan docker run clean control-plane control-plane-test control-plane-integration all: fmt lint test build @@ -38,5 +38,14 @@ docker: run: cargo run -p gw-server +control-plane: + $(MAKE) -C control-plane build + +control-plane-test: + $(MAKE) -C control-plane test web-test + +control-plane-integration: + $(MAKE) -C control-plane test-integration + clean: cargo clean diff --git a/README.md b/README.md index dcfa9ce..da3f42e 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ key-based auth, quotas, rate limits, failover, and a billing ledger. - **Providers behind traits** — engines talk to upstreams through a `Transport` seam; accounts with a real endpoint go over HTTP (reqwest + rustls), accounts without one are served by a deterministic in-process mock; AWS SigV4 signing included - **Observability built in** — Prometheus `/metrics` (per-route request/status counters, per-pipeline-stage latency, token counters), structured access logs - **One binary, one YAML** — no external services required to start; in-process state by default, SQLite for one-node durability, Postgres + Redis for a shared fleet; graceful shutdown +- **Web control plane** — a role-aware browser console ([`control-plane/`](control-plane/)): members see their own usage and charges, tenant admins manage keys and security events under gateway-enforced tenant scope, system admins get fleet economics, instance health, config publish/rollback and audit. Go BFF + React UI, its own identity store, everything proxied through the gateway admin API ## Quick Start @@ -54,7 +55,9 @@ docker run -p 8080:8080 -v $PWD/conf/gateway.yaml:/etc/gateway.yaml \ ``` The image binds `0.0.0.0` (`GW_HOST`) and ships a `/health` HEALTHCHECK. -Published multi-arch to `ghcr.io/cocoonstack/gateway` on `v*` tags. +Published multi-arch (amd64 + arm64) to `ghcr.io/cocoonstack/gateway` on `v*` +tags, alongside `ghcr.io/cocoonstack/gateway-control-plane` and control-plane +binary tarballs (linux/darwin × amd64/arm64) on the GitHub Release. ## Development diff --git a/control-plane/.dockerignore b/control-plane/.dockerignore new file mode 100644 index 0000000..661ae7b --- /dev/null +++ b/control-plane/.dockerignore @@ -0,0 +1,4 @@ +web/node_modules +web/dist +web/test-results +web/playwright-report diff --git a/control-plane/Dockerfile b/control-plane/Dockerfile new file mode 100644 index 0000000..1432bf6 --- /dev/null +++ b/control-plane/Dockerfile @@ -0,0 +1,24 @@ +FROM node:24-alpine AS web +WORKDIR /src/web +COPY web/package.json web/package-lock.json ./ +RUN npm ci +COPY web/ ./ +RUN npm run build + +FROM golang:1.25.6-alpine AS backend +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -o /control-plane ./cmd/control-plane + +FROM alpine:3.23 +RUN apk add --no-cache ca-certificates \ + && adduser -D -H -u 10001 controlplane +COPY --from=backend /control-plane /usr/local/bin/control-plane +COPY --from=web /src/web/dist /srv/control-plane/web +ENV CP_LISTEN=0.0.0.0:8090 CP_WEB_DIR=/srv/control-plane/web CP_COOKIE_SECURE=true +USER controlplane +EXPOSE 8090 +HEALTHCHECK --interval=30s --timeout=3s CMD wget -q -O /dev/null http://127.0.0.1:8090/api/v1/health || exit 1 +ENTRYPOINT ["/usr/local/bin/control-plane"] diff --git a/control-plane/Makefile b/control-plane/Makefile new file mode 100644 index 0000000..dfc1e07 --- /dev/null +++ b/control-plane/Makefile @@ -0,0 +1,30 @@ +.PHONY: test test-integration web web-test build + +RUST_CARGO ?= cargo + +test: + GOWORK=off go test ./... + +test-integration: + @set -e; trap 'docker compose -f compose.test.yaml down -v' EXIT; \ + docker compose -f compose.test.yaml up -d --wait; \ + $(RUST_CARGO) build --manifest-path ../Cargo.toml -p gw-server; \ + CP_TEST_PG_URL='postgres://postgres:controlplane@127.0.0.1:55432/controlplane?sslmode=disable' \ + CP_TEST_REDIS_URL='redis://127.0.0.1:56379/0' \ + CP_TEST_GW_BIN="$$(cd .. && pwd)/target/debug/gw" \ + GOWORK=off go test -tags=integration -count=1 -v ./internal/integration; \ + GW_TEST_PG_URL='postgres://postgres:controlplane@127.0.0.1:55432/controlplane' \ + GW_TEST_REDIS_URL='redis://127.0.0.1:56379' \ + $(RUST_CARGO) test --manifest-path ../Cargo.toml -p gw-state; \ + GW_TEST_PG_URL='postgres://postgres:controlplane@127.0.0.1:55432/controlplane' \ + $(RUST_CARGO) test --manifest-path ../Cargo.toml -p gw-server --test e2e admin_config_publish_reloads_from_store + +web-test: + npm --prefix web test + npm --prefix web run build + +web: + npm --prefix web run build + +build: web + GOWORK=off go build ./cmd/control-plane diff --git a/control-plane/README.md b/control-plane/README.md new file mode 100644 index 0000000..d24a031 --- /dev/null +++ b/control-plane/README.md @@ -0,0 +1,126 @@ +# Gateway control plane + +The control plane is a small Go BFF with a React/TypeScript browser UI. It owns +human identities and browser sessions; it does **not** read or mutate the Rust +gateway's Postgres tables or Redis keys. Usage, billing, model status, access +keys, audit data and configuration all cross the Rust gateway admin HTTP API. + +## Roles + +| Role | Scope | UI and API access | +| --- | --- | --- | +| `member` | One gateway tenant and user ID | Own usage, charges and model availability | +| `tenant_admin` | One gateway tenant | Tenant usage, key lifecycle and security events | +| `system_admin` | Global | Fleet economics, instances/accounts, users, keys, audit and configuration | + +The Go service derives tenant/user filters from the authenticated session. It +never trusts browser-supplied scope for a member or tenant administrator, and it +removes vendor cost from non-system responses. + +Tenant boundaries on key mutations are enforced by the **gateway**, not this +process: when a tenant administrator acts, the control plane authenticates with +that tenant's scoped gateway admin token (`CP_GATEWAY_TENANT_TOKENS`, matching +the gateway's per-tenant `admin_token_env`), so the gateway's own +`AdminScope` checks draw the line atomically. Mutations fail closed — a tenant +admin without a configured tenant token cannot mutate keys at all. The gateway +additionally redacts vendor cost for tenant-scoped tokens server-side. + +Every request carries an `X-Request-ID` (caller-supplied or generated): it is +echoed in the response, stamped on access and audit log lines as `rid=`, and +forwarded on every proxied gateway call. + +## Gateway instances + +`CP_GATEWAY_TARGETS` is a deliberately simple ordered list such as: + +```text +gw-a=http://gateway-a:8080,gw-b=http://gateway-b:8080 +``` + +The first target handles shared admin reads and mutations. Gateway configuration +and keys already converge through the gateway's own Postgres/Redis mechanisms. +The control plane polls every configured target in parallel for `/health` and +`/internal/accounts`, so an operator can distinguish instance reachability from +account-pool health and client-visible model availability. There is no second +service-discovery or leader-election system in this project. + +## Local development + +The default configuration uses an in-memory identity store, in-memory session +KV and a deterministic gateway mock: + +```sh +cd control-plane +CP_DEV_SEED=true go run ./cmd/control-plane +``` + +In another terminal: + +```sh +cd control-plane/web +npm install +npm run dev +``` + +Open . Development accounts are: + +| Role | Email | Password | +| --- | --- | --- | +| System admin | `admin@example.com` | `admin12345!` | +| Tenant admin | `manager@example.com` | `manager123!` | +| Member | `user@example.com` | `user12345!` | + +These accounts exist only when `CP_DEV_SEED=true` is set explicitly — the +default is always false, for every store backend. + +## Configuration + +| Variable | Default | Purpose | +| --- | --- | --- | +| `CP_LISTEN` | `127.0.0.1:8090` | HTTP listen address | +| `CP_STORE` | `memory` | `memory` or `postgres` for control-plane users | +| `CP_DATABASE_URL` | — | Control-plane Postgres URL | +| `CP_KV` | `memory` | `memory` or `redis` for browser sessions | +| `CP_REDIS_URL` | — | Control-plane Redis URL | +| `CP_DEV_SEED` | `false` | Seed fixed demo accounts (never enable in production) | +| `CP_GATEWAY_MODE` | `mock` | `mock` or `http` | +| `CP_GATEWAY_TARGETS` | `local=http://127.0.0.1:8080` | Comma-separated `id=url` targets | +| `CP_GATEWAY_ADMIN_TOKEN` | — | Global Rust gateway admin bearer token | +| `CP_GATEWAY_TENANT_TOKENS` | — | Comma-separated `tenant=token` scoped gateway admin tokens | +| `CP_WEB_DIR` | `web/dist` | Built browser assets | +| `CP_SESSION_TTL` | `12h` | Browser session lifetime | +| `CP_COOKIE_SECURE` | `false` | Secure cookie flag; enable behind HTTPS | +| `CP_BOOTSTRAP_ADMIN_EMAIL` | — | Optional first Postgres system admin | +| `CP_BOOTSTRAP_ADMIN_PASSWORD` | — | Password paired with bootstrap email | + +The Postgres schema contains only `users`. Redis uses only +`gateway:control-plane:session:*`. Neither connection points at gateway-owned +state. + +## Tests and builds + +```sh +make test # Go unit tests +make web-test # Vitest plus production UI build +make test-integration # Local Postgres 16 + Redis 7 containers +make build # Browser assets plus Go binary +``` + +The browser E2E suite runs the in-memory/mock stack: + +```sh +cd web +npx playwright install chromium +npm run e2e +``` + +The BFF contract is in [`api/openapi.yaml`](api/openapi.yaml); a Go test +(`internal/httpapi/openapi_test.go`) fails CI when routes and spec drift. Rust +admin API details remain in [`../docs/api.md`](../docs/api.md). + +## Releases + +Version tags publish multi-arch (`linux/amd64` + `linux/arm64`) Docker images +for the gateway and the control plane (`.github/workflows/docker.yml`), plus +control-plane binary tarballs for linux/darwin × amd64/arm64 with the built +web assets bundled (`.github/workflows/release.yml`). diff --git a/control-plane/api/openapi.yaml b/control-plane/api/openapi.yaml new file mode 100644 index 0000000..3c3ab2a --- /dev/null +++ b/control-plane/api/openapi.yaml @@ -0,0 +1,279 @@ +# Mirrors internal/httpapi/server.go routes and the wire types in +# internal/gateway + internal/user — update together. +openapi: 3.1.0 +info: + title: Gateway Control Plane API + version: 0.1.0 + description: Browser-facing API. Gateway data and mutations are proxied through the Rust gateway admin API. +servers: + - url: /api/v1 +paths: + /health: + get: + security: [] + summary: Control-plane liveness + responses: + "200": {description: Healthy, content: {application/json: {schema: {type: object}}}} + /auth/login: + post: + security: [] + summary: Start an opaque browser session + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, password] + properties: + email: {type: string, format: email} + password: {type: string, format: password} + responses: + "200": {$ref: "#/components/responses/Session"} + "401": {$ref: "#/components/responses/Error"} + /auth/logout: + post: + summary: End the current session + responses: + "204": {description: Signed out} + /session: + get: + summary: Current user and CSRF token + responses: + "200": {$ref: "#/components/responses/Session"} + "401": {$ref: "#/components/responses/Error"} + /overview: + get: + summary: Scoped usage, trend and model availability + parameters: + - {$ref: "#/components/parameters/Since"} + - {$ref: "#/components/parameters/Until"} + - {$ref: "#/components/parameters/Bucket"} + responses: + "200": {description: Overview, content: {application/json: {schema: {type: object}}}} + /usage: + get: + summary: Scoped usage and billing rows + parameters: + - {$ref: "#/components/parameters/Since"} + - {$ref: "#/components/parameters/Until"} + responses: + "200": {description: Usage rows, content: {application/json: {schema: {type: object}}}} + /usage/series: + get: + summary: Scoped hourly or daily usage series + parameters: + - {$ref: "#/components/parameters/Since"} + - {$ref: "#/components/parameters/Until"} + - {$ref: "#/components/parameters/Bucket"} + responses: + "200": {description: Usage series, content: {application/json: {schema: {$ref: "#/components/schemas/UsageSeries"}}}} + /models/status: + get: + summary: Scoped client-visible model availability + responses: + "200": {description: Model states, content: {application/json: {schema: {type: object}}}} + /admin/instances: + get: + summary: Configured gateway instances and local account health + x-role: system_admin + responses: + "200": {description: Instance states, content: {application/json: {schema: {type: object}}}} + /admin/users: + get: + summary: List control-plane identities + x-role: system_admin + responses: + "200": {description: Users, content: {application/json: {schema: {type: object}}}} + post: + summary: Create a control-plane identity + x-role: system_admin + requestBody: {$ref: "#/components/requestBodies/UserCreate"} + responses: + "201": {description: Created user, content: {application/json: {schema: {$ref: "#/components/schemas/User"}}}} + /admin/users/{id}: + patch: + summary: Update or disable a control-plane identity + x-role: system_admin + parameters: + - {$ref: "#/components/parameters/ID"} + requestBody: + required: true + content: {application/json: {schema: {type: object}}} + responses: + "200": {description: Updated user, content: {application/json: {schema: {$ref: "#/components/schemas/User"}}}} + /admin/keys: + get: + summary: List live gateway access-key lifecycle state + x-role: tenant_admin_or_system_admin + parameters: + - {name: tenant, in: query, schema: {type: string}} + - {name: offset, in: query, schema: {type: integer, minimum: 0}} + - {name: limit, in: query, schema: {type: integer, minimum: 1, default: 1000}} + responses: + "200": {description: Keys, content: {application/json: {schema: {type: object}}}} + post: + summary: Create a gateway access key + x-role: tenant_admin_or_system_admin + requestBody: + required: true + content: {application/json: {schema: {$ref: "#/components/schemas/AccessKey"}}} + responses: + "201": {description: Key created} + /admin/keys/{ak}: + patch: + summary: Change gateway key limits or lifecycle state + x-role: tenant_admin_or_system_admin + parameters: + - {$ref: "#/components/parameters/AK"} + requestBody: + required: true + content: {application/json: {schema: {type: object}}} + responses: + "200": {description: Updated key, content: {application/json: {schema: {$ref: "#/components/schemas/AccessKey"}}}} + delete: + summary: Revoke a gateway access key + x-role: tenant_admin_or_system_admin + parameters: + - {$ref: "#/components/parameters/AK"} + responses: + "204": {description: Revoked} + /admin/config: + get: + summary: Current gateway configuration + x-role: system_admin + responses: + "200": {description: Configuration document, content: {application/json: {schema: {$ref: "#/components/schemas/ConfigDocument"}}}} + put: + summary: Validate and publish gateway configuration + x-role: system_admin + requestBody: {$ref: "#/components/requestBodies/Config"} + responses: + "200": {description: Published version, content: {application/json: {schema: {type: object}}}} + "409": {description: Head moved past expected_version; reload and retry} + /admin/config/validate: + post: + summary: Validate gateway configuration without publishing + x-role: system_admin + requestBody: {$ref: "#/components/requestBodies/Config"} + responses: + "200": {description: Validation result, content: {application/json: {schema: {type: object}}}} + /admin/config/versions: + get: + summary: Retained gateway configuration versions + x-role: system_admin + responses: + "200": {description: Versions, content: {application/json: {schema: {type: object}}}} + /admin/config/versions/{id}/rollback: + post: + summary: Republish a retained gateway configuration + x-role: system_admin + parameters: + - {$ref: "#/components/parameters/ID"} + responses: + "200": {description: New active version, content: {application/json: {schema: {type: object}}}} + /admin/audit: + get: + summary: Gateway operational audit or scoped security events + x-role: "kind=ops: system_admin; kind=security: tenant_admin_or_system_admin" + parameters: + - {name: kind, in: query, schema: {type: string, enum: [ops, security], default: ops}} + - {name: tenant, in: query, schema: {type: string}} + responses: + "200": {description: Audit entries or security events, content: {application/json: {schema: {type: object}}}} +components: + securitySchemes: + sessionCookie: + type: apiKey + in: cookie + name: cp_session + csrf: + type: apiKey + in: header + name: X-CSRF-Token + parameters: + ID: {name: id, in: path, required: true, schema: {type: string}} + AK: {name: ak, in: path, required: true, schema: {type: string}} + Since: {name: since, in: query, schema: {type: integer, format: int64}} + Until: {name: until, in: query, schema: {type: integer, format: int64}} + Bucket: {name: bucket, in: query, schema: {type: string, enum: [hour, day]}} + requestBodies: + Config: + required: true + content: + application/json: + schema: + type: object + required: [yaml] + properties: + yaml: {type: string} + expected_version: {type: integer, format: int64, description: "When >0, publish only if this is still the head version"} + UserCreate: + required: true + content: + application/json: + schema: + allOf: + - {$ref: "#/components/schemas/User"} + - {type: object, required: [password], properties: {password: {type: string, format: password}}} + responses: + Session: + description: Authenticated session + content: + application/json: + schema: + type: object + required: [user, csrf_token] + properties: + user: {$ref: "#/components/schemas/User"} + csrf_token: {type: string} + Error: + description: Error envelope + content: {application/json: {schema: {type: object}}} + schemas: + User: + type: object + required: [email, display_name, role] + properties: + id: {type: string} + email: {type: string, format: email} + display_name: {type: string} + tenant: {type: string} + gateway_user_id: {type: string} + role: {type: string, enum: [member, tenant_admin, system_admin]} + disabled: {type: boolean} + created_at: {type: integer, format: int64} + updated_at: {type: integer, format: int64} + AccessKey: + type: object + required: [ak, product, tenant] + properties: + ak: {type: string} + product: {type: string} + tenant: {type: string} + owner: {type: [string, "null"]} + qps: {type: number} + daily_token_quota: {type: integer, format: int64} + tokens_per_minute: {type: [integer, "null"], format: int64} + expires_at_epoch_secs: {type: [integer, "null"], format: int64} + suspended_until_epoch_secs: {type: [integer, "null"], format: int64} + model_quotas: {type: object, additionalProperties: {type: integer, format: int64}} + banned: {type: boolean} + status: {type: string, enum: [active, banned, expired, suspended]} + available: {type: boolean} + ConfigDocument: + type: object + required: [version, yaml] + properties: + version: {type: integer, format: int64} + yaml: {type: string} + UsageSeries: + type: object + properties: + bucket: {type: string, enum: [hour, day]} + since: {type: integer, format: int64} + until: {type: integer, format: int64} + series: {type: array, items: {type: object}} +security: + - sessionCookie: [] + csrf: [] diff --git a/control-plane/cmd/control-plane/main.go b/control-plane/cmd/control-plane/main.go new file mode 100644 index 0000000..74825c9 --- /dev/null +++ b/control-plane/cmd/control-plane/main.go @@ -0,0 +1,163 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/projecteru2/core/log" + "github.com/projecteru2/core/types" + + "github.com/cocoonstack/gateway/control-plane/internal/auth" + "github.com/cocoonstack/gateway/control-plane/internal/config" + "github.com/cocoonstack/gateway/control-plane/internal/gateway" + "github.com/cocoonstack/gateway/control-plane/internal/httpapi" + "github.com/cocoonstack/gateway/control-plane/internal/kv" + kvmemory "github.com/cocoonstack/gateway/control-plane/internal/kv/memory" + kvredis "github.com/cocoonstack/gateway/control-plane/internal/kv/redis" + "github.com/cocoonstack/gateway/control-plane/internal/user" + usermemory "github.com/cocoonstack/gateway/control-plane/internal/user/memory" + userpostgres "github.com/cocoonstack/gateway/control-plane/internal/user/postgres" +) + +func main() { + ctx := context.Background() + cfg, err := config.Load() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if err := log.SetupLog(ctx, &types.ServerLogConfig{Level: cfg.LogLevel}, ""); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) + defer stop() + if err := run(ctx, cfg); err != nil { + log.WithFunc("main").Error(ctx, err, "control plane stopped") + os.Exit(1) + } +} + +func run(ctx context.Context, cfg config.Config) error { + users, closeUsers, err := buildUserStore(ctx, cfg) + if err != nil { + return err + } + defer closeUsers() + sessions, err := buildSessionStore(ctx, cfg) + if err != nil { + return err + } + defer sessions.Close() + gw, err := buildGateway(cfg) + if err != nil { + return err + } + if err := seedUsers(ctx, users, cfg); err != nil { + return err + } + + api := httpapi.New(users, sessions, gw, cfg.SessionTTL, cfg.CookieSecure, cfg.WebDir) + server := &http.Server{ + Addr: cfg.ListenAddr, + Handler: api.Handler(), + ReadHeaderTimeout: 5 * time.Second, + IdleTimeout: 60 * time.Second, + } + done := make(chan struct{}) + go func() { + defer close(done) + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + log.WithFunc("main.run").Error(shutdownCtx, err, "drain HTTP server") + } + }() + log.WithFunc("main.run").Infof(ctx, "control plane listening on http://%s", cfg.ListenAddr) + err = server.ListenAndServe() + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("serve control plane: %w", err) + } + <-done + return nil +} + +func buildUserStore(ctx context.Context, cfg config.Config) (user.Store, func(), error) { + if cfg.StoreDriver == "memory" { + return usermemory.New(), func() {}, nil + } + store, err := userpostgres.Connect(ctx, cfg.DatabaseURL) + if err != nil { + return nil, nil, err + } + return store, store.Close, nil +} + +func buildSessionStore(ctx context.Context, cfg config.Config) (kv.Sessions, error) { + if cfg.KVDriver == "memory" { + return kvmemory.New(), nil + } + return kvredis.Connect(ctx, cfg.RedisURL) +} + +func buildGateway(cfg config.Config) (gateway.Client, error) { + if cfg.GatewayMode == "mock" { + return gateway.NewMock(), nil + } + return gateway.NewHTTP(cfg.GatewayTargets, cfg.GatewayAdminToken, cfg.GatewayTenantTokens) +} + +type userSeed struct { + id, email, displayName, password, tenant, gatewayUserID string + role user.Role +} + +func seedUsers(ctx context.Context, store user.Store, cfg config.Config) error { + if cfg.DevSeed { + seeds := []userSeed{ + {"dev-admin", "admin@example.com", "System Admin", "admin12345!", "", "", user.RoleSystemAdmin}, + {"dev-tenant-admin", "manager@example.com", "Acme Admin", "manager123!", "acme", "", user.RoleTenantAdmin}, + {"dev-member", "user@example.com", "Alice Chen", "user12345!", "acme", "alice", user.RoleMember}, + } + for _, seed := range seeds { + if err := ensureUser(ctx, store, seed); err != nil { + return err + } + } + } + if cfg.BootstrapEmail != "" { + seed := userSeed{"bootstrap-admin", cfg.BootstrapEmail, "System Admin", cfg.BootstrapPassword, "", "", user.RoleSystemAdmin} + if err := ensureUser(ctx, store, seed); err != nil { + return err + } + } + return nil +} + +func ensureUser(ctx context.Context, store user.Store, seed userSeed) error { + if _, err := store.ByEmail(ctx, seed.email); err == nil { + return nil + } else if !errors.Is(err, user.ErrNotFound) { + return err + } + hash, err := auth.HashPassword(seed.password) + if err != nil { + return fmt.Errorf("hash seed password: %w", err) + } + now := time.Now().Unix() + if err := store.Create(ctx, user.User{ + ID: seed.id, Email: seed.email, DisplayName: seed.displayName, PasswordHash: hash, + Tenant: seed.tenant, GatewayUserID: seed.gatewayUserID, Role: seed.role, + CreatedAt: now, UpdatedAt: now, + }); err != nil { + return fmt.Errorf("seed user %s: %w", seed.email, err) + } + return nil +} diff --git a/control-plane/compose.test.yaml b/control-plane/compose.test.yaml new file mode 100644 index 0000000..42c83de --- /dev/null +++ b/control-plane/compose.test.yaml @@ -0,0 +1,23 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_PASSWORD: controlplane + POSTGRES_DB: controlplane + ports: + - "55432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d controlplane"] + interval: 2s + timeout: 2s + retries: 20 + + redis: + image: redis:7-alpine + ports: + - "56379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 2s + timeout: 2s + retries: 20 diff --git a/control-plane/go.mod b/control-plane/go.mod new file mode 100644 index 0000000..acf364e --- /dev/null +++ b/control-plane/go.mod @@ -0,0 +1,40 @@ +module github.com/cocoonstack/gateway/control-plane + +go 1.25.6 + +require ( + github.com/jackc/pgx/v5 v5.10.0 + github.com/projecteru2/core v0.0.0-20241016125006-ff909eefe04c + github.com/redis/go-redis/v9 v9.21.0 + golang.org/x/crypto v0.54.0 + golang.org/x/sync v0.22.0 +) + +require ( + github.com/alphadose/haxmap v1.2.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cockroachdb/errors v1.9.1 // indirect + github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect + github.com/cockroachdb/redact v1.1.3 // indirect + github.com/getsentry/sentry-go v0.20.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.18 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rogpeppe/go-internal v1.11.0 // indirect + github.com/rs/zerolog v1.29.1 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/grpc v1.60.1 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect +) diff --git a/control-plane/go.sum b/control-plane/go.sum new file mode 100644 index 0000000..fb58ca4 --- /dev/null +++ b/control-plane/go.sum @@ -0,0 +1,409 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno= +github.com/CloudyKit/jet/v3 v3.0.0/go.mod h1:HKQPgSJmdK8hdoAbKUUWajkHyHo4RaU5rMdUywE7VMo= +github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY= +github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398/go.mod h1:a1uqRtAwp2Xwc6WNPJEufxJ7fx3npB4UV/JOLmbu5I0= +github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= +github.com/alphadose/haxmap v1.2.0 h1:noGrAmCE+gNheZ4KpW+sYj9W5uMcO1UAjbAq9XBOAfM= +github.com/alphadose/haxmap v1.2.0/go.mod h1:rjHw1IAqbxm0S3U5tD16GoKsiAd8FWx5BJ2IYqXwgmM= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cockroachdb/datadriven v1.0.2/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU= +github.com/cockroachdb/errors v1.9.1 h1:yFVvsI0VxmRShfawbt/laCIDy/mtTqqnvoNgiy5bEV8= +github.com/cockroachdb/errors v1.9.1/go.mod h1:2sxOtL2WIc096WSZqZ5h8fa17rdDq9HZOZLBCor4mBk= +github.com/cockroachdb/logtags v0.0.0-20211118104740-dabe8e521a4f/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= +github.com/cockroachdb/redact v1.1.3 h1:AKZds10rFSIj7qADf0g46UixK8NNLwWTNdCIGS5wfSQ= +github.com/cockroachdb/redact v1.1.3/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= +github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgraph-io/badger v1.6.0/go.mod h1:zwt7syl517jmP8s94KqSxTlM6IMsdhYy6psNgSztDR4= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw= +github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc= +github.com/getsentry/sentry-go v0.12.0/go.mod h1:NSap0JBYWzHND8oMbyi0+XZhUalc1TBdRL1M71JZW2c= +github.com/getsentry/sentry-go v0.20.0 h1:bwXW98iMRIWxn+4FgPW7vMrjmbym6HblXALmhjHmQaQ= +github.com/getsentry/sentry-go v0.20.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= +github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= +github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM= +github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= +github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= +github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/googleapis v0.0.0-20180223154316-0cd9801be74a/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= +github.com/gogo/googleapis v1.4.1/go.mod h1:2lpHqI5OcWCtVElxXnPt+s8oJvMpySlOyM6xDCrzib4= +github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/gogo/status v1.1.0/go.mod h1:BFv9nrluPLmrS0EmGVvLaPNmRosr9KapBYd5/hpY1WM= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/gomodule/redigo v1.7.1-0.20190724094224-574c33c3df38/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/hydrogen18/memlistener v0.0.0-20200120041712-dcc25e7acd91/go.mod h1:qEIFzExnS6016fRpRfxrExeVn2gbClQA99gQhnIcdhE= +github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/iris-contrib/blackfriday v2.0.0+incompatible/go.mod h1:UzZ2bDEoaSGPbkg6SAB4att1aAwTmVIx/5gCVqeyUdI= +github.com/iris-contrib/go.uuid v2.0.0+incompatible/go.mod h1:iz2lgM/1UnEf1kP0L/+fafWORmlnuysV2EMP8MW+qe0= +github.com/iris-contrib/jade v1.1.3/go.mod h1:H/geBymxJhShH5kecoiOCSssPX7QWYH7UaeZTSWddIk= +github.com/iris-contrib/pongo2 v0.0.1/go.mod h1:Ssh+00+3GAZqSQb30AvBRNxBx7rf0GqwkjqxNd0u65g= +github.com/iris-contrib/schema v0.0.1/go.mod h1:urYA3uvUNG1TIIjOSCzHr9/LmbQo8LrOcOqfqxa4hXw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k= +github.com/kataras/golog v0.0.10/go.mod h1:yJ8YKCmyL+nWjERB90Qwn+bdyBZsaQwU3bTVFgkFIp8= +github.com/kataras/iris/v12 v12.1.8/go.mod h1:LMYy4VlP67TQ3Zgriz8RE2h2kMZV2SgMYbq3UhfoFmE= +github.com/kataras/neffos v0.0.14/go.mod h1:8lqADm8PnbeFfL7CLXh1WHw53dG27MC3pgi2R1rmoTE= +github.com/kataras/pio v0.0.2/go.mod h1:hAoW0t9UmXi4R5Oyq5Z4irTbaTsOemSrDGUtaTl7Dro= +github.com/kataras/sitemap v0.0.5/go.mod h1:KY2eugMKiPwsJgx7+U103YZehfvNGOXURubcGyk0Bz8= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.9.7/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/cpuid v1.2.1 h1:vJi+O/nMdFt0vqm8NZBI6wzALWdA2X+egi0ogNyrC/w= +github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo/v4 v4.5.0/go.mod h1:czIriw4a0C1dFun+ObrXp7ok03xON0N1awStJ6ArI7Y= +github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= +github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw= +github.com/mediocregopher/radix/v3 v3.4.2/go.mod h1:8FL3F6UQRXHXIBSPUs5h0RybMF8i4n7wVopoX3x7Bv8= +github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= +github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg= +github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w= +github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/projecteru2/core v0.0.0-20241016125006-ff909eefe04c h1:5KVBuw+0Kcy7NhnR1j0eETNRH/AdI8Koodw+WWhWIMw= +github.com/projecteru2/core v0.0.0-20241016125006-ff909eefe04c/go.mod h1:MmpwqgDuc9Wx7JZzyCvEzAqNoYfDgBfKhcrkeWhBHvc= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= +github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.29.1 h1:cO+d60CHkknCbvzEWxP0S9K6KqyTjrCNUy1LdQLCGPc= +github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= +github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= +github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.6.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w= +github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= +github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI= +github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= +github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= +go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 h1:5llv2sWeaMSnA3w2kS57ouQQ4pudlXrR0dCgw51QK9o= +golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190327091125-710a502c58a2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20211008194852-3b03d305991f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181221001348-537d06c36207/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190327201419-c70d86f8b7cf/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180518175338-11a468237815/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24= +google.golang.org/genproto v0.0.0-20231002182017-d307bd883b97 h1:SeZZZx0cP0fqUyA+oRzP9k7cSwJlvDFiROO72uwD6i0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97 h1:6GQBEOdGkX6MMTLT9V+TjtIRZCw9VPD5Z+yHY9wMgS0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97/go.mod h1:v7nGkzlmW8P3n/bKmWBn2WpBjpOEx8Q6gMueudAmKfY= +google.golang.org/grpc v1.12.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.60.1 h1:26+wFr+cNqSGFcOXcabYC0lUVJVRa2Sb2ortSK7VrEU= +google.golang.org/grpc v1.60.1/go.mod h1:OlCHIeLYqSSsLi6i49B5QGdzaMZK9+M7LXN2FKz4eGM= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= +gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20191120175047-4206685974f2/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/control-plane/internal/auth/password.go b/control-plane/internal/auth/password.go new file mode 100644 index 0000000..db7998f --- /dev/null +++ b/control-plane/internal/auth/password.go @@ -0,0 +1,74 @@ +// Package auth implements local password and opaque-session authentication. +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +const ( + argonTime = 2 + argonMemory = 64 * 1024 + argonThreads = 2 + argonKeyLen = 32 + argonSaltLen = 16 +) + +func HashPassword(password string) (string, error) { + if len(password) < 10 { + return "", fmt.Errorf("password must be at least 10 characters") + } + salt := make([]byte, argonSaltLen) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("read password salt: %w", err) + } + hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + enc := base64.RawStdEncoding + return fmt.Sprintf( + "$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", + argonMemory, + argonTime, + argonThreads, + enc.EncodeToString(salt), + enc.EncodeToString(hash), + ), nil +} + +func VerifyPassword(encoded, password string) bool { + parts := strings.Split(encoded, "$") + if len(parts) != 6 || parts[1] != "argon2id" || parts[2] != "v=19" { + return false + } + var memory, timeCost uint32 + var threads uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads); err != nil { + return false + } + if memory > 256*1024 || timeCost > 10 || threads > 16 || memory == 0 || timeCost == 0 || threads == 0 { + return false + } + enc := base64.RawStdEncoding + salt, err := enc.DecodeString(parts[4]) + if err != nil || len(salt) < 8 { + return false + } + want, err := enc.DecodeString(parts[5]) + if err != nil || len(want) == 0 || len(want) > 64 { + return false + } + got := argon2.IDKey([]byte(password), salt, timeCost, memory, threads, uint32(len(want))) + return subtle.ConstantTimeCompare(got, want) == 1 +} + +func RandomToken(bytes int) (string, error) { + buf := make([]byte, bytes) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("read random token: %w", err) + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} diff --git a/control-plane/internal/auth/password_test.go b/control-plane/internal/auth/password_test.go new file mode 100644 index 0000000..060bd9e --- /dev/null +++ b/control-plane/internal/auth/password_test.go @@ -0,0 +1,25 @@ +package auth + +import "testing" + +func TestPasswordRoundTrip(t *testing.T) { + hash, err := HashPassword("correct-horse") + if err != nil { + t.Fatalf("hash password: %v", err) + } + if !VerifyPassword(hash, "correct-horse") { + t.Fatal("correct password did not verify") + } + if VerifyPassword(hash, "wrong-password") { + t.Fatal("wrong password verified") + } + if VerifyPassword("not-a-hash", "correct-horse") { + t.Fatal("malformed hash verified") + } +} + +func TestPasswordMinimum(t *testing.T) { + if _, err := HashPassword("short"); err == nil { + t.Fatal("short password accepted") + } +} diff --git a/control-plane/internal/config/config.go b/control-plane/internal/config/config.go new file mode 100644 index 0000000..18adf1a --- /dev/null +++ b/control-plane/internal/config/config.go @@ -0,0 +1,109 @@ +// Package config loads control-plane runtime configuration from environment. +package config + +import ( + "cmp" + "fmt" + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + ListenAddr string + StoreDriver string + DatabaseURL string + KVDriver string + RedisURL string + GatewayMode string + GatewayTargets string + GatewayAdminToken string + GatewayTenantTokens map[string]string + WebDir string + SessionTTL time.Duration + CookieSecure bool + DevSeed bool + BootstrapEmail string + BootstrapPassword string + LogLevel string +} + +func Load() (Config, error) { + cfg := Config{ + ListenAddr: cmp.Or(os.Getenv("CP_LISTEN"), "127.0.0.1:8090"), + StoreDriver: cmp.Or(os.Getenv("CP_STORE"), "memory"), + DatabaseURL: os.Getenv("CP_DATABASE_URL"), + KVDriver: cmp.Or(os.Getenv("CP_KV"), "memory"), + RedisURL: os.Getenv("CP_REDIS_URL"), + GatewayMode: cmp.Or(os.Getenv("CP_GATEWAY_MODE"), "mock"), + GatewayTargets: cmp.Or(os.Getenv("CP_GATEWAY_TARGETS"), "local=http://127.0.0.1:8080"), + GatewayAdminToken: os.Getenv("CP_GATEWAY_ADMIN_TOKEN"), + WebDir: cmp.Or(os.Getenv("CP_WEB_DIR"), "web/dist"), + SessionTTL: 12 * time.Hour, + CookieSecure: envBool("CP_COOKIE_SECURE", false), + DevSeed: envBool("CP_DEV_SEED", false), + BootstrapEmail: os.Getenv("CP_BOOTSTRAP_ADMIN_EMAIL"), + BootstrapPassword: os.Getenv("CP_BOOTSTRAP_ADMIN_PASSWORD"), + LogLevel: cmp.Or(os.Getenv("CP_LOG_LEVEL"), "info"), + } + if raw := os.Getenv("CP_SESSION_TTL"); raw != "" { + ttl, err := time.ParseDuration(raw) + if err != nil || ttl <= 0 { + return Config{}, fmt.Errorf("CP_SESSION_TTL must be a positive duration") + } + cfg.SessionTTL = ttl + } + if cfg.StoreDriver != "memory" && cfg.StoreDriver != "postgres" { + return Config{}, fmt.Errorf("CP_STORE must be memory or postgres") + } + if cfg.StoreDriver == "postgres" && cfg.DatabaseURL == "" { + return Config{}, fmt.Errorf("CP_DATABASE_URL is required for postgres") + } + if cfg.KVDriver != "memory" && cfg.KVDriver != "redis" { + return Config{}, fmt.Errorf("CP_KV must be memory or redis") + } + if cfg.KVDriver == "redis" && cfg.RedisURL == "" { + return Config{}, fmt.Errorf("CP_REDIS_URL is required for redis") + } + if cfg.GatewayMode != "mock" && cfg.GatewayMode != "http" { + return Config{}, fmt.Errorf("CP_GATEWAY_MODE must be mock or http") + } + if cfg.GatewayMode == "http" && strings.TrimSpace(cfg.GatewayAdminToken) == "" { + return Config{}, fmt.Errorf("CP_GATEWAY_ADMIN_TOKEN is required for http gateway mode") + } + tenantTokens, err := parseTenantTokens(os.Getenv("CP_GATEWAY_TENANT_TOKENS")) + if err != nil { + return Config{}, err + } + cfg.GatewayTenantTokens = tenantTokens + if (cfg.BootstrapEmail == "") != (cfg.BootstrapPassword == "") { + return Config{}, fmt.Errorf("bootstrap admin email and password must be set together") + } + return cfg, nil +} + +func parseTenantTokens(raw string) (map[string]string, error) { + tokens := make(map[string]string) + for part := range strings.SplitSeq(raw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + tenant, token, ok := strings.Cut(part, "=") + if !ok || tenant == "" || token == "" { + return nil, fmt.Errorf("CP_GATEWAY_TENANT_TOKENS entries must be tenant=token") + } + tokens[tenant] = token + } + return tokens, nil +} + +func envBool(name string, fallback bool) bool { + raw := os.Getenv(name) + if raw == "" { + return fallback + } + v, err := strconv.ParseBool(raw) + return err == nil && v +} diff --git a/control-plane/internal/config/config_test.go b/control-plane/internal/config/config_test.go new file mode 100644 index 0000000..6b1a878 --- /dev/null +++ b/control-plane/internal/config/config_test.go @@ -0,0 +1,21 @@ +package config + +import "testing" + +func TestParseTenantTokens(t *testing.T) { + tokens, err := parseTenantTokens(" acme=tok-a, labs=tok-b ,") + if err != nil { + t.Fatalf("parse tenant tokens: %v", err) + } + if tokens["acme"] != "tok-a" || tokens["labs"] != "tok-b" || len(tokens) != 2 { + t.Errorf("tokens = %v, want acme/labs pair", tokens) + } + if empty, err := parseTenantTokens(""); err != nil || len(empty) != 0 { + t.Errorf("empty input = %v, %v; want empty map", empty, err) + } + for _, bad := range []string{"acme", "=tok", "acme="} { + if _, err := parseTenantTokens(bad); err == nil { + t.Errorf("input %q accepted, want error", bad) + } + } +} diff --git a/control-plane/internal/gateway/gateway.go b/control-plane/internal/gateway/gateway.go new file mode 100644 index 0000000..961d390 --- /dev/null +++ b/control-plane/internal/gateway/gateway.go @@ -0,0 +1,155 @@ +// Package gateway adapts the Rust gateway admin API for the control plane. +package gateway + +import ( + "context" + "errors" +) + +var ( + ErrNotFound = errors.New("not found") + ErrConflict = errors.New("conflict") +) + +type UsageRow struct { + UserID string `json:"user_id"` + Model string `json:"model"` + Requests int64 `json:"requests"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + TotalTokens int64 `json:"total_tokens"` + CostMicros int64 `json:"cost_micros"` + VendorCostMicros int64 `json:"vendor_cost_micros"` +} + +type SeriesPoint struct { + Start int64 `json:"start"` + End int64 `json:"end"` + Requests int64 `json:"requests"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + TotalTokens int64 `json:"total_tokens"` + CostMicros int64 `json:"cost_micros"` + VendorCostMicros int64 `json:"vendor_cost_micros"` +} + +type Series struct { + Bucket string `json:"bucket"` + Since int64 `json:"since"` + Until int64 `json:"until"` + Points []SeriesPoint `json:"series"` +} + +type ModelStatus struct { + Model string `json:"model"` + State string `json:"state"` + Requests int64 `json:"requests"` + Errors int64 `json:"errors"` + WindowMinutes int64 `json:"window_minutes"` +} + +type Key struct { + AK string `json:"ak"` + Product string `json:"product"` + Tenant string `json:"tenant"` + Owner *string `json:"owner"` + QPS float64 `json:"qps"` + DailyTokenQuota int64 `json:"daily_token_quota"` + TokensPerMinute *int64 `json:"tokens_per_minute"` + ExpiresAtEpochSecs *int64 `json:"expires_at_epoch_secs"` + Banned bool `json:"banned"` + SuspendedUntilEpochSecs *int64 `json:"suspended_until_epoch_secs"` + Status string `json:"status"` + Available bool `json:"available"` + ModelQuotas map[string]int64 `json:"model_quotas,omitempty"` +} + +type Account struct { + Name string `json:"name"` + Provider string `json:"provider"` + Priority int64 `json:"priority"` + Tier string `json:"tier"` + Health string `json:"health"` + Protocols []string `json:"protocols"` +} + +type Instance struct { + ID string `json:"id"` + URL string `json:"url"` + Status string `json:"status"` + LatencyMS int64 `json:"latency_ms"` + Error string `json:"error,omitempty"` + Accounts []Account `json:"accounts"` +} + +type ConfigDocument struct { + Version int64 `json:"version"` + YAML string `json:"yaml"` +} + +type ConfigVersion struct { + ID int64 `json:"id"` + CreatedAtEpochSecs int64 `json:"created_at_epoch_secs"` +} + +type AuditEntry struct { + CreatedAtEpochSecs int64 `json:"created_at_epoch_secs"` + Actor string `json:"actor"` + Scope string `json:"scope"` + Action string `json:"action"` + Target string `json:"target"` + Summary string `json:"summary"` + SourceIP string `json:"source_ip"` +} + +type SecurityEvent struct { + CreatedAtEpochSecs int64 `json:"created_at_epoch_secs"` + RequestID string `json:"request_id"` + AK string `json:"ak"` + UserID string `json:"user_id"` + Tenant string `json:"tenant"` + Surface string `json:"surface"` + Rule string `json:"rule"` + Action string `json:"action"` + Hits int64 `json:"hits"` +} + +type Scope struct { + Tenant string + User string +} + +// Client is the control plane's only dependency on the Rust gateway. Key +// mutations carry the acting tenant ("" = global operator) so the gateway's +// own AdminScope enforcement — not this process — draws the tenant boundary. +type Client interface { + Usage(ctx context.Context, scope Scope, since, until int64) ([]UsageRow, error) + UsageSeries(ctx context.Context, scope Scope, bucket string, since, until int64) (Series, error) + Models(ctx context.Context, scope Scope) ([]ModelStatus, error) + Keys(ctx context.Context, tenant string, offset, limit int64) ([]Key, error) + CreateKey(ctx context.Context, actingTenant string, key Key) error + PatchKey(ctx context.Context, actingTenant, ak string, patch map[string]any) (Key, error) + DeleteKey(ctx context.Context, actingTenant, ak string) error + Instances(ctx context.Context) ([]Instance, error) + Config(ctx context.Context) (ConfigDocument, error) + ValidateConfig(ctx context.Context, yaml string) (map[string]any, error) + PublishConfig(ctx context.Context, yaml string, expectedVersion int64) (int64, error) + ConfigVersions(ctx context.Context) ([]ConfigVersion, error) + RollbackConfig(ctx context.Context, id int64) (int64, error) + Audit(ctx context.Context) ([]AuditEntry, error) + SecurityEvents(ctx context.Context, tenant string) ([]SecurityEvent, error) +} + +type ridKey struct{} + +// WithRequestID tags ctx so every gateway call made under it carries the +// X-Request-ID header end to end. +func WithRequestID(ctx context.Context, rid string) context.Context { + return context.WithValue(ctx, ridKey{}, rid) +} + +// RequestIDFrom returns the request id set by WithRequestID, or "". +func RequestIDFrom(ctx context.Context) string { + rid, _ := ctx.Value(ridKey{}).(string) + return rid +} diff --git a/control-plane/internal/gateway/http.go b/control-plane/internal/gateway/http.go new file mode 100644 index 0000000..b411f6a --- /dev/null +++ b/control-plane/internal/gateway/http.go @@ -0,0 +1,358 @@ +package gateway + +import ( + "bytes" + "cmp" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + "time" +) + +type Target struct { + ID string + URL string +} + +var _ Client = (*HTTPClient)(nil) + +type HTTPClient struct { + targets []Target + adminToken string + tenantTokens map[string]string + client *http.Client +} + +func NewHTTP(rawTargets, adminToken string, tenantTokens map[string]string) (*HTTPClient, error) { + targets, err := parseTargets(rawTargets) + if err != nil { + return nil, err + } + return &HTTPClient{ + targets: targets, + adminToken: adminToken, + tenantTokens: tenantTokens, + client: &http.Client{Timeout: 8 * time.Second}, + }, nil +} + +func (c *HTTPClient) Usage(ctx context.Context, scope Scope, since, until int64) ([]UsageRow, error) { + q := scopeQuery(scope) + q.Set("since", strconv.FormatInt(since, 10)) + q.Set("until", strconv.FormatInt(until, 10)) + var resp struct { + Usage []UsageRow `json:"usage"` + } + if err := c.doJSON(ctx, c.primary(), http.MethodGet, "/admin/usage/users?"+q.Encode(), nil, &resp, c.readBearer(scope.Tenant)); err != nil { + return nil, err + } + return resp.Usage, nil +} + +func (c *HTTPClient) UsageSeries(ctx context.Context, scope Scope, bucket string, since, until int64) (Series, error) { + q := scopeQuery(scope) + q.Set("bucket", bucket) + q.Set("since", strconv.FormatInt(since, 10)) + q.Set("until", strconv.FormatInt(until, 10)) + var series Series + err := c.doJSON(ctx, c.primary(), http.MethodGet, "/admin/usage/series?"+q.Encode(), nil, &series, c.readBearer(scope.Tenant)) + return series, err +} + +func (c *HTTPClient) Models(ctx context.Context, scope Scope) ([]ModelStatus, error) { + q := scopeQuery(scope) + path := "/admin/models/status" + if len(q) > 0 { + path += "?" + q.Encode() + } + var resp struct { + Models []ModelStatus `json:"models"` + } + if err := c.doJSON(ctx, c.primary(), http.MethodGet, path, nil, &resp, c.readBearer(scope.Tenant)); err != nil { + return nil, err + } + return resp.Models, nil +} + +func (c *HTTPClient) Keys(ctx context.Context, tenant string, offset, limit int64) ([]Key, error) { + q := make(url.Values) + if offset > 0 { + q.Set("offset", strconv.FormatInt(offset, 10)) + } + if limit > 0 { + q.Set("limit", strconv.FormatInt(limit, 10)) + } + if tenant != "" { + q.Set("tenant", tenant) + } + var resp struct { + Keys []Key `json:"keys"` + } + if err := c.doJSON(ctx, c.primary(), http.MethodGet, "/admin/keys?"+q.Encode(), nil, &resp, c.readBearer(tenant)); err != nil { + return nil, err + } + return resp.Keys, nil +} + +func (c *HTTPClient) CreateKey(ctx context.Context, actingTenant string, key Key) error { + bearer, err := c.mutateBearer(actingTenant) + if err != nil { + return err + } + return c.doJSON(ctx, c.primary(), http.MethodPost, "/admin/keys", key, nil, bearer) +} + +func (c *HTTPClient) PatchKey(ctx context.Context, actingTenant, ak string, patch map[string]any) (Key, error) { + bearer, err := c.mutateBearer(actingTenant) + if err != nil { + return Key{}, err + } + var key Key + err = c.doJSON(ctx, c.primary(), http.MethodPatch, "/admin/keys/"+url.PathEscape(ak), patch, &key, bearer) + return key, err +} + +func (c *HTTPClient) DeleteKey(ctx context.Context, actingTenant, ak string) error { + bearer, err := c.mutateBearer(actingTenant) + if err != nil { + return err + } + return c.doJSON(ctx, c.primary(), http.MethodDelete, "/admin/keys/"+url.PathEscape(ak), nil, nil, bearer) +} + +func (c *HTTPClient) Instances(ctx context.Context) ([]Instance, error) { + ch := make(chan Instance, len(c.targets)) + for _, target := range c.targets { + go func() { + started := time.Now() + instance := Instance{ID: target.ID, URL: target.URL, Status: "unavailable", Accounts: []Account{}} + var health struct { + Status string `json:"status"` + } + if err := c.doJSON(ctx, target, http.MethodGet, "/health", nil, &health, ""); err != nil { + instance.Error = err.Error() + instance.LatencyMS = time.Since(started).Milliseconds() + ch <- instance + return + } + var accounts struct { + Accounts []Account `json:"accounts"` + } + if err := c.doJSON(ctx, target, http.MethodGet, "/internal/accounts", nil, &accounts, ""); err != nil { + instance.Status = "degraded" + instance.Error = err.Error() + } else { + instance.Status = "available" + instance.Accounts = accounts.Accounts + } + instance.LatencyMS = time.Since(started).Milliseconds() + ch <- instance + }() + } + instances := make([]Instance, 0, len(c.targets)) + for range c.targets { + instances = append(instances, <-ch) + } + slices.SortFunc(instances, func(a, b Instance) int { return cmp.Compare(a.ID, b.ID) }) + return instances, nil +} + +func (c *HTTPClient) Config(ctx context.Context) (ConfigDocument, error) { + var doc ConfigDocument + err := c.doJSON(ctx, c.primary(), http.MethodGet, "/admin/config", nil, &doc, c.adminToken) + return doc, err +} + +func (c *HTTPClient) ValidateConfig(ctx context.Context, yaml string) (map[string]any, error) { + var result map[string]any + err := c.doText(ctx, c.primary(), http.MethodPost, "/admin/config/validate", yaml, &result) + return result, err +} + +func (c *HTTPClient) PublishConfig(ctx context.Context, yaml string, expectedVersion int64) (int64, error) { + path := "/admin/config" + if expectedVersion > 0 { + path += "?expected_version=" + strconv.FormatInt(expectedVersion, 10) + } + var result struct { + Version int64 `json:"version"` + } + err := c.doText(ctx, c.primary(), http.MethodPut, path, yaml, &result) + return result.Version, err +} + +func (c *HTTPClient) ConfigVersions(ctx context.Context) ([]ConfigVersion, error) { + var result struct { + Versions []ConfigVersion `json:"versions"` + } + err := c.doJSON(ctx, c.primary(), http.MethodGet, "/admin/config/versions", nil, &result, c.adminToken) + return result.Versions, err +} + +func (c *HTTPClient) RollbackConfig(ctx context.Context, id int64) (int64, error) { + var result struct { + Version int64 `json:"version"` + } + path := fmt.Sprintf("/admin/config/versions/%d/rollback", id) + err := c.doJSON(ctx, c.primary(), http.MethodPost, path, nil, &result, c.adminToken) + return result.Version, err +} + +func (c *HTTPClient) Audit(ctx context.Context) ([]AuditEntry, error) { + var result struct { + Entries []AuditEntry `json:"entries"` + } + err := c.doJSON(ctx, c.primary(), http.MethodGet, "/admin/audit/ops?limit=200", nil, &result, c.adminToken) + return result.Entries, err +} + +func (c *HTTPClient) SecurityEvents(ctx context.Context, tenant string) ([]SecurityEvent, error) { + q := make(url.Values) + q.Set("limit", "200") + if tenant != "" { + q.Set("tenant", tenant) + } + var result struct { + Events []SecurityEvent `json:"events"` + } + err := c.doJSON(ctx, c.primary(), http.MethodGet, "/admin/audit/events?"+q.Encode(), nil, &result, c.readBearer(tenant)) + return result.Events, err +} + +func (c *HTTPClient) primary() Target { return c.targets[0] } + +func (c *HTTPClient) readBearer(tenant string) string { + if token, ok := c.tenantTokens[tenant]; ok { + return token + } + return c.adminToken +} + +// mutateBearer fails closed: a global-token fallback would erase the tenant boundary the gateway enforces. +func (c *HTTPClient) mutateBearer(actingTenant string) (string, error) { + if actingTenant == "" { + return c.adminToken, nil + } + token, ok := c.tenantTokens[actingTenant] + if !ok { + return "", fmt.Errorf("no gateway admin token configured for tenant %s", actingTenant) + } + return token, nil +} + +func (c *HTTPClient) doJSON(ctx context.Context, target Target, method, path string, input, output any, bearer string) error { + var body io.Reader + if input != nil { + encoded, err := json.Marshal(input) + if err != nil { + return fmt.Errorf("encode gateway request: %w", err) + } + body = bytes.NewReader(encoded) + } + req, err := http.NewRequestWithContext(ctx, method, target.URL+path, body) + if err != nil { + return fmt.Errorf("create gateway request: %w", err) + } + if input != nil { + req.Header.Set("Content-Type", "application/json") + } + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + if rid := RequestIDFrom(ctx); rid != "" { + req.Header.Set("X-Request-ID", rid) + } + return c.send(req, output) +} + +func (c *HTTPClient) doText(ctx context.Context, target Target, method, path, input string, output any) error { + req, err := http.NewRequestWithContext(ctx, method, target.URL+path, strings.NewReader(input)) + if err != nil { + return fmt.Errorf("create gateway request: %w", err) + } + req.Header.Set("Content-Type", "application/yaml") + req.Header.Set("Authorization", "Bearer "+c.adminToken) + if rid := RequestIDFrom(ctx); rid != "" { + req.Header.Set("X-Request-ID", rid) + } + return c.send(req, output) +} + +func (c *HTTPClient) send(req *http.Request, output any) error { + resp, err := c.client.Do(req) + if err != nil { + return fmt.Errorf("request gateway: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return fmt.Errorf("read gateway response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var envelope struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } + _ = json.Unmarshal(body, &envelope) + message := strings.TrimSpace(envelope.Error.Message) + if message == "" { + message = strings.TrimSpace(string(body)) + } + switch resp.StatusCode { + case http.StatusNotFound: + return fmt.Errorf("%w: %s", ErrNotFound, message) + case http.StatusConflict: + return fmt.Errorf("%w: %s", ErrConflict, message) + } + return fmt.Errorf("gateway %s: %s", resp.Status, message) + } + if output != nil && len(body) > 0 { + if err := json.Unmarshal(body, output); err != nil { + return fmt.Errorf("decode gateway response: %w", err) + } + } + return nil +} + +func parseTargets(raw string) ([]Target, error) { + parts := strings.Split(raw, ",") + targets := make([]Target, 0, len(parts)) + seen := make(map[string]struct{}) + for _, part := range parts { + id, endpoint, ok := strings.Cut(strings.TrimSpace(part), "=") + if !ok || id == "" || endpoint == "" { + return nil, fmt.Errorf("CP_GATEWAY_TARGETS entries must be id=url") + } + parsed, err := url.Parse(endpoint) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return nil, fmt.Errorf("gateway target %s has an invalid URL", id) + } + if _, ok := seen[id]; ok { + return nil, fmt.Errorf("duplicate gateway target %s", id) + } + seen[id] = struct{}{} + targets = append(targets, Target{ID: id, URL: strings.TrimRight(endpoint, "/")}) + } + if len(targets) == 0 { + return nil, fmt.Errorf("at least one gateway target is required") + } + return targets, nil +} + +func scopeQuery(scope Scope) url.Values { + q := make(url.Values) + if scope.Tenant != "" { + q.Set("tenant", scope.Tenant) + } + if scope.User != "" { + q.Set("user", scope.User) + } + return q +} diff --git a/control-plane/internal/gateway/mock.go b/control-plane/internal/gateway/mock.go new file mode 100644 index 0000000..7eb1656 --- /dev/null +++ b/control-plane/internal/gateway/mock.go @@ -0,0 +1,260 @@ +package gateway + +import ( + "cmp" + "context" + "encoding/json" + "fmt" + "slices" + "strings" + "sync" + "time" +) + +var _ Client = (*MockClient)(nil) + +type MockClient struct { + mu sync.RWMutex + yaml string + version int64 + versions []ConfigVersion + keys map[string]Key + audit []AuditEntry +} + +func NewMock() *MockClient { + now := time.Now().Unix() + owner := "alice" + expires := now + 30*86_400 + return &MockClient{ + yaml: "listen: {host: 0.0.0.0, port: 8080}\nstorage: {}\nmodels:\n - {name: gpt-4o, protocol: openai-chat}\naccounts:\n - {name: primary-openai, provider: openai, protocols: [openai-chat]}\ntenants:\n - {name: acme}\naccess_keys: []\n", + version: 3, + versions: []ConfigVersion{ + {ID: 3, CreatedAtEpochSecs: now - 300}, + {ID: 2, CreatedAtEpochSecs: now - 3_600}, + {ID: 1, CreatedAtEpochSecs: now - 86_400}, + }, + keys: map[string]Key{ + "ak-acme-alice": { + AK: "ak-acme-alice", Product: "standard", Tenant: "acme", Owner: &owner, + QPS: 10, DailyTokenQuota: 1_000_000, Status: "active", Available: true, + }, + "ak-acme-batch": { + AK: "ak-acme-batch", Product: "batch", Tenant: "acme", QPS: 2, + DailyTokenQuota: 500_000, ExpiresAtEpochSecs: &expires, Status: "active", Available: true, + }, + "ak-labs-paused": { + AK: "ak-labs-paused", Product: "research", Tenant: "labs", QPS: 1, + DailyTokenQuota: 100_000, Banned: true, Status: "banned", Available: false, + }, + }, + audit: []AuditEntry{ + {CreatedAtEpochSecs: now - 300, Actor: "global", Scope: "global", Action: "config_publish", Target: "3", SourceIP: "127.0.0.1"}, + {CreatedAtEpochSecs: now - 900, Actor: "global", Scope: "global", Action: "key_patch", Target: "ak-labs-paused", SourceIP: "127.0.0.1"}, + }, + } +} + +func (m *MockClient) Usage(_ context.Context, scope Scope, _, _ int64) ([]UsageRow, error) { + userID := scope.User + if userID == "" { + userID = "alice" + } + rows := []UsageRow{ + {UserID: userID, Model: "gpt-4o", Requests: 184, PromptTokens: 128_400, CompletionTokens: 42_700, TotalTokens: 171_100, CostMicros: 748_000, VendorCostMicros: 422_000}, + {UserID: userID, Model: "claude-sonnet", Requests: 62, PromptTokens: 84_200, CompletionTokens: 19_600, TotalTokens: 103_800, CostMicros: 512_000, VendorCostMicros: 331_000}, + } + if scope.User == "" { + rows = append(rows, UsageRow{UserID: "bob", Model: "gpt-4o-mini", Requests: 323, PromptTokens: 212_000, CompletionTokens: 31_000, TotalTokens: 243_000, CostMicros: 192_000, VendorCostMicros: 89_000}) + } + return rows, nil +} + +func (m *MockClient) UsageSeries(_ context.Context, _ Scope, bucket string, since, until int64) (Series, error) { + seconds := int64(86_400) + if bucket == "hour" { + seconds = 3_600 + } + first := since - since%seconds + points := make([]SeriesPoint, 0) + for start, idx := first, int64(0); start <= until && len(points) < 400; start, idx = start+seconds, idx+1 { + requests := 18 + (idx*7)%19 + tokens := requests * (730 + (idx%5)*120) + points = append(points, SeriesPoint{ + Start: start, End: min(start+seconds-1, until), Requests: requests, + PromptTokens: tokens * 3 / 4, CompletionTokens: tokens / 4, TotalTokens: tokens, + CostMicros: tokens * 4, VendorCostMicros: tokens * 2, + }) + } + return Series{Bucket: bucket, Since: since, Until: until, Points: points}, nil +} + +func (m *MockClient) Models(_ context.Context, _ Scope) ([]ModelStatus, error) { + return []ModelStatus{ + {Model: "gpt-4o", State: "available", Requests: 986, Errors: 4, WindowMinutes: 15}, + {Model: "gpt-4o-mini", State: "available", Requests: 1_422, Errors: 8, WindowMinutes: 15}, + {Model: "claude-sonnet", State: "unstable", Requests: 412, Errors: 57, WindowMinutes: 15}, + {Model: "realtime", State: "no_data", Requests: 0, Errors: 0, WindowMinutes: 15}, + }, nil +} + +func (m *MockClient) Keys(_ context.Context, tenant string, offset, limit int64) ([]Key, error) { + m.mu.RLock() + defer m.mu.RUnlock() + keys := make([]Key, 0, len(m.keys)) + for _, key := range m.keys { + if tenant == "" || key.Tenant == tenant { + keys = append(keys, cloneJSON(key)) + } + } + slices.SortFunc(keys, func(a, b Key) int { return cmp.Compare(a.AK, b.AK) }) + if offset > 0 { + keys = keys[min(offset, int64(len(keys))):] + } + if limit > 0 && int64(len(keys)) > limit { + keys = keys[:limit] + } + return keys, nil +} + +func (m *MockClient) CreateKey(_ context.Context, actingTenant string, key Key) error { + m.mu.Lock() + defer m.mu.Unlock() + if key.AK == "" || key.Product == "" || key.Tenant == "" { + return fmt.Errorf("ak, product and tenant are required") + } + // the real gateway answers an uncovered existing ak with 404 (scoped_key anti-probing), never 409 + if existing, ok := m.keys[key.AK]; ok && actingTenant != "" && existing.Tenant != actingTenant { + return fmt.Errorf("key %s: %w", key.AK, ErrNotFound) + } + key.Status = "active" + key.Available = true + m.keys[key.AK] = cloneJSON(key) + m.record("key_create", key.AK) + return nil +} + +func (m *MockClient) PatchKey(_ context.Context, actingTenant, ak string, patch map[string]any) (Key, error) { + m.mu.Lock() + defer m.mu.Unlock() + key, ok := m.keys[ak] + if !ok || (actingTenant != "" && key.Tenant != actingTenant) { + return Key{}, fmt.Errorf("key %s: %w", ak, ErrNotFound) + } + if value, ok := patch["qps"].(float64); ok { + key.QPS = value + } + if value, ok := patch["daily_token_quota"].(float64); ok { + key.DailyTokenQuota = int64(value) + } + if value, ok := patch["banned"].(bool); ok { + key.Banned = value + key.Available = !value + if value { + key.Status = "banned" + } else { + key.Status = "active" + } + } + m.keys[ak] = key + m.record("key_patch", ak) + return cloneJSON(key), nil +} + +func (m *MockClient) DeleteKey(_ context.Context, actingTenant, ak string) error { + m.mu.Lock() + defer m.mu.Unlock() + if key, ok := m.keys[ak]; !ok || (actingTenant != "" && key.Tenant != actingTenant) { + return fmt.Errorf("key %s: %w", ak, ErrNotFound) + } + delete(m.keys, ak) + m.record("key_delete", ak) + return nil +} + +func (m *MockClient) Instances(context.Context) ([]Instance, error) { + return []Instance{ + {ID: "gw-a", URL: "http://gw-a:8080", Status: "available", LatencyMS: 8, Accounts: []Account{{Name: "openai-primary", Provider: "openai", Tier: "paygo", Health: "healthy", Protocols: []string{"openai-chat"}}}}, + {ID: "gw-b", URL: "http://gw-b:8080", Status: "available", LatencyMS: 11, Accounts: []Account{{Name: "anthropic-primary", Provider: "anthropic", Tier: "paygo", Health: "healthy", Protocols: []string{"anthropic-messages"}}}}, + }, nil +} + +func (m *MockClient) Config(context.Context) (ConfigDocument, error) { + m.mu.RLock() + defer m.mu.RUnlock() + return ConfigDocument{Version: m.version, YAML: m.yaml}, nil +} + +func (m *MockClient) ValidateConfig(_ context.Context, yaml string) (map[string]any, error) { + if !strings.Contains(yaml, "listen:") || !strings.Contains(yaml, "models:") { + return nil, fmt.Errorf("invalid config: listen and models are required") + } + return map[string]any{"valid": true, "models": strings.Count(yaml, "name:")}, nil +} + +func (m *MockClient) PublishConfig(ctx context.Context, yaml string, expectedVersion int64) (int64, error) { + if _, err := m.ValidateConfig(ctx, yaml); err != nil { + return 0, err + } + m.mu.Lock() + defer m.mu.Unlock() + if expectedVersion > 0 && expectedVersion != m.version { + return 0, fmt.Errorf("config head is at version %d: %w", m.version, ErrConflict) + } + m.version++ + m.yaml = yaml + m.versions = append([]ConfigVersion{{ID: m.version, CreatedAtEpochSecs: time.Now().Unix()}}, m.versions...) + m.record("config_publish", fmt.Sprint(m.version)) + return m.version, nil +} + +func (m *MockClient) ConfigVersions(context.Context) ([]ConfigVersion, error) { + m.mu.RLock() + defer m.mu.RUnlock() + return append([]ConfigVersion(nil), m.versions...), nil +} + +func (m *MockClient) RollbackConfig(_ context.Context, id int64) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if !slices.ContainsFunc(m.versions, func(v ConfigVersion) bool { return v.ID == id }) { + return 0, fmt.Errorf("config version %d: %w", id, ErrNotFound) + } + m.version++ + m.versions = append([]ConfigVersion{{ID: m.version, CreatedAtEpochSecs: time.Now().Unix()}}, m.versions...) + m.record("config_rollback", fmt.Sprint(m.version)) + return m.version, nil +} + +func (m *MockClient) Audit(context.Context) ([]AuditEntry, error) { + m.mu.RLock() + defer m.mu.RUnlock() + entries := append([]AuditEntry(nil), m.audit...) + slices.SortFunc(entries, func(a, b AuditEntry) int { return cmp.Compare(b.CreatedAtEpochSecs, a.CreatedAtEpochSecs) }) + return entries, nil +} + +func (m *MockClient) SecurityEvents(_ context.Context, tenant string) ([]SecurityEvent, error) { + now := time.Now().Unix() + if tenant == "" { + tenant = "acme" + } + return []SecurityEvent{ + {CreatedAtEpochSecs: now - 120, RequestID: "req-42", AK: "ak-acme-alice", UserID: "alice", Tenant: tenant, Surface: "chat", Rule: "dlp", Action: "redact", Hits: 1}, + {CreatedAtEpochSecs: now - 500, RequestID: "req-39", AK: "ak-acme-batch", UserID: "bob", Tenant: tenant, Surface: "batch", Rule: "blocklist", Action: "flag", Hits: 2}, + }, nil +} + +func (m *MockClient) record(action, target string) { + m.audit = append(m.audit, AuditEntry{ + CreatedAtEpochSecs: time.Now().Unix(), Actor: "control-plane", Scope: "global", + Action: action, Target: target, SourceIP: "127.0.0.1", + }) +} + +func cloneJSON[T any](value T) T { + body, _ := json.Marshal(value) + var out T + _ = json.Unmarshal(body, &out) + return out +} diff --git a/control-plane/internal/httpapi/admin.go b/control-plane/internal/httpapi/admin.go new file mode 100644 index 0000000..b0ca831 --- /dev/null +++ b/control-plane/internal/httpapi/admin.go @@ -0,0 +1,296 @@ +package httpapi + +import ( + "errors" + "net/http" + "strconv" + "time" + + "github.com/cocoonstack/gateway/control-plane/internal/auth" + "github.com/cocoonstack/gateway/control-plane/internal/gateway" + "github.com/cocoonstack/gateway/control-plane/internal/user" +) + +var patchableKeyFields = map[string]bool{ + "qps": true, "daily_token_quota": true, "tokens_per_minute": true, + "expires_at_epoch_secs": true, "banned": true, "suspended_until_epoch_secs": true, +} + +func (s *Server) listUsers(w http.ResponseWriter, r *http.Request) { + users, err := s.users.List(r.Context()) + if err != nil { + mapError(r.Context(), w, err) + return + } + for idx := range users { + users[idx] = publicUser(users[idx]) + } + writeJSON(w, http.StatusOK, map[string]any{"users": users}) +} + +func (s *Server) createUser(w http.ResponseWriter, r *http.Request) { + var body struct { + Email string `json:"email"` + DisplayName string `json:"display_name"` + Password string `json:"password"` + Tenant string `json:"tenant"` + GatewayUserID string `json:"gateway_user_id"` + Role user.Role `json:"role"` + } + if !decodeJSON(w, r, maxJSONBody, &body) { + return + } + hash, err := auth.HashPassword(body.Password) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + id, err := auth.RandomToken(12) + if err != nil { + mapError(r.Context(), w, err) + return + } + now := time.Now().Unix() + u := user.User{ + ID: id, Email: body.Email, DisplayName: body.DisplayName, PasswordHash: hash, + Tenant: body.Tenant, GatewayUserID: body.GatewayUserID, Role: body.Role, + CreatedAt: now, UpdatedAt: now, + } + if err := s.users.Create(r.Context(), u); err != nil { + writeUserSaveError(w, err) + return + } + auditLog(r, "user_create", u.Email) + writeJSON(w, http.StatusCreated, publicUser(u)) +} + +func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) { + u, err := s.users.ByID(r.Context(), r.PathValue("id")) + if err != nil { + mapError(r.Context(), w, err) + return + } + var body struct { + Email *string `json:"email"` + DisplayName *string `json:"display_name"` + Password *string `json:"password"` + Tenant *string `json:"tenant"` + GatewayUserID *string `json:"gateway_user_id"` + Role *user.Role `json:"role"` + Disabled *bool `json:"disabled"` + } + if !decodeJSON(w, r, maxJSONBody, &body) { + return + } + if body.Email != nil { + u.Email = *body.Email + } + if body.DisplayName != nil { + u.DisplayName = *body.DisplayName + } + if body.Password != nil && *body.Password != "" { + u.PasswordHash, err = auth.HashPassword(*body.Password) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + // evict sessions issued before the reset so a stolen cookie dies too + u.PasswordChangedAt = time.Now().Unix() + } + if body.Tenant != nil { + u.Tenant = *body.Tenant + } + if body.GatewayUserID != nil { + u.GatewayUserID = *body.GatewayUserID + } + if body.Role != nil { + u.Role = *body.Role + } + if body.Disabled != nil { + u.Disabled = *body.Disabled + } + u.UpdatedAt = time.Now().Unix() + if err := s.users.Update(r.Context(), u); err != nil { + writeUserSaveError(w, err) + return + } + auditLog(r, "user_patch", u.ID) + writeJSON(w, http.StatusOK, publicUser(u)) +} + +func (s *Server) listKeys(w http.ResponseWriter, r *http.Request) { + p := current(r) + tenant := scopedTenant(p, r.URL.Query().Get("tenant")) + keys, err := s.gateway.Keys(r.Context(), tenant, queryInt(r, "offset", 0), queryInt(r, "limit", 1000)) + if err != nil { + mapError(r.Context(), w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"keys": keys}) +} + +func (s *Server) createKey(w http.ResponseWriter, r *http.Request) { + var key gateway.Key + if !decodeJSON(w, r, maxJSONBody, &key) { + return + } + p := current(r) + key.Tenant = scopedTenant(p, key.Tenant) + if key.AK == "" || key.Product == "" || key.Tenant == "" { + writeError(w, http.StatusBadRequest, "ak, product and tenant are required") + return + } + if err := s.gateway.CreateKey(r.Context(), actingTenant(p), key); err != nil { + mapError(r.Context(), w, err) + return + } + auditLog(r, "key_create", key.AK) + writeJSON(w, http.StatusCreated, map[string]string{"status": "created", "ak": key.AK}) +} + +func (s *Server) patchKey(w http.ResponseWriter, r *http.Request) { + ak := r.PathValue("ak") + var patch map[string]any + if !decodeJSON(w, r, maxJSONBody, &patch) { + return + } + for field := range patch { + if !patchableKeyFields[field] { + writeError(w, http.StatusBadRequest, "unsupported key field: "+field) + return + } + } + key, err := s.gateway.PatchKey(r.Context(), actingTenant(current(r)), ak, patch) + if err != nil { + mapError(r.Context(), w, err) + return + } + auditLog(r, "key_patch", ak) + writeJSON(w, http.StatusOK, key) +} + +func (s *Server) deleteKey(w http.ResponseWriter, r *http.Request) { + ak := r.PathValue("ak") + if err := s.gateway.DeleteKey(r.Context(), actingTenant(current(r)), ak); err != nil { + mapError(r.Context(), w, err) + return + } + auditLog(r, "key_delete", ak) + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) getConfig(w http.ResponseWriter, r *http.Request) { + doc, err := s.gateway.Config(r.Context()) + if err != nil { + mapError(r.Context(), w, err) + return + } + writeJSON(w, http.StatusOK, doc) +} + +func (s *Server) validateConfig(w http.ResponseWriter, r *http.Request) { + body, ok := configBody(w, r) + if !ok { + return + } + result, err := s.gateway.ValidateConfig(r.Context(), body.YAML) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, result) +} + +func (s *Server) publishConfig(w http.ResponseWriter, r *http.Request) { + body, ok := configBody(w, r) + if !ok { + return + } + version, err := s.gateway.PublishConfig(r.Context(), body.YAML, body.ExpectedVersion) + if err != nil { + mapError(r.Context(), w, err) + return + } + auditLog(r, "config_publish", strconv.FormatInt(version, 10)) + writeJSON(w, http.StatusOK, map[string]any{"status": "published", "version": version}) +} + +func (s *Server) configVersions(w http.ResponseWriter, r *http.Request) { + versions, err := s.gateway.ConfigVersions(r.Context()) + if err != nil { + mapError(r.Context(), w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"versions": versions}) +} + +func (s *Server) rollbackConfig(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id <= 0 { + writeError(w, http.StatusBadRequest, "invalid config version") + return + } + version, err := s.gateway.RollbackConfig(r.Context(), id) + if err != nil { + mapError(r.Context(), w, err) + return + } + auditLog(r, "config_rollback", strconv.FormatInt(version, 10)) + writeJSON(w, http.StatusOK, map[string]any{"status": "rolled_back", "version": version}) +} + +func (s *Server) audit(w http.ResponseWriter, r *http.Request) { + p := current(r) + kind := r.URL.Query().Get("kind") + if kind == "" { + kind = "ops" + } + if kind == "ops" { + if p.User.Role != user.RoleSystemAdmin { + writeError(w, http.StatusForbidden, "system admin role required") + return + } + entries, err := s.gateway.Audit(r.Context()) + if err != nil { + mapError(r.Context(), w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"entries": entries}) + return + } + if kind != "security" { + writeError(w, http.StatusBadRequest, "kind must be ops or security") + return + } + events, err := s.gateway.SecurityEvents(r.Context(), scopedTenant(p, r.URL.Query().Get("tenant"))) + if err != nil { + mapError(r.Context(), w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"events": events}) +} + +func writeUserSaveError(w http.ResponseWriter, err error) { + if errors.Is(err, user.ErrConflict) { + writeError(w, http.StatusConflict, "email already exists") + return + } + writeError(w, http.StatusBadRequest, err.Error()) +} + +type configPayload struct { + YAML string `json:"yaml"` + ExpectedVersion int64 `json:"expected_version"` +} + +func configBody(w http.ResponseWriter, r *http.Request) (configPayload, bool) { + var body configPayload + if !decodeJSON(w, r, maxConfigBody, &body) { + return configPayload{}, false + } + if body.YAML == "" { + writeError(w, http.StatusBadRequest, "yaml is required") + return configPayload{}, false + } + return body, true +} diff --git a/control-plane/internal/httpapi/auth.go b/control-plane/internal/httpapi/auth.go new file mode 100644 index 0000000..970dfc9 --- /dev/null +++ b/control-plane/internal/httpapi/auth.go @@ -0,0 +1,96 @@ +package httpapi + +import ( + "errors" + "net/http" + "sync" + "time" + + "github.com/cocoonstack/gateway/control-plane/internal/auth" + "github.com/cocoonstack/gateway/control-plane/internal/kv" + "github.com/cocoonstack/gateway/control-plane/internal/user" +) + +// dummyHash keeps unknown-email logins as expensive as real ones so timing does not reveal which emails exist. +var dummyHash = sync.OnceValue(func() string { + hash, err := auth.HashPassword("control-plane-timing-decoy") + if err != nil { + return "" + } + return hash +}) + +func (s *Server) health(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "service": "gateway-control-plane"}) +} + +func (s *Server) login(w http.ResponseWriter, r *http.Request) { + var body struct { + Email string `json:"email"` + Password string `json:"password"` + } + if !decodeJSON(w, r, maxJSONBody, &body) { + return + } + throttleKey := clientIP(r) + "|" + user.NormalizeEmail(body.Email) + if !s.throttle.allow(throttleKey, time.Now()) { + writeError(w, http.StatusTooManyRequests, "too many login attempts; retry later") + return + } + u, err := s.users.ByEmail(r.Context(), body.Email) + if err != nil { + auth.VerifyPassword(dummyHash(), body.Password) + writeError(w, http.StatusUnauthorized, "invalid email or password") + return + } + if !auth.VerifyPassword(u.PasswordHash, body.Password) || u.Disabled { + writeError(w, http.StatusUnauthorized, "invalid email or password") + return + } + s.throttle.reset(throttleKey) + sessionID, err := auth.RandomToken(32) + if err != nil { + mapError(r.Context(), w, err) + return + } + csrf, err := auth.RandomToken(24) + if err != nil { + mapError(r.Context(), w, err) + return + } + now := time.Now() + session := kv.Session{ + ID: sessionID, UserID: u.ID, CSRFToken: csrf, + IssuedAt: now.Unix(), ExpiresAt: now.Add(s.sessionTTL).Unix(), + } + if err := s.sessions.Put(r.Context(), session); err != nil { + mapError(r.Context(), w, err) + return + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, Value: session.ID, Path: "/", HttpOnly: true, + Secure: s.cookieSecure, SameSite: http.SameSiteLaxMode, + MaxAge: int(s.sessionTTL.Seconds()), Expires: time.Unix(session.ExpiresAt, 0), + }) + writeJSON(w, http.StatusOK, map[string]any{"user": publicUser(u), "csrf_token": csrf}) +} + +func (s *Server) logout(w http.ResponseWriter, r *http.Request) { + p := current(r) + if err := s.sessions.Delete(r.Context(), p.Session.ID); err != nil && !errors.Is(err, kv.ErrNotFound) { + mapError(r.Context(), w, err) + return + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, Path: "/", HttpOnly: true, Secure: s.cookieSecure, + SameSite: http.SameSiteLaxMode, MaxAge: -1, Expires: time.Unix(1, 0), + }) + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) session(w http.ResponseWriter, r *http.Request) { + p := current(r) + writeJSON(w, http.StatusOK, map[string]any{ + "user": publicUser(p.User), "csrf_token": p.Session.CSRFToken, + }) +} diff --git a/control-plane/internal/httpapi/data.go b/control-plane/internal/httpapi/data.go new file mode 100644 index 0000000..c78236b --- /dev/null +++ b/control-plane/internal/httpapi/data.go @@ -0,0 +1,120 @@ +package httpapi + +import ( + "net/http" + + "golang.org/x/sync/errgroup" + + "github.com/cocoonstack/gateway/control-plane/internal/gateway" + "github.com/cocoonstack/gateway/control-plane/internal/user" +) + +func (s *Server) overview(w http.ResponseWriter, r *http.Request) { + since, until, bucket, err := period(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + p := current(r) + scope := scopeFor(p.User) + var ( + usage []gateway.UsageRow + series gateway.Series + models []gateway.ModelStatus + ) + g, ctx := errgroup.WithContext(r.Context()) + g.Go(func() (err error) { + usage, err = s.gateway.Usage(ctx, scope, since, until) + return err + }) + g.Go(func() (err error) { + series, err = s.gateway.UsageSeries(ctx, scope, bucket, since, until) + return err + }) + g.Go(func() (err error) { + models, err = s.gateway.Models(ctx, scope) + return err + }) + if err := g.Wait(); err != nil { + mapError(r.Context(), w, err) + return + } + stripVendorCost(p.User.Role, usage, series.Points) + var totals struct { + Requests int64 `json:"requests"` + TotalTokens int64 `json:"total_tokens"` + CostMicros int64 `json:"cost_micros"` + VendorCostMicros int64 `json:"vendor_cost_micros,omitempty"` + } + for _, row := range usage { + totals.Requests += row.Requests + totals.TotalTokens += row.TotalTokens + totals.CostMicros += row.CostMicros + totals.VendorCostMicros += row.VendorCostMicros + } + writeJSON(w, http.StatusOK, map[string]any{ + "totals": totals, "usage": usage, "series": series, "models": models, + }) +} + +func (s *Server) usage(w http.ResponseWriter, r *http.Request) { + since, until, _, err := period(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + p := current(r) + rows, err := s.gateway.Usage(r.Context(), scopeFor(p.User), since, until) + if err != nil { + mapError(r.Context(), w, err) + return + } + stripVendorCost(p.User.Role, rows, nil) + writeJSON(w, http.StatusOK, map[string]any{"usage": rows, "since": since, "until": until}) +} + +func (s *Server) usageSeries(w http.ResponseWriter, r *http.Request) { + since, until, bucket, err := period(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + p := current(r) + series, err := s.gateway.UsageSeries(r.Context(), scopeFor(p.User), bucket, since, until) + if err != nil { + mapError(r.Context(), w, err) + return + } + stripVendorCost(p.User.Role, nil, series.Points) + writeJSON(w, http.StatusOK, series) +} + +func (s *Server) models(w http.ResponseWriter, r *http.Request) { + models, err := s.gateway.Models(r.Context(), scopeFor(current(r).User)) + if err != nil { + mapError(r.Context(), w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"models": models}) +} + +func (s *Server) instances(w http.ResponseWriter, r *http.Request) { + instances, err := s.gateway.Instances(r.Context()) + if err != nil { + mapError(r.Context(), w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"instances": instances}) +} + +func stripVendorCost(role user.Role, usage []gateway.UsageRow, points []gateway.SeriesPoint) { + if role == user.RoleSystemAdmin { + return + } + for idx := range usage { + usage[idx].VendorCostMicros = 0 + } + for idx := range points { + points[idx].VendorCostMicros = 0 + } +} diff --git a/control-plane/internal/httpapi/openapi_test.go b/control-plane/internal/httpapi/openapi_test.go new file mode 100644 index 0000000..2a85283 --- /dev/null +++ b/control-plane/internal/httpapi/openapi_test.go @@ -0,0 +1,62 @@ +package httpapi + +import ( + "os" + "regexp" + "strings" + "testing" +) + +var ( + routePattern = regexp.MustCompile(`"(GET|POST|PUT|PATCH|DELETE) /api/v1(/[^"]*)"`) + methodPattern = regexp.MustCompile(`^ (get|post|put|patch|delete):`) +) + +func TestRoutesMatchOpenAPISpec(t *testing.T) { + src, err := os.ReadFile("server.go") + if err != nil { + t.Fatalf("read server.go: %v", err) + } + served := make(map[string]bool) + for _, m := range routePattern.FindAllStringSubmatch(string(src), -1) { + served[strings.ToLower(m[1])+" "+m[2]] = true + } + if len(served) == 0 { + t.Fatal("no routes found in server.go") + } + + spec, err := os.ReadFile("../../api/openapi.yaml") + if err != nil { + t.Fatalf("read openapi.yaml: %v", err) + } + declared := make(map[string]bool) + inPaths := false + path := "" + for line := range strings.SplitSeq(string(spec), "\n") { + switch { + case line == "paths:": + inPaths = true + case inPaths && line != "" && !strings.HasPrefix(line, " "): + inPaths = false + case inPaths && strings.HasPrefix(line, " /"): + path = strings.TrimSuffix(strings.TrimSpace(line), ":") + case inPaths && methodPattern.MatchString(line): + method := strings.TrimSuffix(strings.TrimSpace(line), ":") + declared[method+" "+path] = true + } + } + if len(declared) == 0 { + t.Fatal("no paths found in openapi.yaml") + } + + for route := range served { + if !declared[route] { + t.Errorf("route %q served but missing from api/openapi.yaml", route) + } + } + for route := range declared { + if !served[route] { + t.Errorf("path %q declared in api/openapi.yaml but not served", route) + } + } +} diff --git a/control-plane/internal/httpapi/server.go b/control-plane/internal/httpapi/server.go new file mode 100644 index 0000000..cb3a70e --- /dev/null +++ b/control-plane/internal/httpapi/server.go @@ -0,0 +1,320 @@ +// Package httpapi serves the browser-facing control-plane API and static UI. +package httpapi + +import ( + "cmp" + "context" + "crypto/subtle" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/projecteru2/core/log" + + "github.com/cocoonstack/gateway/control-plane/internal/auth" + "github.com/cocoonstack/gateway/control-plane/internal/gateway" + "github.com/cocoonstack/gateway/control-plane/internal/kv" + "github.com/cocoonstack/gateway/control-plane/internal/user" +) + +const ( + sessionCookie = "cp_session" + maxJSONBody = 1 << 20 + maxConfigBody = 4 << 20 +) + +type principal struct { + User user.User + Session kv.Session +} + +type principalKey struct{} + +type Server struct { + users user.Store + sessions kv.Sessions + gateway gateway.Client + sessionTTL time.Duration + cookieSecure bool + webDir string + throttle *loginThrottle +} + +func New( + users user.Store, + sessions kv.Sessions, + gw gateway.Client, + sessionTTL time.Duration, + cookieSecure bool, + webDir string, +) *Server { + return &Server{ + users: users, + sessions: sessions, + gateway: gw, + sessionTTL: sessionTTL, + cookieSecure: cookieSecure, + webDir: webDir, + throttle: newLoginThrottle(), + } +} + +// Routes are mirrored in api/openapi.yaml — update both together. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/health", s.health) + mux.HandleFunc("POST /api/v1/auth/login", s.login) + mux.Handle("POST /api/v1/auth/logout", s.requireAuth(http.HandlerFunc(s.logout))) + mux.Handle("GET /api/v1/session", s.requireAuth(http.HandlerFunc(s.session))) + mux.Handle("GET /api/v1/overview", s.requireAuth(http.HandlerFunc(s.overview))) + mux.Handle("GET /api/v1/usage", s.requireAuth(http.HandlerFunc(s.usage))) + mux.Handle("GET /api/v1/usage/series", s.requireAuth(http.HandlerFunc(s.usageSeries))) + mux.Handle("GET /api/v1/models/status", s.requireAuth(http.HandlerFunc(s.models))) + mux.Handle("GET /api/v1/admin/instances", s.requireSystem(http.HandlerFunc(s.instances))) + mux.Handle("GET /api/v1/admin/users", s.requireSystem(http.HandlerFunc(s.listUsers))) + mux.Handle("POST /api/v1/admin/users", s.requireSystem(http.HandlerFunc(s.createUser))) + mux.Handle("PATCH /api/v1/admin/users/{id}", s.requireSystem(http.HandlerFunc(s.patchUser))) + mux.Handle("GET /api/v1/admin/keys", s.requireAdmin(http.HandlerFunc(s.listKeys))) + mux.Handle("POST /api/v1/admin/keys", s.requireAdmin(http.HandlerFunc(s.createKey))) + mux.Handle("PATCH /api/v1/admin/keys/{ak}", s.requireAdmin(http.HandlerFunc(s.patchKey))) + mux.Handle("DELETE /api/v1/admin/keys/{ak}", s.requireAdmin(http.HandlerFunc(s.deleteKey))) + mux.Handle("GET /api/v1/admin/config", s.requireSystem(http.HandlerFunc(s.getConfig))) + mux.Handle("POST /api/v1/admin/config/validate", s.requireSystem(http.HandlerFunc(s.validateConfig))) + mux.Handle("PUT /api/v1/admin/config", s.requireSystem(http.HandlerFunc(s.publishConfig))) + mux.Handle("GET /api/v1/admin/config/versions", s.requireSystem(http.HandlerFunc(s.configVersions))) + mux.Handle("POST /api/v1/admin/config/versions/{id}/rollback", s.requireSystem(http.HandlerFunc(s.rollbackConfig))) + mux.Handle("GET /api/v1/admin/audit", s.requireAdmin(http.HandlerFunc(s.audit))) + mux.HandleFunc("/", s.serveWeb) + return requestID(s.accessLog(mux)) +} + +func (s *Server) requireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(sessionCookie) + if err != nil || cookie.Value == "" { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + session, err := s.sessions.Get(r.Context(), cookie.Value) + if err != nil { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + u, err := s.users.ByID(r.Context(), session.UserID) + if err != nil || u.Disabled || u.PasswordChangedAt > 0 && session.IssuedAt <= u.PasswordChangedAt { + _ = s.sessions.Delete(r.Context(), session.ID) + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + if mutating(r.Method) && + subtle.ConstantTimeCompare([]byte(r.Header.Get("X-CSRF-Token")), []byte(session.CSRFToken)) != 1 { + writeError(w, http.StatusForbidden, "invalid CSRF token") + return + } + ctx := context.WithValue(r.Context(), principalKey{}, principal{User: u, Session: session}) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func (s *Server) requireAdmin(next http.Handler) http.Handler { + return s.requireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if role := current(r).User.Role; role != user.RoleTenantAdmin && role != user.RoleSystemAdmin { + writeError(w, http.StatusForbidden, "admin role required") + return + } + next.ServeHTTP(w, r) + })) +} + +func (s *Server) requireSystem(next http.Handler) http.Handler { + return s.requireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if current(r).User.Role != user.RoleSystemAdmin { + writeError(w, http.StatusForbidden, "system admin role required") + return + } + next.ServeHTTP(w, r) + })) +} + +func (s *Server) accessLog(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + started := time.Now() + wrapped := &statusWriter{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(wrapped, r) + log.WithFunc("httpapi.access").Infof( + r.Context(), + "%s %s status=%d duration_ms=%d rid=%s", + r.Method, + r.URL.Path, + wrapped.status, + time.Since(started).Milliseconds(), + gateway.RequestIDFrom(r.Context()), + ) + }) +} + +func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/") { + writeError(w, http.StatusNotFound, "not found") + return + } + path := filepath.Join(s.webDir, filepath.Clean("/"+r.URL.Path)) + if info, err := os.Stat(path); err == nil && !info.IsDir() { + http.ServeFile(w, r, path) + return + } + index := filepath.Join(s.webDir, "index.html") + if _, err := os.Stat(index); err != nil { + writeError(w, http.StatusNotFound, "web assets are not built") + return + } + http.ServeFile(w, r, index) +} + +type statusWriter struct { + http.ResponseWriter + status int +} + +func (w *statusWriter) WriteHeader(status int) { + w.status = status + w.ResponseWriter.WriteHeader(status) +} + +func current(r *http.Request) principal { + value, _ := r.Context().Value(principalKey{}).(principal) + return value +} + +func scopeFor(u user.User) gateway.Scope { + switch u.Role { + case user.RoleSystemAdmin: + return gateway.Scope{} + case user.RoleTenantAdmin: + return gateway.Scope{Tenant: u.Tenant} + default: + return gateway.Scope{Tenant: u.Tenant, User: cmp.Or(u.GatewayUserID, u.ID)} + } +} + +func scopedTenant(p principal, requested string) string { + if p.User.Role == user.RoleTenantAdmin { + return p.User.Tenant + } + return requested +} + +// actingTenant is the scope key mutations act under: "" means the global operator. +func actingTenant(p principal) string { + if p.User.Role == user.RoleTenantAdmin { + return p.User.Tenant + } + return "" +} + +func period(r *http.Request) (int64, int64, string, error) { + now := time.Now().Unix() + since := queryInt(r, "since", now-29*86_400) + until := queryInt(r, "until", now) + bucket := r.URL.Query().Get("bucket") + if bucket == "" { + bucket = "day" + } + if since < 0 || until < since { + return 0, 0, "", fmt.Errorf("since/until must be a valid non-negative range") + } + if bucket != "hour" && bucket != "day" { + return 0, 0, "", fmt.Errorf("bucket must be hour or day") + } + return since, until, bucket, nil +} + +func queryInt(r *http.Request, name string, fallback int64) int64 { + value, err := strconv.ParseInt(r.URL.Query().Get(name), 10, 64) + if err != nil { + return fallback + } + return value +} + +func decodeJSON(w http.ResponseWriter, r *http.Request, limit int64, value any) bool { + decoder := json.NewDecoder(io.LimitReader(r.Body, limit)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(value); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body") + return false + } + return true +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + // the status line is already committed; an encode error is a gone client + _ = json.NewEncoder(w).Encode(value) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]any{"error": map[string]string{"message": message}}) +} + +func publicUser(u user.User) user.User { + u.PasswordHash = "" + return u +} + +func mutating(method string) bool { + return method != http.MethodGet && method != http.MethodHead && method != http.MethodOptions +} + +func requestID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rid := r.Header.Get("X-Request-ID") + if !validRequestID(rid) { + var err error + if rid, err = auth.RandomToken(9); err != nil { + rid = "untraced" + } + } + w.Header().Set("X-Request-ID", rid) + next.ServeHTTP(w, r.WithContext(gateway.WithRequestID(r.Context(), rid))) + }) +} + +func validRequestID(rid string) bool { + if rid == "" || len(rid) > 64 { + return false + } + return !strings.ContainsFunc(rid, func(r rune) bool { + return !('a' <= r && r <= 'z' || 'A' <= r && r <= 'Z' || '0' <= r && r <= '9' || r == '-' || r == '_' || r == '.') + }) +} + +// auditLog names the principal behind a mutating admin action; accessLog lines carry only the route. +func auditLog(r *http.Request, action, target string) { + p := current(r) + log.WithFunc("httpapi.audit").Infof( + r.Context(), + "actor=%s role=%s action=%s target=%s ip=%s rid=%s", + p.User.Email, p.User.Role, action, target, clientIP(r), gateway.RequestIDFrom(r.Context()), + ) +} + +func mapError(ctx context.Context, w http.ResponseWriter, err error) { + switch { + case errors.Is(err, user.ErrNotFound), errors.Is(err, gateway.ErrNotFound): + writeError(w, http.StatusNotFound, err.Error()) + case errors.Is(err, user.ErrConflict), errors.Is(err, gateway.ErrConflict): + writeError(w, http.StatusConflict, err.Error()) + default: + log.WithFunc("httpapi.mapError").Error(ctx, err, "request failed") + writeError(w, http.StatusBadGateway, err.Error()) + } +} diff --git a/control-plane/internal/httpapi/server_test.go b/control-plane/internal/httpapi/server_test.go new file mode 100644 index 0000000..c59fde6 --- /dev/null +++ b/control-plane/internal/httpapi/server_test.go @@ -0,0 +1,250 @@ +package httpapi + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/cocoonstack/gateway/control-plane/internal/auth" + "github.com/cocoonstack/gateway/control-plane/internal/gateway" + kvmemory "github.com/cocoonstack/gateway/control-plane/internal/kv/memory" + "github.com/cocoonstack/gateway/control-plane/internal/user" + usermemory "github.com/cocoonstack/gateway/control-plane/internal/user/memory" +) + +type loginState struct { + cookie *http.Cookie + csrf string +} + +func testServer(t *testing.T) http.Handler { + t.Helper() + store := usermemory.New() + for _, seed := range []struct { + id, email, tenant, gatewayUserID string + role user.Role + }{ + {"admin", "admin@example.com", "", "", user.RoleSystemAdmin}, + {"manager", "manager@example.com", "acme", "", user.RoleTenantAdmin}, + {"member", "user@example.com", "acme", "alice", user.RoleMember}, + } { + hash, err := auth.HashPassword("password123!") + if err != nil { + t.Fatalf("hash password: %v", err) + } + now := time.Now().Unix() + if err := store.Create(t.Context(), user.User{ + ID: seed.id, Email: seed.email, DisplayName: seed.id, PasswordHash: hash, + Tenant: seed.tenant, GatewayUserID: seed.gatewayUserID, Role: seed.role, + CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("seed user: %v", err) + } + } + return New(store, kvmemory.New(), gateway.NewMock(), time.Hour, false, t.TempDir()).Handler() +} + +func loginAs(t *testing.T, handler http.Handler, email string) loginState { + t.Helper() + body, _ := json.Marshal(map[string]string{"email": email, "password": "password123!"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String()) + } + var response struct { + CSRF string `json:"csrf_token"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("decode login: %v", err) + } + return loginState{cookie: rec.Result().Cookies()[0], csrf: response.CSRF} +} + +func request(t *testing.T, handler http.Handler, state loginState, method, path string, body any, csrf bool) *httptest.ResponseRecorder { + t.Helper() + var payload bytes.Buffer + if body != nil { + if err := json.NewEncoder(&payload).Encode(body); err != nil { + t.Fatalf("encode request: %v", err) + } + } + req := httptest.NewRequest(method, path, &payload) + req.AddCookie(state.cookie) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if csrf { + req.Header.Set("X-CSRF-Token", state.csrf) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec +} + +func TestMemberIsScopedAndCannotReadAdminSurfaces(t *testing.T) { + handler := testServer(t) + member := loginAs(t, handler, "user@example.com") + + rec := request(t, handler, member, http.MethodGet, "/api/v1/usage?user=bob", nil, false) + if rec.Code != http.StatusOK { + t.Fatalf("usage status = %d, body = %s", rec.Code, rec.Body.String()) + } + var usage struct { + Rows []gateway.UsageRow `json:"usage"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &usage); err != nil { + t.Fatalf("decode usage: %v", err) + } + for _, row := range usage.Rows { + if row.UserID != "alice" { + t.Errorf("row user = %q, want session user alice", row.UserID) + } + if row.VendorCostMicros != 0 { + t.Errorf("member vendor cost = %d, want hidden", row.VendorCostMicros) + } + } + + rec = request(t, handler, member, http.MethodGet, "/api/v1/admin/instances", nil, false) + if rec.Code != http.StatusForbidden { + t.Fatalf("instances status = %d, want 403", rec.Code) + } +} + +func TestSystemAdminConfigAndInstances(t *testing.T) { + handler := testServer(t) + admin := loginAs(t, handler, "admin@example.com") + + rec := request(t, handler, admin, http.MethodGet, "/api/v1/admin/instances", nil, false) + if rec.Code != http.StatusOK || !bytes.Contains(rec.Body.Bytes(), []byte("gw-a")) { + t.Fatalf("instances status = %d, body = %s", rec.Code, rec.Body.String()) + } + + body := map[string]string{"yaml": "listen: {host: h, port: 1}\nmodels: []\n"} + rec = request(t, handler, admin, http.MethodPost, "/api/v1/admin/config/validate", body, false) + if rec.Code != http.StatusForbidden { + t.Fatalf("missing csrf status = %d, want 403", rec.Code) + } + rec = request(t, handler, admin, http.MethodPost, "/api/v1/admin/config/validate", body, true) + if rec.Code != http.StatusOK { + t.Fatalf("validate status = %d, body = %s", rec.Code, rec.Body.String()) + } +} + +func TestTenantAdminCannotMutateAnotherTenantKey(t *testing.T) { + handler := testServer(t) + manager := loginAs(t, handler, "manager@example.com") + rec := request( + t, + handler, + manager, + http.MethodPatch, + "/api/v1/admin/keys/ak-labs-paused", + map[string]any{"banned": false}, + true, + ) + if rec.Code != http.StatusNotFound { + t.Fatalf("cross-tenant patch status = %d, want 404; body = %s", rec.Code, rec.Body.String()) + } +} + +func TestTenantAdminCannotHijackForeignKeyViaCreate(t *testing.T) { + handler := testServer(t) + manager := loginAs(t, handler, "manager@example.com") + rec := request( + t, + handler, + manager, + http.MethodPost, + "/api/v1/admin/keys", + map[string]any{"ak": "ak-labs-paused", "product": "standard"}, + true, + ) + if rec.Code != http.StatusNotFound { + t.Fatalf("foreign-ak create status = %d, want 404 like the gateway's scoped_key; body = %s", rec.Code, rec.Body.String()) + } + rec = request( + t, + handler, + manager, + http.MethodPost, + "/api/v1/admin/keys", + map[string]any{"ak": "ak-acme-new", "product": "standard"}, + true, + ) + if rec.Code != http.StatusCreated { + t.Fatalf("own-tenant create status = %d, want 201; body = %s", rec.Code, rec.Body.String()) + } +} + +func TestLoginThrottleLocksAfterRepeatedFailures(t *testing.T) { + handler := testServer(t) + body, _ := json.Marshal(map[string]string{"email": "admin@example.com", "password": "wrong-password"}) + var last int + for range loginMaxAttempts + 1 { + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + last = rec.Code + } + if last != http.StatusTooManyRequests { + t.Fatalf("attempt %d status = %d, want 429", loginMaxAttempts+1, last) + } +} + +func TestRequestIDEchoedOrGenerated(t *testing.T) { + handler := testServer(t) + req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) + req.Header.Set("X-Request-ID", "trace-abc.123") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if got := rec.Header().Get("X-Request-ID"); got != "trace-abc.123" { + t.Errorf("request id = %q, want caller value echoed", got) + } + + for _, inbound := range []string{"", "bad id\nwith newline"} { + req = httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) + if inbound != "" { + req.Header.Set("X-Request-ID", inbound) + } + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + got := rec.Header().Get("X-Request-ID") + if got == "" || got == inbound { + t.Errorf("request id = %q for inbound %q, want a fresh generated id", got, inbound) + } + } +} + +func TestPasswordResetEvictsExistingSessions(t *testing.T) { + handler := testServer(t) + admin := loginAs(t, handler, "admin@example.com") + member := loginAs(t, handler, "user@example.com") + + rec := request(t, handler, member, http.MethodGet, "/api/v1/session", nil, false) + if rec.Code != http.StatusOK { + t.Fatalf("pre-reset session status = %d, want 200", rec.Code) + } + rec = request( + t, + handler, + admin, + http.MethodPatch, + "/api/v1/admin/users/member", + map[string]any{"password": "brand-new-pass-1!"}, + true, + ) + if rec.Code != http.StatusOK { + t.Fatalf("password reset status = %d, body = %s", rec.Code, rec.Body.String()) + } + rec = request(t, handler, member, http.MethodGet, "/api/v1/session", nil, false) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("post-reset session status = %d, want 401", rec.Code) + } +} diff --git a/control-plane/internal/httpapi/throttle.go b/control-plane/internal/httpapi/throttle.go new file mode 100644 index 0000000..14e1aaf --- /dev/null +++ b/control-plane/internal/httpapi/throttle.go @@ -0,0 +1,61 @@ +package httpapi + +import ( + "net" + "net/http" + "sync" + "time" +) + +const ( + loginWindow = 5 * time.Minute + loginMaxAttempts = 10 + throttleSweepAt = 1024 +) + +type window struct { + start time.Time + attempts int +} + +type loginThrottle struct { + mu sync.Mutex + windows map[string]window +} + +func newLoginThrottle() *loginThrottle { + return &loginThrottle{windows: make(map[string]window)} +} + +func (t *loginThrottle) allow(key string, now time.Time) bool { + t.mu.Lock() + defer t.mu.Unlock() + if len(t.windows) > throttleSweepAt { + for k, w := range t.windows { + if now.Sub(w.start) >= loginWindow { + delete(t.windows, k) + } + } + } + w := t.windows[key] + if now.Sub(w.start) >= loginWindow { + w = window{start: now} + } + w.attempts++ + t.windows[key] = w + return w.attempts <= loginMaxAttempts +} + +func (t *loginThrottle) reset(key string) { + t.mu.Lock() + defer t.mu.Unlock() + delete(t.windows, key) +} + +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} diff --git a/control-plane/internal/integration/backend_test.go b/control-plane/internal/integration/backend_test.go new file mode 100644 index 0000000..ece8630 --- /dev/null +++ b/control-plane/internal/integration/backend_test.go @@ -0,0 +1,72 @@ +//go:build integration + +package integration_test + +import ( + "context" + "errors" + "fmt" + "os" + "testing" + "time" + + "github.com/cocoonstack/gateway/control-plane/internal/kv" + kvredis "github.com/cocoonstack/gateway/control-plane/internal/kv/redis" + "github.com/cocoonstack/gateway/control-plane/internal/user" + userpostgres "github.com/cocoonstack/gateway/control-plane/internal/user/postgres" +) + +func TestPostgresIdentityAndRedisSession(t *testing.T) { + pgURL := os.Getenv("CP_TEST_PG_URL") + redisURL := os.Getenv("CP_TEST_REDIS_URL") + if pgURL == "" || redisURL == "" { + t.Skip("CP_TEST_PG_URL and CP_TEST_REDIS_URL are required") + } + + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + + users, err := userpostgres.Connect(ctx, pgURL) + if err != nil { + t.Fatalf("connect postgres: %v", err) + } + defer users.Close() + sessions, err := kvredis.Connect(ctx, redisURL) + if err != nil { + t.Fatalf("connect redis: %v", err) + } + defer sessions.Close() + + suffix := time.Now().UnixNano() + u := user.User{ + ID: fmt.Sprintf("integration-%d", suffix), Email: fmt.Sprintf("integration-%d@example.com", suffix), + DisplayName: "Integration User", PasswordHash: "test-hash", Tenant: "integration", + GatewayUserID: "integration-user", Role: user.RoleMember, + CreatedAt: time.Now().Unix(), UpdatedAt: time.Now().Unix(), + } + if err := users.Create(ctx, u); err != nil { + t.Fatalf("create user: %v", err) + } + loaded, err := users.ByEmail(ctx, u.Email) + if err != nil || loaded.ID != u.ID { + t.Fatalf("read user: got=%+v err=%v", loaded, err) + } + + session := kv.Session{ + ID: fmt.Sprintf("session-%d", suffix), UserID: u.ID, CSRFToken: "csrf-integration", + ExpiresAt: time.Now().Add(time.Minute).Unix(), + } + if err := sessions.Put(ctx, session); err != nil { + t.Fatalf("put session: %v", err) + } + loadedSession, err := sessions.Get(ctx, session.ID) + if err != nil || loadedSession != session { + t.Fatalf("read session: got=%+v err=%v", loadedSession, err) + } + if err := sessions.Delete(ctx, session.ID); err != nil { + t.Fatalf("delete session: %v", err) + } + if _, err := sessions.Get(ctx, session.ID); !errors.Is(err, kv.ErrNotFound) { + t.Fatalf("deleted session error = %v, want %v", err, kv.ErrNotFound) + } +} diff --git a/control-plane/internal/integration/gateway_tenant_e2e_test.go b/control-plane/internal/integration/gateway_tenant_e2e_test.go new file mode 100644 index 0000000..47c55bb --- /dev/null +++ b/control-plane/internal/integration/gateway_tenant_e2e_test.go @@ -0,0 +1,342 @@ +//go:build integration + +package integration_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/cocoonstack/gateway/control-plane/internal/auth" + "github.com/cocoonstack/gateway/control-plane/internal/gateway" + "github.com/cocoonstack/gateway/control-plane/internal/httpapi" + kvmemory "github.com/cocoonstack/gateway/control-plane/internal/kv/memory" + "github.com/cocoonstack/gateway/control-plane/internal/user" + usermemory "github.com/cocoonstack/gateway/control-plane/internal/user/memory" +) + +const gatewayConfTemplate = `listen: {host: 127.0.0.1, port: %d} +admin: {token_env: GW_E2E_GLOBAL_TOKEN} +storage: + postgres_url: "%s" + redis_url: "%s" +models: + - name: m1 + protocol: openai-chat + input_price_per_1k_micros: 5000 + output_price_per_1k_micros: 5000 +accounts: + - name: acct-mock + provider: openai + protocols: [openai-chat] + cost_input_price_per_1k_micros: 1000 + cost_output_price_per_1k_micros: 1000 +tenants: + - name: acme + admin_token_env: GW_E2E_ACME_TOKEN + models: [m1] + - name: labs + models: [m1] +access_keys: [] +` + +func TestTenantTokenChain(t *testing.T) { + gwBin := os.Getenv("CP_TEST_GW_BIN") + pgURL := os.Getenv("CP_TEST_PG_URL") + redisURL := os.Getenv("CP_TEST_REDIS_URL") + if gwBin == "" || pgURL == "" || redisURL == "" { + t.Skip("CP_TEST_GW_BIN, CP_TEST_PG_URL and CP_TEST_REDIS_URL are required") + } + gwURL := startGateway(t, gwBin, provisionDatabase(t, pgURL), redisURL) + cp := startControlPlane(t, gwURL) + suffix := strconv.FormatInt(time.Now().UnixNano(), 10) + acmeKey, labsKey := "ak-e2e-acme-"+suffix, "ak-e2e-labs-"+suffix + + root := login(t, cp, "root@example.com") + acme := login(t, cp, "acme@example.com") + labs := login(t, cp, "labs@example.com") + + rec := send(t, cp, root, http.MethodPost, "/api/v1/admin/keys", + map[string]any{"ak": labsKey, "product": "p", "tenant": "labs", "qps": 5, "daily_token_quota": 1_000_000}) + if rec.StatusCode != http.StatusCreated { + t.Fatalf("system admin create labs key = %d, body %s", rec.StatusCode, rec.Body) + } + rec = send(t, cp, acme, http.MethodPost, "/api/v1/admin/keys", + map[string]any{"ak": acmeKey, "product": "p", "qps": 5, "daily_token_quota": 1_000_000}) + if rec.StatusCode != http.StatusCreated { + t.Fatalf("tenant admin create own key = %d, body %s", rec.StatusCode, rec.Body) + } + + rec = send(t, cp, acme, http.MethodGet, "/api/v1/admin/keys", nil) + if rec.StatusCode != http.StatusOK || !strings.Contains(rec.Body, acmeKey) || strings.Contains(rec.Body, labsKey) { + t.Fatalf("tenant admin list = %d, want only own tenant's keys; body %s", rec.StatusCode, rec.Body) + } + + rec = send(t, cp, acme, http.MethodPatch, "/api/v1/admin/keys/"+labsKey, map[string]any{"banned": true}) + if rec.StatusCode != http.StatusNotFound { + t.Fatalf("cross-tenant patch = %d, want 404 from the gateway; body %s", rec.StatusCode, rec.Body) + } + rec = send(t, cp, acme, http.MethodPost, "/api/v1/admin/keys", + map[string]any{"ak": labsKey, "product": "p", "qps": 5, "daily_token_quota": 1_000_000}) + if rec.StatusCode != http.StatusNotFound { + t.Fatalf("cross-tenant create takeover = %d, want 404 from the gateway; body %s", rec.StatusCode, rec.Body) + } + + rec = send(t, cp, labs, http.MethodPatch, "/api/v1/admin/keys/"+labsKey, map[string]any{"banned": true}) + if rec.StatusCode != http.StatusBadGateway || !strings.Contains(rec.Body, "no gateway admin token") { + t.Fatalf("mutation without tenant token = %d, want fail-closed 502; body %s", rec.StatusCode, rec.Body) + } + + gwGet := func(path, token string) (int, string) { + req, err := http.NewRequest(http.MethodGet, gwURL+path, nil) + if err != nil { + t.Fatalf("build gateway request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("call gateway %s: %v", path, err) + } + defer resp.Body.Close() + var body bytes.Buffer + _, _ = body.ReadFrom(resp.Body) + return resp.StatusCode, body.String() + } + + if _, body := gwGet("/admin/keys?ak="+labsKey, "acme-e2e-token"); !strings.Contains(body, `"count":0`) { + t.Fatalf("ak filter under tenant token leaked a foreign key: %s", body) + } + if _, body := gwGet("/admin/keys?ak="+labsKey, "root-e2e-token"); !strings.Contains(body, `"count":1`) { + t.Fatalf("ak filter under global token missed the key: %s", body) + } + + chat, err := json.Marshal(map[string]any{ + "model": "m1", "messages": []map[string]string{{"role": "user", "content": "hello e2e"}}, + }) + if err != nil { + t.Fatalf("encode chat: %v", err) + } + req, err := http.NewRequest(http.MethodPost, gwURL+"/v1/chat/completions", bytes.NewReader(chat)) + if err != nil { + t.Fatalf("build chat request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+acmeKey) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("chat through gateway: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("chat status = %d", resp.StatusCode) + } + + type usageRow struct { + CostMicros int64 `json:"cost_micros"` + VendorCostMicros int64 `json:"vendor_cost_micros"` + } + decodeUsage := func(token string) []usageRow { + _, body := gwGet("/admin/usage/users?since=0&until="+strconv.FormatInt(time.Now().Unix()+60, 10), token) + var view struct { + Usage []usageRow `json:"usage"` + } + if err := json.Unmarshal([]byte(body), &view); err != nil { + t.Fatalf("decode usage view: %v; body %s", err, body) + } + return view.Usage + } + deadline := time.Now().Add(10 * time.Second) + for { + tenantView := decodeUsage("acme-e2e-token") + if slices.ContainsFunc(tenantView, func(r usageRow) bool { return r.CostMicros > 0 }) { + for _, row := range tenantView { + if row.VendorCostMicros != 0 { + t.Fatalf("tenant token saw vendor cost %d", row.VendorCostMicros) + } + } + globalView := decodeUsage("root-e2e-token") + if !slices.ContainsFunc(globalView, func(r usageRow) bool { return r.VendorCostMicros > 0 }) { + t.Fatal("global token saw no vendor cost; margin basis lost") + } + break + } + if time.Now().After(deadline) { + t.Fatal("billing row never appeared in gateway usage") + } + time.Sleep(300 * time.Millisecond) + } +} + +func provisionDatabase(t *testing.T, baseURL string) string { + t.Helper() + parsed, err := url.Parse(baseURL) + if err != nil { + t.Fatalf("parse postgres url: %v", err) + } + conn, err := pgx.Connect(t.Context(), baseURL) + if err != nil { + t.Fatalf("connect postgres: %v", err) + } + name := fmt.Sprintf("cp_e2e_%d", time.Now().UnixNano()) + if _, err := conn.Exec(t.Context(), "CREATE DATABASE "+name); err != nil { + t.Fatalf("create e2e database: %v", err) + } + t.Cleanup(func() { + ctx := context.Background() + _, _ = conn.Exec(ctx, "DROP DATABASE "+name+" WITH (FORCE)") + _ = conn.Close(ctx) + }) + parsed.Path = "/" + name + return parsed.String() +} + +func startGateway(t *testing.T, bin, pgURL, redisURL string) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("pick gateway port: %v", err) + } + port := listener.Addr().(*net.TCPAddr).Port + _ = listener.Close() + conf := filepath.Join(t.TempDir(), "gateway.yaml") + if err := os.WriteFile(conf, fmt.Appendf(nil, gatewayConfTemplate, port, pgURL, redisURL), 0o600); err != nil { + t.Fatalf("write gateway config: %v", err) + } + cmd := exec.Command(bin) + cmd.Env = append(os.Environ(), + "GW_CONFIG="+conf, + "GW_TRANSPORT=mock", + "GW_E2E_GLOBAL_TOKEN=root-e2e-token", + "GW_E2E_ACME_TOKEN=acme-e2e-token", + ) + cmd.Stdout, cmd.Stderr = os.Stderr, os.Stderr + if err := cmd.Start(); err != nil { + t.Fatalf("start gateway: %v", err) + } + t.Cleanup(func() { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + }) + url := fmt.Sprintf("http://127.0.0.1:%d", port) + deadline := time.Now().Add(30 * time.Second) + for { + resp, err := http.Get(url + "/health") + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return url + } + } + if time.Now().After(deadline) { + t.Fatalf("gateway never became healthy: %v", err) + } + time.Sleep(300 * time.Millisecond) + } +} + +func startControlPlane(t *testing.T, gwURL string) *httptest.Server { + t.Helper() + client, err := gateway.NewHTTP("gw="+gwURL, "root-e2e-token", map[string]string{"acme": "acme-e2e-token"}) + if err != nil { + t.Fatalf("build gateway client: %v", err) + } + store := usermemory.New() + for _, seed := range []struct { + id, email, tenant string + role user.Role + }{ + {"root", "root@example.com", "", user.RoleSystemAdmin}, + {"acme", "acme@example.com", "acme", user.RoleTenantAdmin}, + {"labs", "labs@example.com", "labs", user.RoleTenantAdmin}, + } { + hash, err := auth.HashPassword("password123!") + if err != nil { + t.Fatalf("hash password: %v", err) + } + now := time.Now().Unix() + if err := store.Create(t.Context(), user.User{ + ID: seed.id, Email: seed.email, DisplayName: seed.id, PasswordHash: hash, + Tenant: seed.tenant, Role: seed.role, CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("seed user: %v", err) + } + } + server := httptest.NewServer(httpapi.New(store, kvmemory.New(), client, time.Hour, false, t.TempDir()).Handler()) + t.Cleanup(server.Close) + return server +} + +type browserSession struct { + cookie *http.Cookie + csrf string +} + +func login(t *testing.T, cp *httptest.Server, email string) browserSession { + t.Helper() + body, err := json.Marshal(map[string]string{"email": email, "password": "password123!"}) + if err != nil { + t.Fatalf("encode login: %v", err) + } + resp, err := http.Post(cp.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("login %s: %v", email, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("login %s status = %d", email, resp.StatusCode) + } + var payload struct { + CSRF string `json:"csrf_token"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + t.Fatalf("decode login: %v", err) + } + return browserSession{cookie: resp.Cookies()[0], csrf: payload.CSRF} +} + +type wireResponse struct { + StatusCode int + Body string +} + +func send(t *testing.T, cp *httptest.Server, session browserSession, method, path string, body any) wireResponse { + t.Helper() + var payload bytes.Buffer + if body != nil { + if err := json.NewEncoder(&payload).Encode(body); err != nil { + t.Fatalf("encode request: %v", err) + } + } + req, err := http.NewRequest(method, cp.URL+path, &payload) + if err != nil { + t.Fatalf("build request: %v", err) + } + req.AddCookie(session.cookie) + req.Header.Set("X-CSRF-Token", session.csrf) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + defer resp.Body.Close() + var out bytes.Buffer + _, _ = out.ReadFrom(resp.Body) + return wireResponse{StatusCode: resp.StatusCode, Body: out.String()} +} diff --git a/control-plane/internal/kv/memory/session.go b/control-plane/internal/kv/memory/session.go new file mode 100644 index 0000000..4ff948c --- /dev/null +++ b/control-plane/internal/kv/memory/session.go @@ -0,0 +1,53 @@ +// Package memory provides the dependency-free development session store. +package memory + +import ( + "context" + "sync" + "time" + + "github.com/cocoonstack/gateway/control-plane/internal/kv" +) + +var _ kv.Sessions = (*Sessions)(nil) + +type Sessions struct { + mu sync.RWMutex + sessions map[string]kv.Session +} + +func New() *Sessions { + return &Sessions{sessions: make(map[string]kv.Session)} +} + +func (s *Sessions) Put(_ context.Context, session kv.Session) error { + s.mu.Lock() + defer s.mu.Unlock() + s.sessions[session.ID] = session + return nil +} + +func (s *Sessions) Get(_ context.Context, id string) (kv.Session, error) { + s.mu.RLock() + session, ok := s.sessions[id] + s.mu.RUnlock() + if !ok { + return kv.Session{}, kv.ErrNotFound + } + if session.ExpiresAt <= time.Now().Unix() { + s.mu.Lock() + delete(s.sessions, id) + s.mu.Unlock() + return kv.Session{}, kv.ErrNotFound + } + return session, nil +} + +func (s *Sessions) Delete(_ context.Context, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.sessions, id) + return nil +} + +func (s *Sessions) Close() error { return nil } diff --git a/control-plane/internal/kv/redis/session.go b/control-plane/internal/kv/redis/session.go new file mode 100644 index 0000000..b4ab299 --- /dev/null +++ b/control-plane/internal/kv/redis/session.go @@ -0,0 +1,78 @@ +// Package redis provides fleet-shared control-plane sessions. +package redis + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + redisclient "github.com/redis/go-redis/v9" + + "github.com/cocoonstack/gateway/control-plane/internal/kv" +) + +const keyPrefix = "gateway:control-plane:session:" + +var _ kv.Sessions = (*Sessions)(nil) + +type Sessions struct { + client *redisclient.Client +} + +func Connect(ctx context.Context, rawURL string) (*Sessions, error) { + opts, err := redisclient.ParseURL(rawURL) + if err != nil { + return nil, fmt.Errorf("parse redis url: %w", err) + } + client := redisclient.NewClient(opts) + if err := client.Ping(ctx).Err(); err != nil { + _ = client.Close() + return nil, fmt.Errorf("ping redis: %w", err) + } + return &Sessions{client: client}, nil +} + +func (s *Sessions) Put(ctx context.Context, session kv.Session) error { + body, err := json.Marshal(session) + if err != nil { + return fmt.Errorf("encode session: %w", err) + } + ttl := time.Until(time.Unix(session.ExpiresAt, 0)) + if ttl <= 0 { + return kv.ErrNotFound + } + if err := s.client.Set(ctx, keyPrefix+session.ID, body, ttl).Err(); err != nil { + return fmt.Errorf("store session: %w", err) + } + return nil +} + +func (s *Sessions) Get(ctx context.Context, id string) (kv.Session, error) { + body, err := s.client.Get(ctx, keyPrefix+id).Bytes() + if errors.Is(err, redisclient.Nil) { + return kv.Session{}, kv.ErrNotFound + } + if err != nil { + return kv.Session{}, fmt.Errorf("read session: %w", err) + } + var session kv.Session + if err := json.Unmarshal(body, &session); err != nil { + return kv.Session{}, fmt.Errorf("decode session: %w", err) + } + if session.ExpiresAt <= time.Now().Unix() { + _ = s.Delete(ctx, id) + return kv.Session{}, kv.ErrNotFound + } + return session, nil +} + +func (s *Sessions) Delete(ctx context.Context, id string) error { + if err := s.client.Del(ctx, keyPrefix+id).Err(); err != nil { + return fmt.Errorf("delete session: %w", err) + } + return nil +} + +func (s *Sessions) Close() error { return s.client.Close() } diff --git a/control-plane/internal/kv/session.go b/control-plane/internal/kv/session.go new file mode 100644 index 0000000..d0349f8 --- /dev/null +++ b/control-plane/internal/kv/session.go @@ -0,0 +1,24 @@ +// Package kv defines the control-plane session seam. +package kv + +import ( + "context" + "errors" +) + +var ErrNotFound = errors.New("session not found") + +type Session struct { + ID string `json:"id"` + UserID string `json:"user_id"` + CSRFToken string `json:"csrf_token"` + IssuedAt int64 `json:"issued_at"` + ExpiresAt int64 `json:"expires_at"` +} + +type Sessions interface { + Put(context.Context, Session) error + Get(context.Context, string) (Session, error) + Delete(context.Context, string) error + Close() error +} diff --git a/control-plane/internal/user/memory/store.go b/control-plane/internal/user/memory/store.go new file mode 100644 index 0000000..cca09bf --- /dev/null +++ b/control-plane/internal/user/memory/store.go @@ -0,0 +1,95 @@ +// Package memory provides the dependency-free development identity store. +package memory + +import ( + "cmp" + "context" + "slices" + "sync" + + "github.com/cocoonstack/gateway/control-plane/internal/user" +) + +var _ user.Store = (*Store)(nil) + +type Store struct { + mu sync.RWMutex + byID map[string]user.User + byEmail map[string]string +} + +func New() *Store { + return &Store{ + byID: make(map[string]user.User), + byEmail: make(map[string]string), + } +} + +func (s *Store) Create(_ context.Context, u user.User) error { + if err := u.Validate(); err != nil { + return err + } + u.Email = user.NormalizeEmail(u.Email) + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.byID[u.ID]; ok { + return user.ErrConflict + } + if _, ok := s.byEmail[u.Email]; ok { + return user.ErrConflict + } + s.byID[u.ID] = u + s.byEmail[u.Email] = u.ID + return nil +} + +func (s *Store) ByID(_ context.Context, id string) (user.User, error) { + s.mu.RLock() + defer s.mu.RUnlock() + u, ok := s.byID[id] + if !ok { + return user.User{}, user.ErrNotFound + } + return u, nil +} + +func (s *Store) ByEmail(_ context.Context, email string) (user.User, error) { + s.mu.RLock() + defer s.mu.RUnlock() + id, ok := s.byEmail[user.NormalizeEmail(email)] + if !ok { + return user.User{}, user.ErrNotFound + } + return s.byID[id], nil +} + +func (s *Store) List(_ context.Context) ([]user.User, error) { + s.mu.RLock() + defer s.mu.RUnlock() + users := make([]user.User, 0, len(s.byID)) + for _, u := range s.byID { + users = append(users, u) + } + slices.SortFunc(users, func(a, b user.User) int { return cmp.Compare(a.Email, b.Email) }) + return users, nil +} + +func (s *Store) Update(_ context.Context, u user.User) error { + if err := u.Validate(); err != nil { + return err + } + u.Email = user.NormalizeEmail(u.Email) + s.mu.Lock() + defer s.mu.Unlock() + old, ok := s.byID[u.ID] + if !ok { + return user.ErrNotFound + } + if id, ok := s.byEmail[u.Email]; ok && id != u.ID { + return user.ErrConflict + } + delete(s.byEmail, old.Email) + s.byID[u.ID] = u + s.byEmail[u.Email] = u.ID + return nil +} diff --git a/control-plane/internal/user/memory/store_test.go b/control-plane/internal/user/memory/store_test.go new file mode 100644 index 0000000..422b9b7 --- /dev/null +++ b/control-plane/internal/user/memory/store_test.go @@ -0,0 +1,44 @@ +package memory + +import ( + "errors" + "testing" + + "github.com/cocoonstack/gateway/control-plane/internal/user" +) + +func TestStoreContract(t *testing.T) { + ctx := t.Context() + store := New() + alice := user.User{ + ID: "u1", Email: "ALICE@example.com", DisplayName: "Alice", PasswordHash: "hash", + Tenant: "acme", GatewayUserID: "alice", Role: user.RoleMember, + CreatedAt: 1, UpdatedAt: 1, + } + if err := store.Create(ctx, alice); err != nil { + t.Fatalf("create user: %v", err) + } + got, err := store.ByEmail(ctx, " alice@EXAMPLE.com ") + if err != nil { + t.Fatalf("find by normalized email: %v", err) + } + if got.ID != alice.ID || got.Email != "alice@example.com" { + t.Errorf("got %+v, want normalized alice", got) + } + if err := store.Create(ctx, user.User{ID: "u2", Email: "alice@example.com", PasswordHash: "hash", Tenant: "acme", Role: user.RoleMember}); !errors.Is(err, user.ErrConflict) { + t.Fatalf("duplicate email error = %v, want conflict", err) + } + got.Disabled = true + got.UpdatedAt = 2 + if err := store.Update(ctx, got); err != nil { + t.Fatalf("update user: %v", err) + } + updated, err := store.ByID(ctx, alice.ID) + if err != nil || !updated.Disabled { + t.Fatalf("updated user = %+v, %v", updated, err) + } + users, err := store.List(ctx) + if err != nil || len(users) != 1 { + t.Fatalf("users = %+v, %v", users, err) + } +} diff --git a/control-plane/internal/user/postgres/migrations/001_users.sql b/control-plane/internal/user/postgres/migrations/001_users.sql new file mode 100644 index 0000000..8e486c9 --- /dev/null +++ b/control-plane/internal/user/postgres/migrations/001_users.sql @@ -0,0 +1,12 @@ +CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + password_hash TEXT NOT NULL, + tenant TEXT NOT NULL DEFAULT '', + gateway_user_id TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL, + disabled BOOLEAN NOT NULL DEFAULT FALSE, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); diff --git a/control-plane/internal/user/postgres/migrations/002_password_epoch.sql b/control-plane/internal/user/postgres/migrations/002_password_epoch.sql new file mode 100644 index 0000000..958cbe5 --- /dev/null +++ b/control-plane/internal/user/postgres/migrations/002_password_epoch.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS password_changed_at BIGINT NOT NULL DEFAULT 0; diff --git a/control-plane/internal/user/postgres/store.go b/control-plane/internal/user/postgres/store.go new file mode 100644 index 0000000..f84065f --- /dev/null +++ b/control-plane/internal/user/postgres/store.go @@ -0,0 +1,183 @@ +// Package postgres provides the production identity store. +package postgres + +import ( + "cmp" + "context" + "embed" + "errors" + "fmt" + "io/fs" + "slices" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/cocoonstack/gateway/control-plane/internal/user" +) + +const userSelect = `SELECT id, email, display_name, password_hash, tenant, + gateway_user_id, role, disabled, password_changed_at, created_at, updated_at FROM users` + +//go:embed migrations/*.sql +var migrationFiles embed.FS + +var _ user.Store = (*Store)(nil) + +type Store struct { + pool *pgxpool.Pool +} + +func Connect(ctx context.Context, rawURL string) (*Store, error) { + pool, err := pgxpool.New(ctx, rawURL) + if err != nil { + return nil, fmt.Errorf("create postgres pool: %w", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("ping postgres: %w", err) + } + s := &Store{pool: pool} + if err := s.migrate(ctx); err != nil { + pool.Close() + return nil, err + } + return s, nil +} + +func (s *Store) Close() { s.pool.Close() } + +func (s *Store) migrate(ctx context.Context) error { + if _, err := s.pool.Exec(ctx, "CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())"); err != nil { + return fmt.Errorf("create schema migrations: %w", err) + } + entries, err := migrationFiles.ReadDir("migrations") + if err != nil { + return fmt.Errorf("read migrations: %w", err) + } + slices.SortFunc(entries, func(a, b fs.DirEntry) int { return cmp.Compare(a.Name(), b.Name()) }) + for _, entry := range entries { + name := entry.Name() + var applied bool + if err := s.pool.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE name = $1)", name).Scan(&applied); err != nil { + return fmt.Errorf("check migration %s: %w", name, err) + } + if applied { + continue + } + body, err := migrationFiles.ReadFile("migrations/" + name) + if err != nil { + return fmt.Errorf("read migration %s: %w", name, err) + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin migration %s: %w", name, err) + } + if _, err := tx.Exec(ctx, string(body)); err != nil { + _ = tx.Rollback(ctx) + return fmt.Errorf("apply migration %s: %w", name, err) + } + if _, err := tx.Exec(ctx, "INSERT INTO schema_migrations (name) VALUES ($1)", name); err != nil { + _ = tx.Rollback(ctx) + return fmt.Errorf("record migration %s: %w", name, err) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit migration %s: %w", name, err) + } + } + return nil +} + +func (s *Store) Create(ctx context.Context, u user.User) error { + if err := u.Validate(); err != nil { + return err + } + _, err := s.pool.Exec(ctx, + `INSERT INTO users (id, email, display_name, password_hash, tenant, + gateway_user_id, role, disabled, password_changed_at, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, + u.ID, user.NormalizeEmail(u.Email), u.DisplayName, u.PasswordHash, u.Tenant, + u.GatewayUserID, u.Role, u.Disabled, u.PasswordChangedAt, u.CreatedAt, u.UpdatedAt, + ) + if err != nil { + if isDuplicate(err) { + return user.ErrConflict + } + return fmt.Errorf("create user: %w", err) + } + return nil +} + +func (s *Store) ByID(ctx context.Context, id string) (user.User, error) { + return scanUser(s.pool.QueryRow(ctx, userSelect+" WHERE id = $1", id)) +} + +func (s *Store) ByEmail(ctx context.Context, email string) (user.User, error) { + return scanUser(s.pool.QueryRow(ctx, userSelect+" WHERE email = $1", user.NormalizeEmail(email))) +} + +func (s *Store) List(ctx context.Context) ([]user.User, error) { + rows, err := s.pool.Query(ctx, userSelect+" ORDER BY email") + if err != nil { + return nil, fmt.Errorf("list users: %w", err) + } + defer rows.Close() + users := make([]user.User, 0) + for rows.Next() { + u, err := scanUser(rows) + if err != nil { + return nil, err + } + users = append(users, u) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate users: %w", err) + } + return users, nil +} + +func (s *Store) Update(ctx context.Context, u user.User) error { + if err := u.Validate(); err != nil { + return err + } + result, err := s.pool.Exec(ctx, + `UPDATE users SET email=$2, display_name=$3, password_hash=$4, tenant=$5, + gateway_user_id=$6, role=$7, disabled=$8, password_changed_at=$9, updated_at=$10 WHERE id=$1`, + u.ID, user.NormalizeEmail(u.Email), u.DisplayName, u.PasswordHash, u.Tenant, + u.GatewayUserID, u.Role, u.Disabled, u.PasswordChangedAt, u.UpdatedAt, + ) + if err != nil { + if isDuplicate(err) { + return user.ErrConflict + } + return fmt.Errorf("update user: %w", err) + } + if result.RowsAffected() == 0 { + return user.ErrNotFound + } + return nil +} + +func isDuplicate(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23505" +} + +type scanner interface { + Scan(...any) error +} + +func scanUser(row scanner) (user.User, error) { + var u user.User + if err := row.Scan( + &u.ID, &u.Email, &u.DisplayName, &u.PasswordHash, &u.Tenant, + &u.GatewayUserID, &u.Role, &u.Disabled, &u.PasswordChangedAt, &u.CreatedAt, &u.UpdatedAt, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return user.User{}, user.ErrNotFound + } + return user.User{}, fmt.Errorf("scan user: %w", err) + } + return u, nil +} diff --git a/control-plane/internal/user/user.go b/control-plane/internal/user/user.go new file mode 100644 index 0000000..b03557b --- /dev/null +++ b/control-plane/internal/user/user.go @@ -0,0 +1,74 @@ +// Package user defines control-plane identities and their persistence seam. +package user + +import ( + "context" + "errors" + "strings" +) + +type Role string + +const ( + RoleMember Role = "member" + RoleTenantAdmin Role = "tenant_admin" + RoleSystemAdmin Role = "system_admin" +) + +var ( + ErrNotFound = errors.New("user not found") + ErrConflict = errors.New("user already exists") +) + +// User is one human control-plane identity. Non-system users belong to one +// gateway tenant; GatewayUserID is the billing attribution key. +type User struct { + ID string `json:"id"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + PasswordHash string `json:"-"` + Tenant string `json:"tenant"` + GatewayUserID string `json:"gateway_user_id"` + Role Role `json:"role"` + Disabled bool `json:"disabled"` + // PasswordChangedAt evicts sessions issued at or before it (epoch secs); + // zero = never reset. + PasswordChangedAt int64 `json:"-"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +func (u User) Validate() error { + if strings.TrimSpace(u.ID) == "" { + return errors.New("id is required") + } + if NormalizeEmail(u.Email) == "" || !strings.Contains(u.Email, "@") { + return errors.New("valid email is required") + } + if u.PasswordHash == "" { + return errors.New("password hash is required") + } + switch u.Role { + case RoleMember, RoleTenantAdmin: + if strings.TrimSpace(u.Tenant) == "" { + return errors.New("tenant is required for non-system users") + } + case RoleSystemAdmin: + default: + return errors.New("invalid role") + } + return nil +} + +// Store persists human identities. Implementations must enforce unique email. +type Store interface { + Create(context.Context, User) error + ByID(context.Context, string) (User, error) + ByEmail(context.Context, string) (User, error) + List(context.Context) ([]User, error) + Update(context.Context, User) error +} + +func NormalizeEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} diff --git a/control-plane/web/e2e/control-plane.spec.ts b/control-plane/web/e2e/control-plane.spec.ts new file mode 100644 index 0000000..d3c8fe7 --- /dev/null +++ b/control-plane/web/e2e/control-plane.spec.ts @@ -0,0 +1,54 @@ +import { expect, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; + +test("member and system admin receive different control surfaces", async ({ page }) => { + await page.goto("/"); + await signIn(page, "user@example.com", "user12345!"); + + await expect(page.getByRole("heading", { name: /Alice/ })).toBeVisible(); + await expect(page.getByRole("link", { name: "Usage & cost" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Access keys" })).toHaveCount(0); + await expect(page.getByText("Vendor cost")).toHaveCount(0); + await page.getByRole("link", { name: "Availability" }).click(); + await expect(page.getByRole("heading", { name: "Models" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Gateway instances" })).toHaveCount(0); + + await page.getByRole("button", { name: "Sign out" }).click(); + await signIn(page, "admin@example.com", "admin12345!"); + + await expect(page.getByRole("link", { name: "Users & roles" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Configuration" })).toBeVisible(); + await expect(page.getByText("Vendor cost")).toBeVisible(); + await page.getByRole("link", { name: "Availability" }).click(); + await expect(page.getByRole("heading", { name: "Gateway instances" })).toBeVisible(); + await expect(page.getByText("gw-a", { exact: true })).toBeVisible(); + await expect(page.getByText("gw-b", { exact: true })).toBeVisible(); + + await page.getByRole("link", { name: "Configuration" }).click(); + await expect(page.getByText(/Current version \d+/)).toBeVisible(); + await page.getByRole("button", { name: "Validate" }).click(); + await expect(page.getByText("Configuration is valid and ready to publish.")).toBeVisible(); + + page.on("dialog", (dialog) => void dialog.accept()); + await page.getByRole("button", { name: "Publish configuration" }).click(); + await expect(page.getByText(/Published as version \d+/)).toBeVisible(); + + await page.getByRole("button", { name: "Restore" }).first().click(); + await expect(page.getByText(/restored as version \d+/)).toBeVisible(); +}); + +test("failed login shows an error and grants nothing", async ({ page }) => { + await page.goto("/"); + await page.getByLabel("Email").fill("admin@example.com"); + await page.getByLabel("Password").fill("wrong-password!"); + await page.getByRole("button", { name: "Sign in" }).click(); + await expect(page.getByText("invalid email or password")).toBeVisible(); + await expect(page.getByRole("navigation", { name: "Main navigation" })).toHaveCount(0); +}); + +async function signIn(page: Page, email: string, password: string) { + await page.getByLabel("Email").fill(email); + await page.getByLabel("Password").fill(password); + await page.getByRole("button", { name: "Sign in" }).click(); + await expect(page.getByRole("navigation", { name: "Main navigation" })).toBeVisible(); +} diff --git a/control-plane/web/index.html b/control-plane/web/index.html new file mode 100644 index 0000000..0dcf876 --- /dev/null +++ b/control-plane/web/index.html @@ -0,0 +1,14 @@ + + + + + + + + Cocoon Gateway + + +
+ + + diff --git a/control-plane/web/package-lock.json b/control-plane/web/package-lock.json new file mode 100644 index 0000000..b7626e3 --- /dev/null +++ b/control-plane/web/package-lock.json @@ -0,0 +1,2693 @@ +{ + "name": "gateway-control-plane-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gateway-control-plane-web", + "version": "0.1.0", + "dependencies": { + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1" + }, + "devDependencies": { + "@playwright/test": "^1.61.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "jsdom": "^29.1.1", + "typescript": "^7.0.2", + "vite": "^8.1.5", + "vitest": "^4.1.10" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-router": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.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" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/control-plane/web/package.json b/control-plane/web/package.json new file mode 100644 index 0000000..304b6f0 --- /dev/null +++ b/control-plane/web/package.json @@ -0,0 +1,30 @@ +{ + "name": "gateway-control-plane-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "typecheck": "tsc -b --pretty false", + "test": "vitest run", + "e2e": "playwright test" + }, + "dependencies": { + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1" + }, + "devDependencies": { + "@playwright/test": "^1.61.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "jsdom": "^29.1.1", + "typescript": "^7.0.2", + "vite": "^8.1.5", + "vitest": "^4.1.10" + } +} diff --git a/control-plane/web/playwright.config.ts b/control-plane/web/playwright.config.ts new file mode 100644 index 0000000..e5416c3 --- /dev/null +++ b/control-plane/web/playwright.config.ts @@ -0,0 +1,33 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig, devices } from "@playwright/test"; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: false, + retries: 0, + reporter: "list", + use: { + baseURL: "http://127.0.0.1:5173", + trace: "retain-on-failure", + ...devices["Desktop Chrome"], + }, + webServer: [ + { + command: "CP_DEV_SEED=true GOWORK=off go run ./cmd/control-plane", + cwd: path.resolve(here, ".."), + url: "http://127.0.0.1:8090/api/v1/health", + reuseExistingServer: true, + timeout: 120_000, + }, + { + command: "npm run dev -- --host 127.0.0.1", + cwd: here, + url: "http://127.0.0.1:5173", + reuseExistingServer: true, + timeout: 120_000, + }, + ], +}); diff --git a/control-plane/web/src/App.test.tsx b/control-plane/web/src/App.test.tsx new file mode 100644 index 0000000..3a69782 --- /dev/null +++ b/control-plane/web/src/App.test.tsx @@ -0,0 +1,64 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import App from "./App"; +import type { Role, Session } from "./types"; + +const overview = { + totals: { requests: 12, total_tokens: 4200, cost_micros: 20000, vendor_cost_micros: 12000 }, + usage: [], + series: { bucket: "day", since: 1, until: 2, series: [] }, + models: [{ model: "gpt-test", state: "available", requests: 12, errors: 0, window_minutes: 15 }], +}; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("role navigation", () => { + it("keeps a member on usage and availability surfaces", async () => { + mockAPI("member"); + render(); + + expect(await screen.findByRole("link", { name: /usage & cost/i })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /availability/i })).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /access keys/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /configuration/i })).not.toBeInTheDocument(); + }); + + it("exposes fleet and configuration surfaces to a system admin", async () => { + mockAPI("system_admin"); + render(); + + expect(await screen.findByRole("link", { name: /access keys/i })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /users & roles/i })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /configuration/i })).toBeInTheDocument(); + expect(await screen.findByText("Revenue")).toBeInTheDocument(); + }); +}); + +function mockAPI(role: Role) { + const session: Session = { + csrf_token: "csrf-test", + user: { + id: "user-1", + email: "person@example.com", + display_name: role === "system_admin" ? "System Admin" : "Member User", + tenant: role === "system_admin" ? "" : "tenant-a", + gateway_user_id: role === "system_admin" ? "" : "gateway-user-1", + role, + disabled: false, + created_at: 1, + updated_at: 1, + }, + }; + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const path = typeof input === "string" ? input : input.toString(); + const body = path.startsWith("/api/v1/session") ? session : overview; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + })); +} diff --git a/control-plane/web/src/App.tsx b/control-plane/web/src/App.tsx new file mode 100644 index 0000000..d2d71c7 --- /dev/null +++ b/control-plane/web/src/App.tsx @@ -0,0 +1,137 @@ +import { createContext, useContext, useEffect, useMemo, useState } from "react"; +import type { ReactNode } from "react"; +import { Navigate, NavLink, Route, Routes, useLocation, useNavigate } from "react-router-dom"; +import { api, APIError, setCSRF, setUnauthorizedHandler } from "./api"; +import { roleLabel } from "./format"; +import type { Role, Session } from "./types"; +import { Loading } from "./components/UI"; +import LoginPage from "./pages/LoginPage"; +import OverviewPage from "./pages/OverviewPage"; +import UsagePage from "./pages/UsagePage"; +import AvailabilityPage from "./pages/AvailabilityPage"; +import KeysPage from "./pages/KeysPage"; +import UsersPage from "./pages/UsersPage"; +import ConfigPage from "./pages/ConfigPage"; +import AuditPage from "./pages/AuditPage"; + +interface AuthState { + session: Session; + logout: () => Promise; +} + +const AuthContext = createContext(null); + +export function useAuth(): AuthState { + const value = useContext(AuthContext); + if (!value) throw new Error("AuthContext is unavailable"); + return value; +} + +export default function App() { + const [session, updateSession] = useState(null); + const [loading, setLoading] = useState(true); + + const applySession = (value: Session | null) => { + setCSRF(value?.csrf_token ?? ""); + updateSession(value); + }; + + useEffect(() => { + setUnauthorizedHandler(() => applySession(null)); + api("/api/v1/session") + .then(applySession) + .catch((error: unknown) => { + if (!(error instanceof APIError) || error.status !== 401) { + console.error(error); + } + }) + .finally(() => setLoading(false)); + }, []); + + const auth = useMemo(() => { + if (!session) return null; + return { + session, + async logout() { + await api("/api/v1/auth/logout", { method: "POST" }); + applySession(null); + }, + }; + }, [session]); + + if (loading) return ; + if (!auth) return ; + + return ( + + + + ); +} + +interface NavItem { + to: string; + label: string; + icon: string; + minRole: Role; + page: ReactNode; + end?: boolean; +} + +const roleRank: Record = { member: 0, tenant_admin: 1, system_admin: 2 }; + +const navItems: NavItem[] = [ + { to: "/", label: "Overview", icon: "◫", end: true, minRole: "member", page: }, + { to: "/usage", label: "Usage & cost", icon: "⌁", minRole: "member", page: }, + { to: "/availability", label: "Availability", icon: "◉", minRole: "member", page: }, + { to: "/keys", label: "Access keys", icon: "⌘", minRole: "tenant_admin", page: }, + { to: "/audit", label: "Audit", icon: "≣", minRole: "tenant_admin", page: }, + { to: "/users", label: "Users & roles", icon: "♙", minRole: "system_admin", page: }, + { to: "/configuration", label: "Configuration", icon: "⌗", minRole: "system_admin", page: }, +]; + +function Shell() { + const { session, logout } = useAuth(); + const role = session.user.role; + const location = useLocation(); + const navigate = useNavigate(); + const links = navItems.filter((item) => roleRank[role] >= roleRank[item.minRole]); + + useEffect(() => { + window.scrollTo({ top: 0 }); + }, [location.pathname]); + + return ( +
+ +
+ + {links.map((item) => ( + + ))} + } /> + +
+
+ ); +} diff --git a/control-plane/web/src/api.ts b/control-plane/web/src/api.ts new file mode 100644 index 0000000..e89614e --- /dev/null +++ b/control-plane/web/src/api.ts @@ -0,0 +1,56 @@ +let csrfToken = ""; +let onUnauthorized: (() => void) | null = null; + +export class APIError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +export function setCSRF(token: string): void { + csrfToken = token; +} + +export function setUnauthorizedHandler(handler: (() => void) | null): void { + onUnauthorized = handler; +} + +export async function api(path: string, init: RequestInit = {}): Promise { + const method = (init.method ?? "GET").toUpperCase(); + const headers = new Headers(init.headers); + if (init.body && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + if (!["GET", "HEAD", "OPTIONS"].includes(method) && csrfToken) { + headers.set("X-CSRF-Token", csrfToken); + } + const response = await fetch(path, { + ...init, + headers, + credentials: "same-origin", + }); + if (!response.ok) { + let message = `${response.status} ${response.statusText}`; + try { + const body = (await response.json()) as { error?: { message?: string } }; + message = body.error?.message ?? message; + } catch { + // The status line remains the useful fallback for a non-JSON proxy error. + } + if (response.status === 401 && path !== "/api/v1/auth/login" && path !== "/api/v1/session") { + onUnauthorized?.(); + } + throw new APIError(response.status, message); + } + if (response.status === 204) { + return undefined as T; + } + return (await response.json()) as T; +} + +export function jsonBody(value: unknown): RequestInit { + return { body: JSON.stringify(value) }; +} diff --git a/control-plane/web/src/components/UI.tsx b/control-plane/web/src/components/UI.tsx new file mode 100644 index 0000000..0c0556b --- /dev/null +++ b/control-plane/web/src/components/UI.tsx @@ -0,0 +1,166 @@ +import type { FormEvent, ReactNode } from "react"; +import { compact } from "../format"; + +export function PageHeader({ + eyebrow, + title, + description, + actions, +}: { + eyebrow: string; + title: string; + description: string; + actions?: ReactNode; +}) { + return ( +
+
+

{eyebrow}

+

{title}

+

{description}

+
+ {actions &&
{actions}
} +
+ ); +} + +export function Card({ children, className = "" }: { children: ReactNode; className?: string }) { + return
{children}
; +} + +export function Metric({ + label, + value, + detail, + tone = "neutral", +}: { + label: string; + value: string; + detail: string; + tone?: "neutral" | "positive" | "warning"; +}) { + return ( + +

{label}

+ {value} + {detail} +
+ ); +} + +export function Status({ value }: { value: string }) { + return ( + + + ); +} + +export function Loading({ label = "Loading" }: { label?: string }) { + return ( +
+ + {label} +
+ ); +} + +export function Empty({ children }: { children: ReactNode }) { + return
{children}
; +} + +export function ErrorNotice({ message }: { message: string }) { + return
{message}
; +} + +export function FormModal({ + eyebrow, + title, + busy, + error, + submitLabel, + busyLabel, + onClose, + onSubmit, + children, +}: { + eyebrow: string; + title: string; + busy: boolean; + error: string; + submitLabel: string; + busyLabel: string; + onClose: () => void; + onSubmit: (event: FormEvent) => void; + children: ReactNode; +}) { + return ( +
+
event.stopPropagation()}> +
+
+

{eyebrow}

+

{title}

+
+ +
+
+ {children} + {error && } +
+ + +
+ +
+
+ ); +} + +export function LineChart({ + data, + value, + format = compact, +}: { + data: { start: number }[]; + value: string; + format?: (value: number) => string; +}) { + const width = 720; + const height = 230; + const padding = 18; + const values = data.map((item) => (item as Record)[value] ?? 0); + const max = Math.max(...values, 1); + const points = values + .map((item, index) => { + const x = padding + (index / Math.max(values.length - 1, 1)) * (width - padding * 2); + const y = height - padding - (item / max) * (height - padding * 2); + return `${x},${y}`; + }) + .join(" "); + return ( +
+
+ {format(max)} + {format(max / 2)} + 0 +
+ + + + + + + + + {points && ( + <> + + + + )} + +
+ ); +} diff --git a/control-plane/web/src/format.ts b/control-plane/web/src/format.ts new file mode 100644 index 0000000..5483cab --- /dev/null +++ b/control-plane/web/src/format.ts @@ -0,0 +1,34 @@ +const usd = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 2, +}); +const compactNumber = new Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 1, +}); +const shortDateTime = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", +}); + +export function money(micros: number): string { + return usd.format(micros / 1_000_000); +} + +export function compact(value: number): string { + return compactNumber.format(value); +} + +export function dateTime(epoch: number): string { + return shortDateTime.format(new Date(epoch * 1000)); +} + +export function roleLabel(role: string): string { + return role + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} diff --git a/control-plane/web/src/hooks.ts b/control-plane/web/src/hooks.ts new file mode 100644 index 0000000..976b09c --- /dev/null +++ b/control-plane/web/src/hooks.ts @@ -0,0 +1,43 @@ +import { useCallback, useEffect, useState } from "react"; +import { api } from "./api"; + +export function useAPI(path: string | null) { + const [data, setData] = useState(null); + const [error, setError] = useState(""); + const [version, setVersion] = useState(0); + + const reload = useCallback(() => setVersion((value) => value + 1), []); + + useEffect(() => { + if (!path) return; + let active = true; + setError(""); + api(path) + .then((value) => active && setData(value)) + .catch((err: unknown) => active && setError(errorMessage(err, "Request failed"))); + return () => { active = false; }; + }, [path, version]); + + return { data, error, loading: path !== null && data === null && error === "", reload }; +} + +export function useAction(fallback = "Request failed") { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const run = useCallback(async (fn: () => Promise) => { + setBusy(true); + setError(""); + try { + await fn(); + } catch (err) { + setError(errorMessage(err, fallback)); + } finally { + setBusy(false); + } + }, [fallback]); + return { run, busy, error }; +} + +function errorMessage(err: unknown, fallback: string): string { + return err instanceof Error ? err.message : fallback; +} diff --git a/control-plane/web/src/main.tsx b/control-plane/web/src/main.tsx new file mode 100644 index 0000000..efbf46d --- /dev/null +++ b/control-plane/web/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import App from "./App"; +import "./styles.css"; + +createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/control-plane/web/src/pages/AuditPage.tsx b/control-plane/web/src/pages/AuditPage.tsx new file mode 100644 index 0000000..1ac6dea --- /dev/null +++ b/control-plane/web/src/pages/AuditPage.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import { Card, Empty, ErrorNotice, Loading, PageHeader, Status } from "../components/UI"; +import { dateTime } from "../format"; +import { useAPI } from "../hooks"; +import { useAuth } from "../App"; +import type { AuditEntry, SecurityEvent } from "../types"; + +export default function AuditPage() { + const { session } = useAuth(); + const system = session.user.role === "system_admin"; + const [kind, setKind] = useState<"ops" | "security">(system ? "ops" : "security"); + const ops = useAPI<{ entries: AuditEntry[] }>(kind === "ops" ? "/api/v1/admin/audit?kind=ops" : null); + const security = useAPI<{ events: SecurityEvent[] }>(kind === "security" ? "/api/v1/admin/audit?kind=security" : null); + + return ( + <> + + {system && } + + + } + /> + + {kind === "ops" ? ( + +

Operational changes

Configuration and administrative actions recorded by the gateway.

+ {ops.loading && } + {ops.error && } + {ops.data?.entries.length === 0 && No operational audit entries in this period.} + {ops.data && ops.data.entries.length > 0 && ( +
+ {ops.data.entries.map((entry, index) => + + )} +
TimeActorActionTargetSummary
{dateTime(entry.created_at_epoch_secs)}{entry.actor}{entry.target}{entry.summary}
+ )} +
+ ) : ( + +

Security events

Rule matches and actions, scoped to the tenants you manage.

+ {security.loading && } + {security.error && } + {security.data?.events.length === 0 && No security events in this period.} + {security.data && security.data.events.length > 0 && ( +
+ {security.data.events.map((event, index) => + + )} +
TimeTenant / userSurfaceRuleActionHits
{dateTime(event.created_at_epoch_secs)}{event.tenant || "—"}{event.user_id || event.ak}{event.surface}{event.rule}{event.hits}
+ )} +
+ )} + + ); +} diff --git a/control-plane/web/src/pages/AvailabilityPage.tsx b/control-plane/web/src/pages/AvailabilityPage.tsx new file mode 100644 index 0000000..0b05b18 --- /dev/null +++ b/control-plane/web/src/pages/AvailabilityPage.tsx @@ -0,0 +1,46 @@ +import { useAuth } from "../App"; +import { compact } from "../format"; +import { useAPI } from "../hooks"; +import type { Instance, ModelStatus } from "../types"; +import { Card, ErrorNotice, Loading, PageHeader, Status } from "../components/UI"; + +export default function AvailabilityPage() { + const { session } = useAuth(); + const isSystem = session.user.role === "system_admin"; + const models = useAPI<{ models: ModelStatus[] }>("/api/v1/models/status"); + const instances = useAPI<{ instances: Instance[] }>(isSystem ? "/api/v1/admin/instances" : null); + + return ( + <> + { models.reload(); if (isSystem) instances.reload(); }}>Refresh} + /> + {(models.error || (isSystem && instances.error)) && } + {!models.data ? : ( + +

Shared service view

Models

Recent gateway window
+
{models.data.models.map((model) => ( +
{model.model}
{compact(model.requests)} requests{model.errors} errors{model.window_minutes}m window
+ ))}
+
+ )} + {isSystem && ( + !instances.data ? : ( + +

Configured targets

Gateway instances

Health and local account-pool view
+
{instances.data.instances.map((instance) => ( +
+
{instance.id}{instance.url}
+
Latency {instance.latency_ms} msAccounts {instance.accounts.length}{instance.error && {instance.error}}
+
{instance.accounts.map((account) =>
{account.name}{account.provider} · {account.tier} · {account.protocols.join(", ")}
{account.health}
)}
+
+ ))}
+
+ ) + )} + + ); +} diff --git a/control-plane/web/src/pages/ConfigPage.tsx b/control-plane/web/src/pages/ConfigPage.tsx new file mode 100644 index 0000000..1f7bcd5 --- /dev/null +++ b/control-plane/web/src/pages/ConfigPage.tsx @@ -0,0 +1,111 @@ +import { useEffect, useState } from "react"; +import { api, jsonBody } from "../api"; +import { Card, ErrorNotice, Loading, PageHeader } from "../components/UI"; +import { dateTime } from "../format"; +import { useAPI, useAction } from "../hooks"; +import type { ConfigDocument, ConfigVersion } from "../types"; + +interface VersionList { + versions: ConfigVersion[]; +} + +export default function ConfigPage() { + const current = useAPI("/api/v1/admin/config"); + const history = useAPI("/api/v1/admin/config/versions"); + const [yaml, setYAML] = useState(""); + const [message, setMessage] = useState(""); + const { run, busy, error } = useAction(); + + useEffect(() => { + if (current.data) setYAML(current.data.yaml); + }, [current.data]); + + function act(kind: "validate" | "publish") { + if (kind === "publish" && !window.confirm("Publish this configuration to the whole fleet?")) return; + setMessage(""); + void run(async () => { + if (kind === "validate") { + await api("/api/v1/admin/config/validate", { + method: "POST", + ...jsonBody({ yaml }), + }); + setMessage("Configuration is valid and ready to publish."); + } else { + const result = await api<{ version: number }>("/api/v1/admin/config", { + method: "PUT", + ...jsonBody({ yaml, expected_version: current.data?.version ?? 0 }), + }); + setMessage(`Published as version ${result.version}.`); + current.reload(); + history.reload(); + } + }); + } + + function rollback(version: number) { + if (!window.confirm(`Roll back to version ${version}? A new version will be published.`)) return; + setMessage(""); + void run(async () => { + const result = await api<{ version: number }>(`/api/v1/admin/config/versions/${version}/rollback`, { + method: "POST", + }); + setMessage(`Version ${version} restored as version ${result.version}.`); + current.reload(); + history.reload(); + }); + } + + if (current.loading) return ; + + return ( + <> + Current version {current.data?.version ?? "—"}
} + /> + {current.error && } + {error && } + {message &&
{message}
} + +
+ +
+

Gateway YAML

Changes take effect only after a successful publish.

+
+ +