diff --git a/.gitea/workflows/management-images.yml b/.gitea/workflows/management-images.yml new file mode 100644 index 0000000..548d7c0 --- /dev/null +++ b/.gitea/workflows/management-images.yml @@ -0,0 +1,70 @@ +name: management-images + +on: + pull_request: + push: + branches: + - main + tags: + - 'v*' + workflow_dispatch: + +jobs: + build-and-publish: + runs-on: ubuntu-latest + env: + REGISTRY: git.ipao.vip + IMAGE_BASE: git.ipao.vip/rogee/agent-call + MANAGEMENT_UI_API_URL: ${{ vars.MANAGEMENT_UI_API_URL }} + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Build management API image + run: | + set -eu + docker build \ + --file management/deploy/Dockerfile.backend \ + --tag "$IMAGE_BASE-management-api:$GITHUB_SHA" \ + management/backend + + - name: Build management UI image + run: | + set -eu + api_url="${MANAGEMENT_UI_API_URL:-http://localhost:8080}" + docker build \ + --file management/deploy/Dockerfile.frontend \ + --build-arg "VITE_MANAGEMENT_API_URL=$api_url" \ + --tag "$IMAGE_BASE-management-ui:$GITHUB_SHA" \ + management/frontend + + - name: Log in to Gitea Container Registry + if: github.event_name != 'pull_request' + env: + REGISTRY_USERNAME: ${{ github.repository_owner }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -eu + test -n "$REGISTRY_TOKEN" + printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY" \ + --username "$REGISTRY_USERNAME" --password-stdin + + - name: Push immutable and release tags + if: github.event_name != 'pull_request' + run: | + set -eu + tags="$GITHUB_SHA" + if [ "${GITHUB_REF_TYPE:-}" = tag ]; then + tags="$tags $GITHUB_REF_NAME" + fi + if [ "${GITHUB_REF_NAME:-}" = main ]; then + tags="$tags latest" + fi + for component in management-api management-ui; do + for tag in $tags; do + docker tag \ + "$IMAGE_BASE-$component:$GITHUB_SHA" \ + "$IMAGE_BASE-$component:$tag" + docker push "$IMAGE_BASE-$component:$tag" + done + done diff --git a/README.md b/README.md new file mode 100644 index 0000000..b7ac690 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# agent-call + +## SIP 管理后台一键部署 + +管理后台是仓库内独立项目,默认只运行隔离的 `mock` 模式,不连接真实 SIP、MQ、OSS、云资源或旧 Python 服务,也不会自动拨号。 + +### 1. 源码构建运行(推荐首次验证) + +依赖 Docker Engine 和 Docker Compose v2: + +```bash +git clone git@git.ipao.vip:rogee/agent-call.git +cd agent-call +docker compose -f management/deploy/compose.yaml up -d --build +``` + +访问 ,在页面会话中输入演示令牌: + +```text +dev-admin-token +``` + +检查服务: + +```bash +curl http://localhost:8080/healthz/live +docker compose -f management/deploy/compose.yaml logs -f management-api +docker compose -f management/deploy/compose.yaml down +``` + +停止并删除本地演示数据库卷(会删除演示数据): + +```bash +docker compose -f management/deploy/compose.yaml down -v +``` + +默认端口为 API `8080`、页面 `3000`。端口冲突时可显式覆盖: + +```bash +MANAGEMENT_API_PORT=18080 MANAGEMENT_UI_PORT=13001 \ +MANAGEMENT_ALLOWED_ORIGINS=http://localhost:13001 \ +MANAGEMENT_UI_API_URL=http://localhost:18080 \ +docker compose -f management/deploy/compose.yaml up -d --build +``` + +### 2. 使用 Gitea Container Registry 镜像 + +`.gitea/workflows/management-images.yml` 会在 Pull Request 上构建校验,在 `main` 或 `v*` 标签上构建并推送: + +```text +git.ipao.vip/rogee/agent-call-management-api: +git.ipao.vip/rogee/agent-call-management-ui: +``` + +Action 运行前,在 Gitea 账户级 Actions Secret 配置 `REGISTRY_TOKEN`;不要把令牌写入仓库。需要固定前端 API 地址时,配置非敏感的 Actions Variable `MANAGEMENT_UI_API_URL`。镜像发布完成后使用: + +```bash +docker login git.ipao.vip +export MANAGEMENT_API_IMAGE=git.ipao.vip/rogee/agent-call-management-api:latest +export MANAGEMENT_UI_IMAGE=git.ipao.vip/rogee/agent-call-management-ui:latest +docker compose -f management/deploy/compose.images.yaml up -d +``` + +镜像 Compose 同样只启动隔离 `mock`;生产使用前必须替换令牌、TLS/mTLS、允许来源、数据库和外部核验配置,并单独完成真实供应商/SIP/RTP/MQ/OSS/AI 验收。 + +更多开发、契约同步、测试和安全边界见 [`management/README.md`](management/README.md)。 diff --git a/management/ACCEPTANCE.md b/management/ACCEPTANCE.md new file mode 100644 index 0000000..edd384a --- /dev/null +++ b/management/ACCEPTANCE.md @@ -0,0 +1,32 @@ +# 管理后台验收映射 + +对应 `docs/SIP管理后台_接口与页面设计及验收_v1.0.md`。这是 management 自身 Mock/隔离数据库的可重复验收,不把真实供应商、云、MQ、SIP/RTP、ASR/LLM/TTS 结果混入。 + +## 已自动覆盖 + +| 设计用例 | 代码证据 | +| --- | --- | +| SIP-03/04/10/11/16/21 | `server_test.go` 的鉴权、CORS、敏感字段脱敏、观察 boot/sequence 及模式隔离;`util.go`/`store.go` 的边界校验 | +| SIP-05/06/20/31/32 | durable SQLite operation、If-Match CAS、请求 ID 原值重放/异载荷冲突、只读目录 active revision、`Recover` | +| SIP-07/08/28/30 | `TestPartialPublicationRetainsBarrierAndRetriesFailedCell`、`AdmissionAllowed`、逐 Cell publication rows;未全量确认不激活 | +| SIP-09/37 | `trunk_verifications` 版本证据及 real 发布缺失核验阻断 | +| SIP-13/14/15/24/26/33/34 | 固定 7 行 synthetic attempts、cohort/timeline/realtime 语义、过滤指纹 cursor、异常事实不推造 | +| SIP-25/38/39/40 | Go 独立模块、独立 SQLite、无 Python/其它业务 import、Mock 默认不联网;frontend 固定 upstream commit 和许可证 | + +固定样例自动校验:`attempts_total=6`(拒绝不计入)、`started_confirmed=6`、`answered=2`、`terminal_not_answered=3`、`pending_not_answered=1`、`rejected=1`、`answer_rate=0.333...`、`talk total=180`、`acd=90`;号码只在响应中脱敏。 + +## 运行门禁 + +```bash +cd management/backend +go test -race ./... +go vet ./... +cd ../frontend +bun run lint +bun run format:check +bun run build +cd ../.. +management/scripts/sync-contracts.sh --check +``` + +真实模式发布还要求外部 Cell Agent 的 HTTPS/mTLS、供应商核验、固定出口白名单和真实线路证据;本地测试没有伪造这些证据,也没有发起真实呼叫。Compose 只启动隔离 Mock。 diff --git a/management/README.md b/management/README.md new file mode 100644 index 0000000..b48743c --- /dev/null +++ b/management/README.md @@ -0,0 +1,87 @@ +# SIP 管理后台 + +独立于仓库其它业务的管理项目:Go 负责最终鉴权、版本/CAS、发布屏障、Cell 状态和统计;TanStack Start 只负责管理页面。运行时不导入 `agent_call`、不读取其它项目数据库、不代理 Python API,也不会因启动而拨打真实电话。 + +## 技术栈与固定基座 + +- 后端:Go 1.26、Fiber v3、Viper、Cobra、Logrus、SQLite、sqlc。 +- 前端:上游 `Kiranism/tanstack-start-dashboard`,锁定提交 `f3375fcce5d2b2fbc35caef776e5a1c61b030439`;许可证保留在 `frontend/LICENSE`,依赖和 `bun.lock` 随项目发布。 +- 契约:`docs/contracts/` 是唯一来源;`contracts/` 是打包后的生成快照,使用 `scripts/sync-contracts.sh --check` 校验。 + +## 本地运行 + +```bash +cd management/backend +go test -race ./... +go vet ./... +go run ./cmd/management serve +``` + +默认监听 `127.0.0.1:8080`,默认 SQLite 为 `backend/data/management.db`。首次演示数据: + +```bash +MANAGEMENT_DB_PATH=/tmp/agent-call-management.db go run ./cmd/management seed-demo +curl -H 'Authorization: Bearer dev-admin-token' http://127.0.0.1:8080/admin/v1/sip-status +``` + +演示数据仅使用合成的 TEST-NET SIP 地址;`dev-*` 令牌仅适用于 `mock` 本地开发,生产必须通过受控环境注入专用令牌和 mTLS 文件。可用令牌域隔离:`dev-admin-token`、`dev-config-token`、`dev-publisher-token`、`dev-observer-token`、`dev-audit-token`、`dev-saas-token`。调度器令牌不会被管理 API 接受。 + +前端另开终端: + +```bash +cd management/frontend +bun install --frozen-lockfile +VITE_MANAGEMENT_API_URL=http://127.0.0.1:8080 bun run dev +``` + +页面首次打开后在当前浏览器会话输入管理令牌;令牌不写入源码、URL、localStorage 或日志。 + +## 独立验证 + +```bash +# contract source/snapshot +management/scripts/sync-contracts.sh --check + +# backend +cd management/backend +gofmt -w . +go test -race ./... +go vet ./... + +# frontend +cd ../frontend +bun run lint +bun run format:check +bun run build +``` + +后端测试覆盖鉴权域隔离、资源范围、幂等重放、CAS 冲突、全 Cell 发布屏障、Mock 意图恢复、观察 boot/sequence、新鲜度和统计事实语义。默认 `mock` 发布不声称 Cell 已应用;需要协议 Mock 完整验收时显式设置 `MANAGEMENT_MOCK_CELL_APPLY=true`。 + +## 模式与安全边界 + +`MANAGEMENT_MODE=mock` 只处理合成/协议 Mock 数据;`MANAGEMENT_MODE=real` 发布前要求 HTTPS Cell Agent、mTLS 客户端证书、CA 和主机白名单,失败时保留原活动版本并保持 admission barrier,绝不静默回退 Mock。SIP 凭据只能是 secret-store reference,管理读取、审计和统计均不返回密钥或完整被叫号码。 + +`real` 模式不会把 `123.56.71.98` 当 SIP 服务端;该地址只作为固定出口白名单事实。真实供应商、MQ/OSS、云资源和真实呼叫验收需要另行安排,不是本项目启动前置。 + +## 一键 Compose 部署 + +从仓库根目录执行: + +```bash +docker compose -f management/deploy/compose.yaml up -d --build +``` + +打开 ,输入 `dev-admin-token`。健康检查和日志: + +```bash +curl http://localhost:8080/healthz/live +docker compose -f management/deploy/compose.yaml logs -f management-api +``` + +停止服务: + +```bash +docker compose -f management/deploy/compose.yaml down +``` + +默认仍是隔离 Mock。使用 Gitea Registry 镜像时,将 `MANAGEMENT_API_IMAGE`、`MANAGEMENT_UI_IMAGE` 传给 `deploy/compose.images.yaml`;镜像由根目录 `.gitea/workflows/management-images.yml` 在 PR 构建、在 `main`/版本标签推送。不要将示例令牌和 Mock 配置用于生产。 diff --git a/management/backend/admission.go b/management/backend/admission.go new file mode 100644 index 0000000..6d0b191 --- /dev/null +++ b/management/backend/admission.go @@ -0,0 +1,60 @@ +package management + +import ( + "context" + "database/sql" + "errors" + "time" +) + +// CreateAdmissionIntent writes the fail-closed boundary before a disable or rollback creates a new snapshot. +func (s *Store) CreateAdmissionIntent(ctx context.Context, trunkID string, revision int64, operationID, reason string) error { + now := utcString(time.Now()) + return s.withTx(ctx, func(tx *sql.Tx) error { + var existing barrier + err := tx.QueryRowContext(ctx, "SELECT trunk_id,operation_id,target_revision,reason,state FROM admission_barriers WHERE trunk_id=?", trunkID).Scan(&existing.TrunkID, &existing.OperationID, &existing.TargetRevision, &existing.Reason, &existing.State) + if err == nil && existing.State != "released" && existing.OperationID != operationID { + return newAppError(409, "PUBLISH_IN_PROGRESS", "another publication currently owns the admission barrier", map[string]any{"operation_id": existing.OperationID}) + } + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + _, err = tx.ExecContext(ctx, "INSERT INTO admission_barriers(trunk_id,operation_id,target_revision,reason,state,created_at,updated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(trunk_id) DO UPDATE SET operation_id=excluded.operation_id,target_revision=excluded.target_revision,reason=excluded.reason,state='active',updated_at=excluded.updated_at", trunkID, operationID, revision, reason, "active", now, now) + return err + }) +} + +func (s *Store) SetBarrierState(ctx context.Context, trunkID, operationID, state string) error { + if state != "active" && state != "pending" && state != "released" { + return errors.New("invalid barrier state") + } + return s.withTx(ctx, func(tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, "UPDATE admission_barriers SET state=?,updated_at=? WHERE trunk_id=? AND operation_id=?", state, utcString(time.Now()), trunkID, operationID) + return err + }) +} + +// AdmissionAllowed is the single persisted gate an external scheduler can consult before creating a new call. +func (s *Store) AdmissionAllowed(ctx context.Context, trunkID string) (bool, string, error) { + var activeStatus string + var barrierState sql.NullString + err := s.withRead(ctx, func(db *sql.DB) error { + return db.QueryRowContext(ctx, "SELECT active_status FROM trunks WHERE trunk_id=?", trunkID).Scan(&activeStatus) + }) + if err != nil { + return false, "trunk_not_found", err + } + err = s.withRead(ctx, func(db *sql.DB) error { + return db.QueryRowContext(ctx, "SELECT state FROM admission_barriers WHERE trunk_id=?", trunkID).Scan(&barrierState) + }) + if err == nil && barrierState.Valid && barrierState.String != "released" { + return false, "admission_barrier", nil + } + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return false, "unknown", err + } + if activeStatus != "published" { + return false, "trunk_not_published", nil + } + return true, "ok", nil +} diff --git a/management/backend/cmd/management/main.go b/management/backend/cmd/management/main.go new file mode 100644 index 0000000..40625ae --- /dev/null +++ b/management/backend/cmd/management/main.go @@ -0,0 +1,108 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "io" + "os" + "path/filepath" + "strings" + + management "github.com/rogee/agent-call-management" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func main() { + logrus.SetFormatter(&logrus.JSONFormatter{}) + root := &cobra.Command{Use: "agent-call-management", Short: "Independent SIP management control plane"} + root.AddCommand(serveCommand(), seedCommand(), contractCommand()) + if err := root.Execute(); err != nil { + logrus.Error(err) + os.Exit(1) + } +} + +func configuredViper() *viper.Viper { + v := viper.New() + v.SetEnvPrefix("MANAGEMENT") + v.AutomaticEnv() + v.SetEnvKeyReplacer(stringsReplacer()) + for _, key := range []string{"addr", "mode", "db_path", "mock_cell_apply", "mock_cell_failures", "seed_demo", "allow_private_agent", "agent_allowlist", "allowed_origins", "tokens_json"} { + _ = v.BindEnv(key) + } + return v +} + +func stringsReplacer() *strings.Replacer { return strings.NewReplacer("-", "_") } + +func serveCommand() *cobra.Command { + return &cobra.Command{Use: "serve", Short: "run the Fiber API", RunE: func(cmd *cobra.Command, args []string) error { + cfg := management.LoadConfig(configuredViper()) + store, err := management.NewStore(cfg.DBPath) + if err != nil { + return err + } + defer store.Close() + if cfg.SeedDemo { + if err := store.SeedDemo(context.Background()); err != nil { + return err + } + } + server := management.NewServer(cfg, store) + if err := server.Recover(context.Background()); err != nil { + return err + } + logrus.WithFields(logrus.Fields{"addr": cfg.Addr, "mode": cfg.Mode, "db_path": cfg.DBPath}).Info("starting SIP management API") + return server.Listen() + }} +} + +func seedCommand() *cobra.Command { + return &cobra.Command{Use: "seed-demo", Short: "load synthetic TEST-NET data", RunE: func(cmd *cobra.Command, args []string) error { + cfg := management.LoadConfig(configuredViper()) + store, err := management.NewStore(cfg.DBPath) + if err != nil { + return err + } + defer store.Close() + if err := store.SeedDemo(context.Background()); err != nil { + return err + } + _, _ = io.WriteString(cmd.OutOrStdout(), "demo data loaded; no external systems were contacted\n") + return nil + }} +} + +func contractCommand() *cobra.Command { + return &cobra.Command{Use: "check-contract", Short: "verify the packaged contract snapshot", RunE: func(cmd *cobra.Command, args []string) error { + root, err := os.Getwd() + if err != nil { + return err + } + candidates := []string{ + filepath.Join(root, "contracts", "sip-management.openapi.yaml"), + filepath.Join(root, "..", "contracts", "sip-management.openapi.yaml"), + filepath.Join(root, "management", "contracts", "sip-management.openapi.yaml"), + } + var data []byte + var lastErr error + for _, candidate := range candidates { + data, lastErr = os.ReadFile(candidate) + if lastErr == nil { + break + } + if !os.IsNotExist(lastErr) { + return lastErr + } + } + if lastErr != nil { + return lastErr + } + sum := sha256.Sum256(data) + _, _ = io.WriteString(cmd.OutOrStdout(), hex.EncodeToString(sum[:])+"\n") + return nil + }} +} diff --git a/management/backend/config.go b/management/backend/config.go new file mode 100644 index 0000000..2a762a3 --- /dev/null +++ b/management/backend/config.go @@ -0,0 +1,120 @@ +package management + +import ( + "encoding/json" + "os" + "strings" + + "github.com/spf13/viper" +) + +type TokenSpec struct { + Principal string `json:"principal"` + Realm string `json:"realm"` + Scopes []string `json:"scopes"` + Resources map[string][]string `json:"resources,omitempty"` +} + +type Config struct { + Addr string + Mode string + DBPath string + MockCellApply bool + MockCellFailures map[string]string + SeedDemo bool + AllowPrivateAgent bool + AgentAllowlist map[string]bool + AllowedOrigins map[string]bool + Tokens map[string]TokenSpec +} + +func DefaultConfig() Config { + return Config{ + Addr: "127.0.0.1:8080", Mode: ModeMock, DBPath: "./data/management.db", + MockCellApply: false, MockCellFailures: map[string]string{}, SeedDemo: false, AgentAllowlist: map[string]bool{}, AllowedOrigins: map[string]bool{"http://localhost:3000": true, "http://127.0.0.1:3000": true}, Tokens: defaultTokens(), + } +} + +func defaultTokens() map[string]TokenSpec { + all := []string{"sip.provider.read", "sip.provider.write", "sip.trunk.read", "sip.trunk.write", "sip.trunk.publish", "sip.trunk.disable", "sip.trunk.rollback", "sip.cell.read", "sip.cell.write", "sip.status.read", "sip.statistics.read", "sip.calls.read", "sip.audit.read", "sip.operation.read"} + return map[string]TokenSpec{ + "dev-admin-token": {Principal: "operator:local-admin", Realm: "admin", Scopes: all}, + "dev-config-token": {Principal: "operator:config", Realm: "admin", Scopes: []string{"sip.provider.read", "sip.provider.write", "sip.trunk.read", "sip.trunk.write", "sip.cell.read", "sip.status.read", "sip.statistics.read", "sip.calls.read"}}, + "dev-publisher-token": {Principal: "operator:publisher", Realm: "admin", Scopes: []string{"sip.provider.read", "sip.trunk.read", "sip.trunk.publish", "sip.trunk.disable", "sip.trunk.rollback", "sip.cell.read", "sip.status.read", "sip.operation.read"}}, + "dev-observer-token": {Principal: "operator:observer", Realm: "admin", Scopes: []string{"sip.provider.read", "sip.trunk.read", "sip.cell.read", "sip.status.read", "sip.statistics.read", "sip.calls.read"}}, + "dev-audit-token": {Principal: "operator:auditor", Realm: "admin", Scopes: []string{"sip.provider.read", "sip.trunk.read", "sip.audit.read", "sip.operation.read"}}, + "dev-saas-token": {Principal: "saas:readonly", Realm: "saas", Scopes: []string{"sip.trunk.read"}}, + "dev-scheduler-token": {Principal: "scheduler:ordinary", Realm: "scheduler", Scopes: []string{"sip.trunk.read"}}, + } +} + +func LoadConfig(v *viper.Viper) Config { + cfg := DefaultConfig() + v.SetDefault("addr", cfg.Addr) + v.SetDefault("mode", cfg.Mode) + v.SetDefault("db_path", cfg.DBPath) + v.SetDefault("mock_cell_apply", cfg.MockCellApply) + v.SetDefault("seed_demo", cfg.SeedDemo) + v.SetDefault("allow_private_agent", cfg.AllowPrivateAgent) + v.SetDefault("mock_cell_failures", "") + v.SetDefault("agent_allowlist", "") + v.SetDefault("allowed_origins", "") + v.SetDefault("tokens_json", "") + cfg.Addr = v.GetString("addr") + cfg.Mode = strings.ToLower(v.GetString("mode")) + if cfg.Mode != ModeReal { + cfg.Mode = ModeMock + } + cfg.DBPath = v.GetString("db_path") + cfg.MockCellApply = v.GetBool("mock_cell_apply") + cfg.MockCellFailures = map[string]string{} + for _, item := range strings.Split(v.GetString("mock_cell_failures"), ",") { + parts := strings.SplitN(strings.TrimSpace(item), "=", 2) + if len(parts) == 2 && validID(parts[0]) && parts[1] != "" { + cfg.MockCellFailures[parts[0]] = parts[1] + } + } + cfg.SeedDemo = v.GetBool("seed_demo") + cfg.AllowPrivateAgent = v.GetBool("allow_private_agent") + cfg.AgentAllowlist = map[string]bool{} + for _, host := range strings.Split(v.GetString("agent_allowlist"), ",") { + if host = strings.TrimSpace(strings.ToLower(host)); host != "" { + cfg.AgentAllowlist[host] = true + } + } + cfg.AllowedOrigins = map[string]bool{} + for _, origin := range strings.Split(v.GetString("allowed_origins"), ",") { + if origin = strings.TrimSpace(origin); origin != "" { + cfg.AllowedOrigins[origin] = true + } + } + if len(cfg.AllowedOrigins) == 0 { + cfg.AllowedOrigins = DefaultConfig().AllowedOrigins + } + if raw := v.GetString("tokens_json"); raw != "" { + var tokens map[string]TokenSpec + if json.Unmarshal([]byte(raw), &tokens) == nil && len(tokens) > 0 { + cfg.Tokens = tokens + } + } + return cfg +} + +func ConfigFromEnv() Config { + v := viper.New() + v.SetEnvPrefix("MANAGEMENT") + v.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + v.AutomaticEnv() + return LoadConfig(v) +} + +func (c Config) PublicMode() string { + if c.Mode == ModeReal { + return ModeReal + } + return ModeMock +} + +func EnsureDevTokenWarning(c Config) bool { + return c.Mode == ModeMock && os.Getenv("MANAGEMENT_SUPPRESS_DEV_TOKEN_WARNING") == "" +} diff --git a/management/backend/config_test.go b/management/backend/config_test.go new file mode 100644 index 0000000..7125f7f --- /dev/null +++ b/management/backend/config_test.go @@ -0,0 +1,29 @@ +package management + +import ( + "strings" + "testing" + + "github.com/spf13/viper" +) + +func TestLoadConfigReadsEnvironmentOverrides(t *testing.T) { + t.Setenv("MANAGEMENT_ALLOWED_ORIGINS", "http://127.0.0.1:13000") + t.Setenv("MANAGEMENT_DB_PATH", "/tmp/config-test.db") + v := viper.New() + v.SetEnvPrefix("MANAGEMENT") + v.AutomaticEnv() + v.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + for _, key := range []string{"db_path", "allowed_origins"} { + if err := v.BindEnv(key); err != nil { + t.Fatal(err) + } + } + cfg := LoadConfig(v) + if !cfg.AllowedOrigins["http://127.0.0.1:13000"] { + t.Fatalf("allowed origins = %#v", cfg.AllowedOrigins) + } + if cfg.DBPath != "/tmp/config-test.db" { + t.Fatalf("db path = %q", cfg.DBPath) + } +} diff --git a/management/backend/demo.go b/management/backend/demo.go new file mode 100644 index 0000000..d7e6d11 --- /dev/null +++ b/management/backend/demo.go @@ -0,0 +1,126 @@ +package management + +import ( + "context" + "database/sql" + "errors" + "time" +) + +// SeedDemo creates only synthetic TEST-NET data. It never calls SIP, Cell Agent, MQ, OSS, or cloud APIs. +func (s *Store) SeedDemo(ctx context.Context) error { + now := time.Now().UTC() + if err := s.UpsertEgressPool(ctx, "egress-main", "北京测试出口", []string{"123.56.71.98"}, "unknown", "mock", &now); err != nil { + return err + } + if _, err := s.GetProvider(ctx, "demo-provider"); errors.Is(err, sql.ErrNoRows) { + if _, _, err := s.UpsertProvider(ctx, "demo-provider", ProviderInput{DisplayName: "演示 SIP 供应商", Notes: "synthetic mock fixture", Lifecycle: "active"}, 0, "fixture:demo", "fixture-provider"); err != nil { + return err + } + } else if err != nil { + return err + } + if err := s.SetEgressPoolProvider(ctx, "egress-main", "demo-provider"); err != nil { + return err + } + cells := []struct{ id, name, url string }{{"cell-a", "demo-cell-a", "https://cell-a.mock.invalid"}, {"cell-b", "demo-cell-b", "https://cell-b.mock.invalid"}} + for _, item := range cells { + if _, err := s.GetCell(ctx, item.id); errors.Is(err, sql.ErrNoRows) { + if _, _, err := s.UpsertCell(ctx, item.id, CellConfig{EgressPoolID: "egress-main", CodecCapabilities: []string{"PCMA", "PCMU"}, Status: "healthy", MaxConcurrency: 100, ManagementURL: item.url}, 0, "fixture:demo", "fixture-cell-"+item.id, item.id, item.name, "cn-beijing"); err != nil { + return err + } + } else if err != nil { + return err + } + if err := s.AdvanceCellBoot(ctx, item.id, "demo-boot-1"); err != nil { + return err + } + obs := ObservationInput{ObservationID: "obs-" + item.id, CellID: item.id, BootID: "demo-boot-1", Sequence: 1, ObservedAt: now, ReceivedAt: now, Source: "mock", States: map[string]any{"cell_agent": "healthy", "asterisk": "healthy", "ari": "healthy", "registration": "registered", "media": "healthy"}, Occupancy: map[string]any{"answered": 0, "cps": 0, "capacity": 100}, SampleID: "demo"} + if err := s.InsertObservation(ctx, obs); err != nil { + if appErr, ok := err.(*AppError); !ok || appErr.Code != "STALE_OBSERVATION" { + return err + } + } + } + trunk, err := s.GetTrunk(ctx, "demo-trunk", ModeMock) + if errors.Is(err, sql.ErrNoRows) { + cfg := TrunkConfig{ProviderID: "demo-provider", DisplayName: "演示主线路", Enabled: true, Sip: SipConfig{Host: "198.51.100.20", Port: 5060, Transport: "udp", AuthMode: "ip"}, CodecProfile: CodecProfile{Allowed: []string{"PCMA"}, Preferred: "PCMA"}, CallerIDs: []string{"BD93205882"}, DialPrefix: "7089", EgressPoolID: "egress-main", MaxConcurrency: 100, MaxCPS: 5} + trunk, _, err = s.PutTrunk(ctx, "demo-trunk", cfg, 0, "fixture:demo", "fixture-trunk") + if err != nil { + return err + } + } else if err != nil { + return err + } + if trunk.ActiveRevision == 0 { + cfg, err := s.getTrunkConfig(ctx, "demo-trunk", trunk.LatestRevision) + if err != nil { + return err + } + cells, err := s.compatibleCellIDs(ctx, cfg) + if err != nil { + return err + } + opID := "fixture-publish-demo" + if err := s.ResumeOrStagePublication(ctx, "demo-trunk", trunk.LatestRevision, cfg, cells, opID, "fixture"); err != nil { + return err + } + digest, _, _ := hashJSON(cfg) + for _, cellID := range cells { + if err := s.UpdatePublication(ctx, "demo-trunk", trunk.LatestRevision, cellID, "applied", "", trunk.LatestRevision, digest, opID); err != nil { + return err + } + } + ok, err := s.FinalizePublication(ctx, "demo-trunk", trunk.LatestRevision, cfg, opID, "fixture:demo", "fixture-publish-demo", "publish") + if err != nil { + return err + } + if !ok { + return errors.New("demo publication did not finalize") + } + } + return seedAttempts(ctx, s, now) +} + +func seedAttempts(ctx context.Context, s *Store, now time.Time) error { + for i := 0; i < 7; i++ { + started := now.Add(-time.Duration(7-i) * time.Minute) + a := Attempt{AttemptID: "demo-attempt-" + string(rune('a'+i)), ExecutionID: "demo-execution", CallID: "demo-call-" + string(rune('a'+i)), TenantKey: "demo-tenant", ProviderID: "demo-provider", TrunkID: "demo-trunk", CellID: []string{"cell-a", "cell-b"}[i%2], EgressPoolID: "egress-main", ConfigRevision: 1, Mode: ModeMock, DialedNumber: "15003164745", AttemptStartedAt: &started, OriginStatus: "confirmed", TerminationReason: "no_answer", AIStatus: "not_started", Source: "mock", FactVersion: 1, UpdatedAt: started.Add(2 * time.Minute)} + if i == 6 { + a.OriginStatus = "rejected" + a.AttemptStartedAt = nil + a.UpdatedAt = now.Add(-30 * time.Second) + a.TerminationReason = "admission_rejected" + } + if i < 2 { + answered := started.Add(8 * time.Second) + ended := answered.Add(time.Duration(60+i*60) * time.Second) + a.AnsweredAt = &answered + a.EndedAt = &ended + a.TerminationReason = "completed" + a.AIStatus = "completed" + } + if i >= 2 && i <= 4 { + ended := started.Add(40 * time.Second) + a.EndedAt = &ended + switch i { + case 2: + a.TerminationReason = "busy" + case 3: + a.TerminationReason = "no_answer" + default: + a.EndedAt = nil + a.TerminationReason = "in_progress" + } + } + if i == 5 { + ended := started.Add(35 * time.Second) + a.EndedAt = &ended + a.TerminationReason = "network_error" + } + if err := s.UpsertAttempt(ctx, a); err != nil { + return err + } + } + return nil +} diff --git a/management/backend/errors.go b/management/backend/errors.go new file mode 100644 index 0000000..c80e529 --- /dev/null +++ b/management/backend/errors.go @@ -0,0 +1,38 @@ +package management + +import ( + "database/sql" + "errors" + "fmt" +) + +type AppError struct { + Status int + Code string + Message string + Fields map[string]any +} + +func newAppError(status int, code, message string, fields map[string]any) *AppError { + return &AppError{Status: status, Code: code, Message: message, Fields: fields} +} + +func (e *AppError) Error() string { + if e == nil { + return "" + } + return fmt.Sprintf("%s: %s", e.Code, e.Message) +} + +func asAppError(err error) *AppError { + if err == nil { + return nil + } + if appErr, ok := err.(*AppError); ok { + return appErr + } + if errors.Is(err, sql.ErrNoRows) { + return newAppError(404, "RESOURCE_NOT_FOUND", "resource does not exist", nil) + } + return newAppError(500, "INTERNAL_ERROR", "internal server error", nil) +} diff --git a/management/backend/go.mod b/management/backend/go.mod new file mode 100644 index 0000000..4d9ff7b --- /dev/null +++ b/management/backend/go.mod @@ -0,0 +1,46 @@ +module github.com/rogee/agent-call-management + +go 1.26 + +require ( + github.com/gofiber/fiber/v3 v3.5.0 + github.com/sirupsen/logrus v1.10.2 + github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 + modernc.org/sqlite v1.56.0 +) + +require ( + github.com/andybalholm/brotli v1.2.2 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gofiber/schema v1.8.3 // indirect + github.com/gofiber/utils/v2 v2.4.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/klauspost/compress v1.19.2 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/tinylib/msgp v1.6.4 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.73.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + modernc.org/libc v1.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/management/backend/go.sum b/management/backend/go.sum new file mode 100644 index 0000000..059a7bd --- /dev/null +++ b/management/backend/go.sum @@ -0,0 +1,129 @@ +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gofiber/fiber/v3 v3.5.0 h1:dk7TOUH6DXJGtOLsN2XEG+0ZML7cznzHILTVozbNEK8= +github.com/gofiber/fiber/v3 v3.5.0/go.mod h1:GOVDTW+gjJvfe0iJyVujbQ1Lnx+JUjFySJRI/9/xX/w= +github.com/gofiber/schema v1.8.3 h1:06ZedxIYjngzc0095PYy7uWnFnbRflWFpikvZH61fDc= +github.com/gofiber/schema v1.8.3/go.mod h1:jWnnZdhcW1mHyV+VnfRxKJDPNcepJsTZ9RIWxrr32Ng= +github.com/gofiber/utils/v2 v2.4.1 h1:E2X9G8O5Mn7b2GDb0JU3IUk42Rw2npuhhepIbuJQ2po= +github.com/gofiber/utils/v2 v2.4.1/go.mod h1:I+RTsgMUdzFuifVc3LOEkfh32wQW9BfRl7l5RYjamW4= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/shamaton/msgpack/v3 v3.2.0 h1:1q2Ms+MWmuRju+PuDMSFDB7p7621npeX4zprJN5Zck8= +github.com/shamaton/msgpack/v3 v3.2.0/go.mod h1:sgBYvEiyz8JR1NC3yGRoPVME9xXovpnh3l/plW1nfRo= +github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= +github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.73.0 h1:ocTOORnBWtJ+P8t/6wAjdkchMzdfHmWx2VD/DPbgZ7s= +github.com/valyala/fasthttp v1.73.0/go.mod h1:EtXQDHaR+5P18p8wqDRFpUhxr108Ga9mXvVJXHRrN2k= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +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/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= +modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/management/backend/internal/db/db.go b/management/backend/internal/db/db.go new file mode 100644 index 0000000..f43598b --- /dev/null +++ b/management/backend/internal/db/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package db + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/management/backend/internal/db/models.go b/management/backend/internal/db/models.go new file mode 100644 index 0000000..1ec9900 --- /dev/null +++ b/management/backend/internal/db/models.go @@ -0,0 +1,185 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package db + +import ( + "database/sql" +) + +type AdmissionBarrier struct { + TrunkID string `json:"trunk_id"` + OperationID string `json:"operation_id"` + TargetRevision int64 `json:"target_revision"` + Reason string `json:"reason"` + State string `json:"state"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type Attempt struct { + AttemptID string `json:"attempt_id"` + ExecutionID string `json:"execution_id"` + CallID string `json:"call_id"` + TenantKey string `json:"tenant_key"` + ProviderID string `json:"provider_id"` + TrunkID string `json:"trunk_id"` + CellID string `json:"cell_id"` + EgressPoolID string `json:"egress_pool_id"` + ConfigRevision int64 `json:"config_revision"` + Mode string `json:"mode"` + DialedNumber string `json:"dialed_number"` + AttemptStartedAt sql.NullString `json:"attempt_started_at"` + OriginStatus string `json:"origin_status"` + AnsweredAt sql.NullString `json:"answered_at"` + EndedAt sql.NullString `json:"ended_at"` + TerminationReason string `json:"termination_reason"` + SipCode sql.NullInt64 `json:"sip_code"` + Q850 string `json:"q850"` + AiStatus string `json:"ai_status"` + Source string `json:"source"` + FactVersion int64 `json:"fact_version"` + UpdatedAt string `json:"updated_at"` +} + +type AttemptEvent struct { + EventID string `json:"event_id"` + AttemptID string `json:"attempt_id"` + EventType string `json:"event_type"` + EventTime string `json:"event_time"` + ReceivedAt string `json:"received_at"` + Source string `json:"source"` + FactVersion int64 `json:"fact_version"` + PayloadJson string `json:"payload_json"` +} + +type AuditEntry struct { + AuditID string `json:"audit_id"` + ResourceType string `json:"resource_type"` + ResourceID string `json:"resource_id"` + Action string `json:"action"` + Revision int64 `json:"revision"` + Actor string `json:"actor"` + RequestID sql.NullString `json:"request_id"` + DetailsJson string `json:"details_json"` + CreatedAt string `json:"created_at"` +} + +type Cell struct { + CellID string `json:"cell_id"` + Revision int64 `json:"revision"` + ConfigJson string `json:"config_json"` + CloudInstanceID string `json:"cloud_instance_id"` + InstanceName string `json:"instance_name"` + Region string `json:"region"` + BootID string `json:"boot_id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + UpdatedBy string `json:"updated_by"` +} + +type EgressPool struct { + EgressPoolID string `json:"egress_pool_id"` + ProviderID string `json:"provider_id"` + DisplayName string `json:"display_name"` + FixedIpsJson string `json:"fixed_ips_json"` + WhitelistStatus string `json:"whitelist_status"` + WhitelistCheckedAt sql.NullString `json:"whitelist_checked_at"` + Source string `json:"source"` + UpdatedAt string `json:"updated_at"` +} + +type Observation struct { + ObservationID string `json:"observation_id"` + CellID string `json:"cell_id"` + TrunkID sql.NullString `json:"trunk_id"` + BootID string `json:"boot_id"` + Sequence int64 `json:"sequence"` + ObservedAt string `json:"observed_at"` + ReceivedAt string `json:"received_at"` + Source string `json:"source"` + ConfigRevision int64 `json:"config_revision"` + StatesJson string `json:"states_json"` + OccupancyJson string `json:"occupancy_json"` + SampleID string `json:"sample_id"` +} + +type Operation struct { + OperationID string `json:"operation_id"` + Actor string `json:"actor"` + Action string `json:"action"` + ResourceType string `json:"resource_type"` + ResourceID string `json:"resource_id"` + RequestID string `json:"request_id"` + RequestDigest string `json:"request_digest"` + ExpectedRevision int64 `json:"expected_revision"` + State string `json:"state"` + HttpStatus int64 `json:"http_status"` + ResponseJson sql.NullString `json:"response_json"` + ErrorJson sql.NullString `json:"error_json"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + ExpiresAt string `json:"expires_at"` +} + +type Provider struct { + ProviderID string `json:"provider_id"` + DisplayName string `json:"display_name"` + Notes string `json:"notes"` + Lifecycle string `json:"lifecycle"` + Revision int64 `json:"revision"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + CreatedBy string `json:"created_by"` + UpdatedBy string `json:"updated_by"` +} + +type Publication struct { + TrunkID string `json:"trunk_id"` + Revision int64 `json:"revision"` + CellID string `json:"cell_id"` + Status string `json:"status"` + ErrorCode sql.NullString `json:"error_code"` + TargetDigest string `json:"target_digest"` + LocalRevision int64 `json:"local_revision"` + LocalDigest string `json:"local_digest"` + AppliedAt sql.NullString `json:"applied_at"` + OperationID string `json:"operation_id"` + UpdatedAt string `json:"updated_at"` +} + +type SchemaMigration struct { + Version int64 `json:"version"` + AppliedAt string `json:"applied_at"` +} + +type Trunk struct { + TrunkID string `json:"trunk_id"` + ProviderID string `json:"provider_id"` + LatestRevision int64 `json:"latest_revision"` + ActiveRevision int64 `json:"active_revision"` + ActiveStatus string `json:"active_status"` + UpdatedAt string `json:"updated_at"` + UpdatedBy string `json:"updated_by"` +} + +type TrunkVerification struct { + TrunkID string `json:"trunk_id"` + Revision int64 `json:"revision"` + CheckName string `json:"check_name"` + Result string `json:"result"` + EvidenceRef string `json:"evidence_ref"` + CheckedBy string `json:"checked_by"` + CheckedAt string `json:"checked_at"` +} + +type TrunkVersion struct { + TrunkID string `json:"trunk_id"` + Revision int64 `json:"revision"` + State string `json:"state"` + ConfigJson string `json:"config_json"` + ConfigSha256 string `json:"config_sha256"` + CreatedAt string `json:"created_at"` + CreatedBy string `json:"created_by"` +} diff --git a/management/backend/internal/db/providers.sql.go b/management/backend/internal/db/providers.sql.go new file mode 100644 index 0000000..03e1415 --- /dev/null +++ b/management/backend/internal/db/providers.sql.go @@ -0,0 +1,89 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: providers.sql + +package db + +import ( + "context" +) + +const getProviderRow = `-- name: GetProviderRow :one +SELECT provider_id, display_name, notes, lifecycle, revision, created_at, updated_at, created_by, updated_by +FROM providers +WHERE provider_id = ? +` + +func (q *Queries) GetProviderRow(ctx context.Context, providerID string) (Provider, error) { + row := q.db.QueryRowContext(ctx, getProviderRow, providerID) + var i Provider + err := row.Scan( + &i.ProviderID, + &i.DisplayName, + &i.Notes, + &i.Lifecycle, + &i.Revision, + &i.CreatedAt, + &i.UpdatedAt, + &i.CreatedBy, + &i.UpdatedBy, + ) + return i, err +} + +const listProviderRows = `-- name: ListProviderRows :many +SELECT p.provider_id, p.display_name, p.notes, p.lifecycle, p.revision, p.created_at, p.updated_at, p.created_by, p.updated_by, + COUNT(t.trunk_id) AS trunk_count +FROM providers p +LEFT JOIN trunks t ON t.provider_id = p.provider_id +GROUP BY p.provider_id, p.display_name, p.notes, p.lifecycle, p.revision, p.created_at, p.updated_at, p.created_by, p.updated_by +ORDER BY p.updated_at DESC, p.provider_id ASC +` + +type ListProviderRowsRow struct { + ProviderID string `json:"provider_id"` + DisplayName string `json:"display_name"` + Notes string `json:"notes"` + Lifecycle string `json:"lifecycle"` + Revision int64 `json:"revision"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + CreatedBy string `json:"created_by"` + UpdatedBy string `json:"updated_by"` + TrunkCount int64 `json:"trunk_count"` +} + +func (q *Queries) ListProviderRows(ctx context.Context) ([]ListProviderRowsRow, error) { + rows, err := q.db.QueryContext(ctx, listProviderRows) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListProviderRowsRow{} + for rows.Next() { + var i ListProviderRowsRow + if err := rows.Scan( + &i.ProviderID, + &i.DisplayName, + &i.Notes, + &i.Lifecycle, + &i.Revision, + &i.CreatedAt, + &i.UpdatedAt, + &i.CreatedBy, + &i.UpdatedBy, + &i.TrunkCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/management/backend/migrations/001_init.sql b/management/backend/migrations/001_init.sql new file mode 100644 index 0000000..1596776 --- /dev/null +++ b/management/backend/migrations/001_init.sql @@ -0,0 +1,195 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS providers ( + provider_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + notes TEXT NOT NULL DEFAULT '', + lifecycle TEXT NOT NULL CHECK (lifecycle IN ('active','archived')), + revision INTEGER NOT NULL CHECK (revision >= 1), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + created_by TEXT NOT NULL, + updated_by TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS egress_pools ( + egress_pool_id TEXT PRIMARY KEY, + provider_id TEXT NOT NULL DEFAULT '', + display_name TEXT NOT NULL, + fixed_ips_json TEXT NOT NULL DEFAULT '[]', + whitelist_status TEXT NOT NULL DEFAULT 'unknown', + whitelist_checked_at TEXT, + source TEXT NOT NULL DEFAULT 'mock', + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS cells ( + cell_id TEXT PRIMARY KEY, + revision INTEGER NOT NULL CHECK (revision >= 1), + config_json TEXT NOT NULL, + cloud_instance_id TEXT NOT NULL DEFAULT '', + instance_name TEXT NOT NULL DEFAULT '', + region TEXT NOT NULL DEFAULT '', + boot_id TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + updated_by TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS trunks ( + trunk_id TEXT PRIMARY KEY, + provider_id TEXT NOT NULL REFERENCES providers(provider_id), + latest_revision INTEGER NOT NULL CHECK (latest_revision >= 1), + active_revision INTEGER NOT NULL DEFAULT 0 CHECK (active_revision >= 0), + active_status TEXT NOT NULL CHECK (active_status IN ('draft','published','disabled')), + updated_at TEXT NOT NULL, + updated_by TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS trunk_versions ( + trunk_id TEXT NOT NULL REFERENCES trunks(trunk_id) ON DELETE CASCADE, + revision INTEGER NOT NULL CHECK (revision >= 1), + state TEXT NOT NULL CHECK (state IN ('draft','publishing','published','superseded')), + config_json TEXT NOT NULL, + config_sha256 TEXT NOT NULL, + created_at TEXT NOT NULL, + created_by TEXT NOT NULL, + PRIMARY KEY (trunk_id, revision) +); + +CREATE TABLE IF NOT EXISTS admission_barriers ( + trunk_id TEXT PRIMARY KEY REFERENCES trunks(trunk_id) ON DELETE CASCADE, + operation_id TEXT NOT NULL, + target_revision INTEGER NOT NULL, + reason TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('active','released','pending')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS publications ( + trunk_id TEXT NOT NULL REFERENCES trunks(trunk_id) ON DELETE CASCADE, + revision INTEGER NOT NULL, + cell_id TEXT NOT NULL REFERENCES cells(cell_id), + status TEXT NOT NULL CHECK (status IN ('pending','applied','failed')), + error_code TEXT, + target_digest TEXT NOT NULL, + local_revision INTEGER NOT NULL DEFAULT 0, + local_digest TEXT NOT NULL DEFAULT '', + applied_at TEXT, + operation_id TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (trunk_id, revision, cell_id) +); + +CREATE TABLE IF NOT EXISTS operations ( + operation_id TEXT PRIMARY KEY, + actor TEXT NOT NULL, + action TEXT NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + request_id TEXT NOT NULL, + request_digest TEXT NOT NULL, + expected_revision INTEGER NOT NULL DEFAULT 0, + state TEXT NOT NULL CHECK (state IN ('in_progress','succeeded','failed','pending','needs_reconciliation')), + http_status INTEGER NOT NULL DEFAULT 200, + response_json TEXT, + error_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + UNIQUE(actor, action, resource_type, resource_id, request_id) +); + +CREATE TABLE IF NOT EXISTS observations ( + observation_id TEXT PRIMARY KEY, + cell_id TEXT NOT NULL REFERENCES cells(cell_id) ON DELETE CASCADE, + trunk_id TEXT, + boot_id TEXT NOT NULL, + sequence INTEGER NOT NULL, + observed_at TEXT NOT NULL, + received_at TEXT NOT NULL, + source TEXT NOT NULL, + config_revision INTEGER NOT NULL DEFAULT 0, + states_json TEXT NOT NULL, + occupancy_json TEXT NOT NULL DEFAULT '{}', + sample_id TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX IF NOT EXISTS idx_observations_cell_trunk_received + ON observations(cell_id, trunk_id, received_at DESC); +CREATE INDEX IF NOT EXISTS idx_observations_boot_sequence + ON observations(cell_id, boot_id, sequence DESC); + +CREATE TABLE IF NOT EXISTS attempts ( + attempt_id TEXT PRIMARY KEY, + execution_id TEXT NOT NULL DEFAULT '', + call_id TEXT NOT NULL DEFAULT '', + tenant_key TEXT NOT NULL DEFAULT '', + provider_id TEXT NOT NULL DEFAULT '', + trunk_id TEXT NOT NULL DEFAULT '', + cell_id TEXT NOT NULL DEFAULT '', + egress_pool_id TEXT NOT NULL DEFAULT '', + config_revision INTEGER NOT NULL DEFAULT 0, + mode TEXT NOT NULL CHECK (mode IN ('mock','real')), + dialed_number TEXT NOT NULL DEFAULT '', + attempt_started_at TEXT, + origin_status TEXT NOT NULL CHECK (origin_status IN ('confirmed','uncertain','not_started','rejected')), + answered_at TEXT, + ended_at TEXT, + termination_reason TEXT NOT NULL DEFAULT 'unknown', + sip_code INTEGER, + q850 TEXT NOT NULL DEFAULT '', + ai_status TEXT NOT NULL DEFAULT 'unknown', + source TEXT NOT NULL DEFAULT 'mock', + fact_version INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_attempts_started ON attempts(attempt_started_at, attempt_id); +CREATE INDEX IF NOT EXISTS idx_attempts_route ON attempts(provider_id, trunk_id, cell_id, mode); +CREATE INDEX IF NOT EXISTS idx_attempts_execution ON attempts(execution_id, call_id); + +CREATE TABLE IF NOT EXISTS attempt_events ( + event_id TEXT PRIMARY KEY, + attempt_id TEXT NOT NULL REFERENCES attempts(attempt_id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + event_time TEXT NOT NULL, + received_at TEXT NOT NULL, + source TEXT NOT NULL, + fact_version INTEGER NOT NULL DEFAULT 1, + payload_json TEXT NOT NULL DEFAULT '{}' +); + +CREATE TABLE IF NOT EXISTS trunk_verifications ( + trunk_id TEXT NOT NULL REFERENCES trunks(trunk_id) ON DELETE CASCADE, + revision INTEGER NOT NULL, + check_name TEXT NOT NULL, + result TEXT NOT NULL CHECK (result IN ('confirmed','failed','unknown','not_applicable')), + evidence_ref TEXT NOT NULL DEFAULT '', + checked_by TEXT NOT NULL, + checked_at TEXT NOT NULL, + PRIMARY KEY (trunk_id, revision, check_name) +); + +CREATE INDEX IF NOT EXISTS idx_verifications_revision ON trunk_verifications(trunk_id, revision, result); + +CREATE TABLE IF NOT EXISTS audit_entries ( + audit_id TEXT PRIMARY KEY, + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + action TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 0, + actor TEXT NOT NULL, + request_id TEXT, + details_json TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_audit_resource ON audit_entries(resource_type, resource_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_request ON audit_entries(request_id); diff --git a/management/backend/models.go b/management/backend/models.go new file mode 100644 index 0000000..51bc54e --- /dev/null +++ b/management/backend/models.go @@ -0,0 +1,217 @@ +package management + +import "time" + +const ( + ModeMock = "mock" + ModeReal = "real" +) + +type ProviderInput struct { + DisplayName string `json:"display_name"` + Notes string `json:"notes,omitempty"` + Lifecycle string `json:"lifecycle"` +} + +type Provider struct { + ProviderID string `json:"provider_id"` + DisplayName string `json:"display_name"` + Notes string `json:"notes,omitempty"` + Lifecycle string `json:"lifecycle"` + Revision int64 `json:"revision"` + TrunkCount int `json:"trunk_count"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + UpdatedBy string `json:"updated_by"` +} + +type SipConfig struct { + Host string `json:"host"` + Port int `json:"port"` + Transport string `json:"transport"` + AuthMode string `json:"auth_mode"` + Register bool `json:"register"` + CredentialRef string `json:"credential_ref,omitempty"` +} + +type CodecProfile struct { + Allowed []string `json:"allowed"` + Preferred string `json:"preferred"` +} + +type TrunkConfig struct { + ProviderID string `json:"provider_id"` + DisplayName string `json:"display_name"` + Enabled bool `json:"enabled"` + Sip SipConfig `json:"sip"` + CodecProfile CodecProfile `json:"codec_profile"` + CallerIDs []string `json:"caller_ids"` + DialPrefix string `json:"dial_prefix"` + EgressPoolID string `json:"egress_pool_id"` + MaxConcurrency int `json:"max_concurrency"` + MaxCPS int `json:"max_cps"` +} + +type CellConfig struct { + EgressPoolID string `json:"egress_pool_id"` + CodecCapabilities []string `json:"codec_capabilities"` + Status string `json:"status"` + MaxConcurrency int `json:"max_concurrency"` + ManagementURL string `json:"management_url,omitempty"` +} + +type Cell struct { + Mode string `json:"mode"` + CellID string `json:"cell_id"` + Revision int64 `json:"revision"` + Config CellConfig `json:"config"` + CloudInstanceID string `json:"cloud_instance_id,omitempty"` + InstanceName string `json:"instance_name,omitempty"` + Region string `json:"region,omitempty"` + BootID string `json:"boot_id,omitempty"` + UpdatedAt string `json:"updated_at"` + UpdatedBy string `json:"updated_by"` +} + +type RevisionInfo struct { + Revision int64 `json:"revision"` + State string `json:"state"` + ConfigSHA256 string `json:"config_sha256,omitempty"` + CreatedAt string `json:"created_at"` + CreatedBy string `json:"created_by"` +} + +type TrunkView struct { + TrunkConfig + TrunkID string `json:"trunk_id"` + CredentialConfigured bool `json:"credential_configured"` + AsteriskAllow []string `json:"asterisk_allow"` + ConfigSHA256 string `json:"config_sha256,omitempty"` +} + +type AdminTrunk struct { + Mode string `json:"mode"` + TrunkID string `json:"trunk_id"` + ProviderID string `json:"provider_id"` + LatestRevision int64 `json:"latest_revision"` + ActiveRevision int64 `json:"active_revision"` + ActiveStatus string `json:"active_status"` + Status string `json:"status"` + UpdatedAt string `json:"updated_at"` + CompatibleCellIDs []string `json:"compatible_cell_ids"` + Latest *TrunkView `json:"latest"` + Active *TrunkView `json:"active"` + Versions []RevisionInfo `json:"versions"` +} + +type ReadonlyTrunk struct { + Mode string `json:"mode"` + TrunkID string `json:"trunk_id"` + ProviderID string `json:"provider_id"` + Revision int64 `json:"revision"` + Status string `json:"status"` + UpdatedAt string `json:"updated_at"` + Config *TrunkView `json:"config"` +} + +type Publication struct { + TrunkID string `json:"trunk_id"` + Revision int64 `json:"revision"` + CellID string `json:"cell_id"` + Status string `json:"status"` + ErrorCode *string `json:"error_code"` + AppliedAt *string `json:"applied_at,omitempty"` + TargetDigest string `json:"target_digest,omitempty"` + LocalRevision int64 `json:"local_revision,omitempty"` + LocalDigest string `json:"local_digest,omitempty"` + UpdatedAt string `json:"updated_at"` + OperationID string `json:"operation_id,omitempty"` +} + +type AuditEntry struct { + AuditID string `json:"audit_id"` + ResourceType string `json:"resource_type"` + ResourceID string `json:"resource_id"` + Action string `json:"action"` + Revision int64 `json:"revision"` + Actor string `json:"actor"` + RequestID *string `json:"request_id"` + DetailsJSON string `json:"details_json"` + CreatedAt string `json:"created_at"` +} + +type ObservationInput struct { + ObservationID string `json:"observation_id"` + CellID string `json:"cell_id"` + TrunkID string `json:"trunk_id,omitempty"` + BootID string `json:"boot_id"` + Sequence int64 `json:"sequence"` + ObservedAt time.Time `json:"observed_at"` + ReceivedAt time.Time `json:"received_at"` + Source string `json:"source"` + ConfigRevision int64 `json:"config_revision"` + States map[string]any `json:"states"` + Occupancy map[string]any `json:"occupancy,omitempty"` + SampleID string `json:"sample_id,omitempty"` +} + +type Attempt struct { + AttemptID string `json:"attempt_id"` + ExecutionID string `json:"execution_id,omitempty"` + CallID string `json:"call_id,omitempty"` + TenantKey string `json:"tenant_key,omitempty"` + ProviderID string `json:"provider_id,omitempty"` + TrunkID string `json:"trunk_id,omitempty"` + CellID string `json:"cell_id,omitempty"` + EgressPoolID string `json:"egress_pool_id,omitempty"` + ConfigRevision int64 `json:"config_revision,omitempty"` + Mode string `json:"mode"` + DialedNumber string `json:"dialed_number,omitempty"` + AttemptStartedAt *time.Time `json:"attempt_started_at,omitempty"` + OriginStatus string `json:"origin_status"` + AnsweredAt *time.Time `json:"answered_at,omitempty"` + EndedAt *time.Time `json:"ended_at,omitempty"` + TerminationReason string `json:"termination_reason"` + SIPCode *int `json:"sip_code,omitempty"` + Q850 string `json:"q850,omitempty"` + AIStatus string `json:"ai_status,omitempty"` + Source string `json:"source"` + FactVersion int64 `json:"fact_version"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ErrorBody struct { + Code string `json:"code"` + Message string `json:"message"` + Fields map[string]any `json:"fields,omitempty"` + RequestID string `json:"request_id,omitempty"` +} + +type errorResponse struct { + Error ErrorBody `json:"error"` +} + +type VerificationInput struct { + CheckName string `json:"check_name"` + Result string `json:"result"` + EvidenceRef string `json:"evidence_ref,omitempty"` + CheckedBy string `json:"checked_by"` +} + +type Operation struct { + OperationID string + Actor string + Action string + ResourceType string + ResourceID string + RequestID string + RequestDigest string + ExpectedRevision int64 + State string + HTTPStatus int + ResponseJSON string + ErrorJSON string + CreatedAt string + UpdatedAt string + ExpiresAt string +} diff --git a/management/backend/publication.go b/management/backend/publication.go new file mode 100644 index 0000000..643e4e7 --- /dev/null +++ b/management/backend/publication.go @@ -0,0 +1,477 @@ +package management + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "net/http" + "path" + "strings" + "time" + + "github.com/gofiber/fiber/v3" +) + +type barrier struct { + TrunkID string + OperationID string + TargetRevision int64 + Reason string + State string +} + +type cellApplyRequest struct { + Mode string `json:"mode"` + CellID string `json:"cell_id"` + TrunkID string `json:"trunk_id"` + Revision int64 `json:"revision"` + ExpectedLocalRevision int64 `json:"expected_local_revision"` + Config map[string]any `json:"config"` + ConfigSHA256 string `json:"config_sha256"` +} + +type cellApplyAck struct { + Mode string `json:"mode"` + CellID string `json:"cell_id"` + TrunkID string `json:"trunk_id"` + Revision int64 `json:"revision"` + ConfigSHA256 string `json:"config_sha256"` + Status string `json:"status"` + Idempotent bool `json:"idempotent"` +} + +func (s *Store) GetBarrier(ctx context.Context, trunkID string) (barrier, error) { + var b barrier + err := s.withRead(ctx, func(db *sql.DB) error { + return db.QueryRowContext(ctx, "SELECT trunk_id,operation_id,target_revision,reason,state FROM admission_barriers WHERE trunk_id=?", trunkID).Scan(&b.TrunkID, &b.OperationID, &b.TargetRevision, &b.Reason, &b.State) + }) + return b, err +} + +func (s *Store) ResumeOrStagePublication(ctx context.Context, trunkID string, revision int64, cfg TrunkConfig, cellIDs []string, operationID, reason string) error { + digest, _, err := hashJSON(cfg) + if err != nil { + return err + } + now := utcString(time.Now()) + return s.withTx(ctx, func(tx *sql.Tx) error { + var existing barrier + err := tx.QueryRowContext(ctx, "SELECT trunk_id,operation_id,target_revision,reason,state FROM admission_barriers WHERE trunk_id=?", trunkID).Scan(&existing.TrunkID, &existing.OperationID, &existing.TargetRevision, &existing.Reason, &existing.State) + hasBarrier := err == nil + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if hasBarrier && existing.State == "active" && existing.OperationID != operationID { + return newAppError(409, "PUBLISH_IN_PROGRESS", "another publication currently owns the admission barrier", map[string]any{"operation_id": existing.OperationID}) + } + if hasBarrier && existing.TargetRevision != revision && existing.State != "released" && existing.OperationID != operationID { + return newAppError(409, "PUBLISH_IN_PROGRESS", "an older publication must be reconciled before this resource changes", map[string]any{"target_revision": existing.TargetRevision}) + } + if _, err := tx.ExecContext(ctx, "INSERT INTO admission_barriers(trunk_id,operation_id,target_revision,reason,state,created_at,updated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(trunk_id) DO UPDATE SET operation_id=excluded.operation_id,target_revision=excluded.target_revision,reason=excluded.reason,state='active',updated_at=excluded.updated_at", trunkID, operationID, revision, reason, "active", now, now); err != nil { + return err + } + var count int + if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM publications WHERE trunk_id=? AND revision=?", trunkID, revision).Scan(&count); err != nil { + return err + } + if count == 0 { + if len(cellIDs) == 0 { + return newAppError(409, "NO_CAPABLE_CELL", "no compatible Cell is available", nil) + } + for _, cellID := range cellIDs { + var localRevision int64 + if err := tx.QueryRowContext(ctx, "SELECT COALESCE(MAX(local_revision),0) FROM publications WHERE cell_id=? AND status='applied'", cellID).Scan(&localRevision); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, "INSERT INTO publications(trunk_id,revision,cell_id,status,error_code,target_digest,local_revision,local_digest,operation_id,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)", trunkID, revision, cellID, "pending", nil, digest, localRevision, "", operationID, now); err != nil { + return err + } + } + } else { + var mismatched int + if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM publications WHERE trunk_id=? AND revision=? AND target_digest<>?", trunkID, revision, digest).Scan(&mismatched); err != nil { + return err + } + if mismatched > 0 { + return newAppError(409, "PUBLICATION_DIGEST_MISMATCH", "stored publication snapshot differs from the requested revision", nil) + } + if len(cellIDs) > 0 { + var expectedCount int + if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM publications WHERE trunk_id=? AND revision=? AND cell_id IN ("+placeholders(len(cellIDs))+")", append([]any{trunkID, revision}, stringsToAny(cellIDs)...)...).Scan(&expectedCount); err != nil { + return err + } + if expectedCount != len(cellIDs) { + return newAppError(409, "DEPLOYMENT_SET_CHANGED", "the frozen Cell deployment set changed; revalidate before retry", nil) + } + } + if _, err := tx.ExecContext(ctx, "UPDATE publications SET operation_id=?,updated_at=? WHERE trunk_id=? AND revision=?", operationID, now, trunkID, revision); err != nil { + return err + } + } + _, err = tx.ExecContext(ctx, "UPDATE trunk_versions SET state='publishing' WHERE trunk_id=? AND revision=?", trunkID, revision) + return err + }) +} + +func placeholders(n int) string { + if n <= 0 { + return "?" + } + return strings.TrimSuffix(strings.Repeat("?,", n), ",") +} +func stringsToAny(v []string) []any { + out := make([]any, len(v)) + for i, x := range v { + out[i] = x + } + return out +} + +func (s *Server) publishTrunk(c fiber.Ctx) error { + return s.executePublicationRequest(c, "publish") +} + +func (s *Server) executePublicationRequest(c fiber.Ctx, action string) error { + id := c.Params("trunk_id") + scope := "sip.trunk.publish" + if action == "disable" { + scope = "sip.trunk.disable" + } + if action == "rollback" { + scope = "sip.trunk.rollback" + } + p, authErr := s.authorize(c, "admin", scope, "trunk", id) + if authErr != nil { + return s.fail(c, authErr) + } + expected, err := parseIfMatch(c) + if err != nil { + return s.fail(c, err) + } + requestID, err := requireHeader(c, "X-Request-ID") + if err != nil { + return s.fail(c, err) + } + var input struct { + TargetRevision int64 `json:"target_revision"` + } + if action == "rollback" { + if err := decodeJSON(c, &input); err != nil { + return s.fail(c, err) + } + if input.TargetRevision < 1 { + return s.fail(c, newAppError(422, "TARGET_REVISION_REQUIRED", "target_revision must be positive", nil)) + } + } + ctx, cancel := s.context(c) + defer cancel() + payload := map[string]any{"if_match": expected} + if action == "rollback" { + payload["target_revision"] = input.TargetRevision + } + op, replay, beginErr := s.beginOperation(ctx, p, "trunk."+action, "trunk", id, requestID, payload, expected) + if replay || beginErr != nil { + return s.replayOrError(c, op, beginErr) + } + if action == "disable" { + return s.disableWithOperation(c, ctx, p, op, id, expected, requestID) + } + if action == "rollback" { + return s.rollbackWithOperation(c, ctx, p, op, id, expected, input.TargetRevision, requestID) + } + trunk, opErr := s.applyRevision(ctx, id, expected, op.OperationID, p.Principal, requestID, "publish", true) + if opErr != nil { + return s.finishError(ctx, op, opErr, c) + } + if err := s.finishSuccess(ctx, op, 200, trunk); err != nil { + return s.fail(c, asAppError(err)) + } + return c.JSON(trunk) +} + +func (s *Server) disableTrunk(c fiber.Ctx) error { return s.executePublicationRequest(c, "disable") } +func (s *Server) rollbackTrunk(c fiber.Ctx) error { return s.executePublicationRequest(c, "rollback") } + +func (s *Server) disableWithOperation(c fiber.Ctx, ctx context.Context, p principal, op Operation, id string, expected int64, requestID string) error { + current, err := s.store.GetTrunk(ctx, id, s.cfg.PublicMode()) + if errors.Is(err, sql.ErrNoRows) { + return s.finishError(ctx, op, newAppError(404, "TRUNK_NOT_FOUND", "trunk does not exist", nil), c) + } + if err != nil { + return s.finishError(ctx, op, asAppError(err), c) + } + if current.LatestRevision != expected { + return s.finishError(ctx, op, newAppError(409, "REVISION_CONFLICT", "trunk revision changed", map[string]any{"expected_revision": expected, "current_revision": current.LatestRevision}), c) + } + cfg, err := s.store.getTrunkConfig(ctx, id, current.LatestRevision) + if err != nil { + return s.finishError(ctx, op, asAppError(err), c) + } + if current.ActiveRevision > 0 && current.Status == "disabled" && !cfg.Enabled { + return s.finishOperationResponse(ctx, op, current, c) + } + if err := s.store.CreateAdmissionIntent(ctx, id, current.ActiveRevision, op.OperationID, "disable"); err != nil { + return s.finishError(ctx, op, asAppError(err), c) + } + cfg.Enabled = false + created, status, err := s.store.PutTrunk(ctx, id, cfg, expected, p.Principal, requestID) + _ = status + if err != nil { + _ = s.store.SetBarrierState(ctx, id, op.OperationID, "pending") + return s.finishError(ctx, op, asAppError(err), c) + } + trunk, opErr := s.applyRevision(ctx, id, created.LatestRevision, op.OperationID, p.Principal, requestID, "disable", false) + if opErr != nil { + return s.finishError(ctx, op, opErr, c) + } + if err := s.finishSuccess(ctx, op, 200, trunk); err != nil { + return s.fail(c, asAppError(err)) + } + return c.JSON(trunk) +} + +func (s *Server) rollbackWithOperation(c fiber.Ctx, ctx context.Context, p principal, op Operation, id string, expected, target int64, requestID string) error { + current, err := s.store.GetTrunk(ctx, id, s.cfg.PublicMode()) + if errors.Is(err, sql.ErrNoRows) { + return s.finishError(ctx, op, newAppError(404, "TRUNK_NOT_FOUND", "trunk does not exist", nil), c) + } + if err != nil { + return s.finishError(ctx, op, asAppError(err), c) + } + if current.LatestRevision != expected { + return s.finishError(ctx, op, newAppError(409, "REVISION_CONFLICT", "trunk revision changed", map[string]any{"expected_revision": expected, "current_revision": current.LatestRevision}), c) + } + cfg, err := s.store.getTrunkConfig(ctx, id, target) + if errors.Is(err, sql.ErrNoRows) { + return s.finishError(ctx, op, newAppError(409, "VERSION_NOT_FOUND", "target revision does not exist", nil), c) + } + if err != nil { + return s.finishError(ctx, op, asAppError(err), c) + } + if err := s.store.CreateAdmissionIntent(ctx, id, current.ActiveRevision, op.OperationID, "rollback"); err != nil { + return s.finishError(ctx, op, asAppError(err), c) + } + created, _, err := s.store.PutTrunk(ctx, id, cfg, expected, p.Principal, requestID) + if err != nil { + _ = s.store.SetBarrierState(ctx, id, op.OperationID, "pending") + return s.finishError(ctx, op, asAppError(err), c) + } + trunk, opErr := s.applyRevision(ctx, id, created.LatestRevision, op.OperationID, p.Principal, requestID, "rollback", false) + if opErr != nil { + return s.finishError(ctx, op, opErr, c) + } + if err := s.finishSuccess(ctx, op, 200, trunk); err != nil { + return s.fail(c, asAppError(err)) + } + return c.JSON(trunk) +} + +func (s *Server) finishOperationResponse(ctx context.Context, op Operation, value any, c fiber.Ctx) error { + if err := s.finishSuccess(ctx, op, 200, value); err != nil { + return s.fail(c, asAppError(err)) + } + return c.JSON(value) +} + +func (s *Server) applyRevision(ctx context.Context, trunkID string, revision int64, operationID, actor, requestID, action string, publishOnlyEnabled bool) (AdminTrunk, *AppError) { + trunk, err := s.store.GetTrunk(ctx, trunkID, s.cfg.PublicMode()) + if errors.Is(err, sql.ErrNoRows) { + return AdminTrunk{}, newAppError(404, "TRUNK_NOT_FOUND", "trunk does not exist", nil) + } + if err != nil { + return AdminTrunk{}, asAppError(err) + } + if trunk.LatestRevision != revision { + return AdminTrunk{}, newAppError(409, "REVISION_CONFLICT", "trunk revision changed", map[string]any{"current_revision": trunk.LatestRevision}) + } + if action == "publish" && trunk.ActiveRevision == revision && trunk.ActiveStatus == "published" { + trunk.Mode = s.cfg.PublicMode() + return trunk, nil + } + cfg, err := s.store.getTrunkConfig(ctx, trunkID, revision) + if errors.Is(err, sql.ErrNoRows) { + return AdminTrunk{}, newAppError(409, "VERSION_NOT_FOUND", "trunk version does not exist", nil) + } + if err != nil { + return AdminTrunk{}, asAppError(err) + } + if publishOnlyEnabled && !cfg.Enabled { + return AdminTrunk{}, newAppError(409, "TRUNK_DISABLED", "disabled config cannot be published", nil) + } + if s.cfg.Mode == ModeReal { + complete, missing, verificationErr := s.store.RealVerificationComplete(ctx, trunkID, revision) + if verificationErr != nil { + return AdminTrunk{}, asAppError(verificationErr) + } + if !complete { + return AdminTrunk{}, newAppError(409, "REAL_VERIFICATION_INCOMPLETE", "real publication requires confirmed provider/line verification records", map[string]any{"missing_checks": missing}) + } + } + if err := s.store.validateTrunkConfig(ctx, cfg, s.cfg.Mode == ModeReal); err != nil { + return AdminTrunk{}, asAppError(err) + } + pubs, err := s.store.GetPublicationRows(ctx, trunkID, &revision) + if err != nil { + return AdminTrunk{}, asAppError(err) + } + cellIDs := make([]string, 0, len(pubs)) + for _, pub := range pubs { + cellIDs = append(cellIDs, pub.CellID) + } + if len(cellIDs) == 0 { + cellIDs, err = s.store.compatibleCellIDs(ctx, cfg) + if err != nil { + return AdminTrunk{}, asAppError(err) + } + } + if s.cfg.Mode == ModeReal { + if observationErr := s.realCellObservationError(ctx, trunkID, cellIDs); observationErr != nil { + return AdminTrunk{}, observationErr + } + } + if err := s.store.ResumeOrStagePublication(ctx, trunkID, revision, cfg, cellIDs, operationID, action); err != nil { + return AdminTrunk{}, asAppError(err) + } + pubs, err = s.store.GetPublicationRows(ctx, trunkID, &revision) + if err != nil { + return AdminTrunk{}, asAppError(err) + } + var failures []map[string]any + pending := false + for _, pub := range pubs { + if pub.Status == "applied" && pub.LocalRevision == revision && pub.LocalDigest == pub.TargetDigest { + continue + } + cell, err := s.store.GetCell(ctx, pub.CellID) + if err != nil { + failures = append(failures, map[string]any{"cell_id": pub.CellID, "code": "CELL_NOT_FOUND"}) + _ = s.store.UpdatePublication(ctx, trunkID, revision, pub.CellID, "failed", "CELL_NOT_FOUND", 0, "", operationID) + continue + } + ack, applyErr := s.applyToCell(ctx, cell, trunkID, revision, pub.LocalRevision, cfg, pub.TargetDigest, requestID) + if applyErr != nil { + if applyErr.Code == "MOCK_INTENT_ONLY" { + pending = true + continue + } + failures = append(failures, map[string]any{"cell_id": pub.CellID, "code": applyErr.Code}) + _ = s.store.UpdatePublication(ctx, trunkID, revision, pub.CellID, "failed", applyErr.Code, 0, "", operationID) + continue + } + if ack.Mode != s.cfg.PublicMode() || ack.CellID != pub.CellID || ack.TrunkID != trunkID || ack.Revision != revision || ack.ConfigSHA256 != pub.TargetDigest || ack.Status != "applied" { + failures = append(failures, map[string]any{"cell_id": pub.CellID, "code": "ACK_MISMATCH"}) + _ = s.store.UpdatePublication(ctx, trunkID, revision, pub.CellID, "failed", "ACK_MISMATCH", ack.Revision, ack.ConfigSHA256, operationID) + continue + } + _ = s.store.UpdatePublication(ctx, trunkID, revision, pub.CellID, "applied", "", ack.Revision, ack.ConfigSHA256, operationID) + } + if pending && !containsFailure(failures) { + _ = s.store.MarkPublicationFailure(ctx, trunkID, revision, operationID, actor, requestID, action, "MOCK_INTENT_ONLY") + return AdminTrunk{}, newAppError(409, "PUBLISH_PENDING", "mock mode records publication intent but does not claim Cell application", map[string]any{"operation_id": operationID}) + } + if len(failures) > 0 { + _ = s.store.MarkPublicationFailure(ctx, trunkID, revision, operationID, actor, requestID, action, "CELL_APPLY_FAILED") + return AdminTrunk{}, newAppError(409, "PUBLISH_PARTIAL", "not every target Cell acknowledged the exact snapshot; admission remains blocked", map[string]any{"operation_id": operationID, "failures": failures}) + } + ok, err := s.store.FinalizePublication(ctx, trunkID, revision, cfg, operationID, actor, requestID, action) + if err != nil { + return AdminTrunk{}, asAppError(err) + } + if !ok { + return AdminTrunk{}, newAppError(409, "PUBLISH_PENDING", "publication is waiting for Cell acknowledgements", map[string]any{"operation_id": operationID}) + } + trunk, err = s.store.GetTrunk(ctx, trunkID, s.cfg.PublicMode()) + if err != nil { + return AdminTrunk{}, asAppError(err) + } + trunk.Mode = s.cfg.PublicMode() + return trunk, nil +} +func containsFailure(v []map[string]any) bool { return len(v) > 0 } + +func (s *Server) realCellObservationError(ctx context.Context, trunkID string, cellIDs []string) *AppError { + missing := make([]map[string]any, 0) + for _, cellID := range cellIDs { + observations, err := s.store.LatestObservations(ctx, cellID, trunkID) + if err != nil { + return asAppError(err) + } + var current *ObservationInput + for index := range observations { + if observations[index].TrunkID == "" || observations[index].TrunkID == trunkID { + current = &observations[index] + break + } + } + if current == nil { + missing = append(missing, map[string]any{"cell_id": cellID, "reason": "no_current_observation"}) + continue + } + age := time.Since(current.ObservedAt) + if current.ObservedAt.IsZero() || age < 0 || age > 15*time.Second { + missing = append(missing, map[string]any{"cell_id": cellID, "reason": "observation_stale_or_clock_skew", "observed_at": current.ObservedAt}) + continue + } + if sources := missingSources(current.States); len(sources) > 0 { + missing = append(missing, map[string]any{"cell_id": cellID, "reason": "observation_incomplete", "missing_sources": sources}) + } + } + if len(missing) > 0 { + return newAppError(409, "REAL_CELL_OBSERVATION_INCOMPLETE", "real publication requires fresh complete Cell observations", map[string]any{"cells": missing}) + } + return nil +} + +func cellAgentConfig(cfg TrunkConfig) map[string]any { + value := map[string]any{} + data, _ := json.Marshal(cfg) + _ = json.Unmarshal(data, &value) + if sip, ok := value["sip"].(map[string]any); ok && cfg.Sip.CredentialRef == "" { + sip["credential_ref"] = nil + } + return value +} + +func (s *Server) applyToCell(ctx context.Context, cell Cell, trunkID string, revision, expectedLocalRevision int64, cfg TrunkConfig, digest, requestID string) (cellApplyAck, *AppError) { + if s.cfg.Mode == ModeMock { + if failure := s.cfg.MockCellFailures[cell.CellID]; failure != "" { + return cellApplyAck{}, newAppError(502, failure, "configured mock Cell failure", nil) + } + if !s.cfg.MockCellApply { + return cellApplyAck{}, newAppError(409, "MOCK_INTENT_ONLY", "mock publication is intent-only", nil) + } + return cellApplyAck{Mode: ModeMock, CellID: cell.CellID, TrunkID: trunkID, Revision: revision, ConfigSHA256: digest, Status: "applied", Idempotent: false}, nil + } + u, err := validateAgentURL(cell.Config.ManagementURL, s.cfg) + if err != nil { + return cellApplyAck{}, newAppError(503, "CELL_AGENT_UNAVAILABLE", err.Error(), nil) + } + client, err := s.httpClient() + if err != nil { + return cellApplyAck{}, newAppError(503, "MTLS_NOT_CONFIGURED", err.Error(), nil) + } + endpoint := *u + endpoint.Path = path.Join(strings.TrimSuffix(u.Path, "/"), "/v1/sip/trunks/", trunkID, "apply") + payload := cellApplyRequest{Mode: ModeReal, CellID: cell.CellID, TrunkID: trunkID, Revision: revision, ExpectedLocalRevision: expectedLocalRevision, Config: cellAgentConfig(cfg), ConfigSHA256: digest} + body, _ := json.Marshal(payload) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(string(body))) + if err != nil { + return cellApplyAck{}, newAppError(503, "CELL_AGENT_UNAVAILABLE", err.Error(), nil) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Request-ID", requestID) + resp, err := client.Do(req) + if err != nil { + return cellApplyAck{}, newAppError(503, "CELL_AGENT_UNAVAILABLE", "Cell Agent did not acknowledge the snapshot", nil) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return cellApplyAck{}, newAppError(502, "CELL_APPLY_FAILED", "Cell Agent rejected the snapshot", map[string]any{"status": resp.StatusCode}) + } + var ack cellApplyAck + if err := json.NewDecoder(resp.Body).Decode(&ack); err != nil { + return cellApplyAck{}, newAppError(502, "INVALID_CELL_ACK", "Cell Agent acknowledgement is invalid", nil) + } + return ack, nil +} diff --git a/management/backend/queries/providers.sql b/management/backend/queries/providers.sql new file mode 100644 index 0000000..8ae74c7 --- /dev/null +++ b/management/backend/queries/providers.sql @@ -0,0 +1,12 @@ +-- name: GetProviderRow :one +SELECT provider_id, display_name, notes, lifecycle, revision, created_at, updated_at, created_by, updated_by +FROM providers +WHERE provider_id = ?; + +-- name: ListProviderRows :many +SELECT p.provider_id, p.display_name, p.notes, p.lifecycle, p.revision, p.created_at, p.updated_at, p.created_by, p.updated_by, + COUNT(t.trunk_id) AS trunk_count +FROM providers p +LEFT JOIN trunks t ON t.provider_id = p.provider_id +GROUP BY p.provider_id, p.display_name, p.notes, p.lifecycle, p.revision, p.created_at, p.updated_at, p.created_by, p.updated_by +ORDER BY p.updated_at DESC, p.provider_id ASC; diff --git a/management/backend/recovery.go b/management/backend/recovery.go new file mode 100644 index 0000000..a72685f --- /dev/null +++ b/management/backend/recovery.go @@ -0,0 +1,101 @@ +package management + +import ( + "context" + "database/sql" + "encoding/json" + "strings" +) + +func (s *Store) ListUnfinishedOperations(ctx context.Context) ([]Operation, error) { + var out []Operation + err := s.withRead(ctx, func(db *sql.DB) error { + rows, err := db.QueryContext(ctx, "SELECT operation_id,actor,action,resource_type,resource_id,request_id,request_digest,expected_revision,state,http_status,response_json,error_json,created_at,updated_at,expires_at FROM operations WHERE state IN ('in_progress','pending','needs_reconciliation') ORDER BY created_at ASC") + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var op Operation + var response, failure sql.NullString + if err := rows.Scan(&op.OperationID, &op.Actor, &op.Action, &op.ResourceType, &op.ResourceID, &op.RequestID, &op.RequestDigest, &op.ExpectedRevision, &op.State, &op.HTTPStatus, &response, &failure, &op.CreatedAt, &op.UpdatedAt, &op.ExpiresAt); err != nil { + return err + } + if response.Valid { + op.ResponseJSON = response.String + } + if failure.Valid { + op.ErrorJSON = failure.String + } + out = append(out, op) + } + return rows.Err() + }) + return out, err +} + +// Recover finalizes only durable, already-acknowledged snapshots. It never redelivers an uncertain Cell request. +func (s *Server) Recover(ctx context.Context) error { + ops, err := s.store.ListUnfinishedOperations(ctx) + if err != nil { + return err + } + for _, op := range ops { + if !strings.HasPrefix(op.Action, "trunk.") { + continue + } + pubs, err := s.store.GetPublicationRows(ctx, op.ResourceID, nil) + if err != nil { + return err + } + var revision int64 + allApplied := false + for _, pub := range pubs { + if pub.OperationID != op.OperationID { + continue + } + if revision == 0 { + revision = pub.Revision + allApplied = true + } + if pub.Revision != revision || pub.Status != "applied" { + allApplied = false + } + } + if revision == 0 { + _ = s.store.FinishOperation(ctx, op.OperationID, "needs_reconciliation", 409, "", recoveryError(op)) + continue + } + if !allApplied { + _ = s.store.FinishOperation(ctx, op.OperationID, "needs_reconciliation", 409, "", recoveryError(op)) + continue + } + cfg, err := s.store.getTrunkConfig(ctx, op.ResourceID, revision) + if err != nil { + return err + } + ok, err := s.store.FinalizePublication(ctx, op.ResourceID, revision, cfg, op.OperationID, op.Actor, op.RequestID, strings.TrimPrefix(op.Action, "trunk.")) + if err != nil { + return err + } + if !ok { + _ = s.store.FinishOperation(ctx, op.OperationID, "needs_reconciliation", 409, "", recoveryError(op)) + continue + } + trunk, err := s.store.GetTrunk(ctx, op.ResourceID, s.cfg.PublicMode()) + if err != nil { + return err + } + trunk.Mode = s.cfg.PublicMode() + body, _ := json.Marshal(trunk) + if err := s.store.FinishOperation(ctx, op.OperationID, "succeeded", 200, string(body), ""); err != nil { + return err + } + } + return nil +} + +func recoveryError(op Operation) string { + b, _ := json.Marshal(errorResponse{Error: ErrorBody{Code: "OPERATION_RECOVERY_REQUIRED", Message: "the operation stopped before a durable all-Cell acknowledgement; no request was redelivered", Fields: map[string]any{"operation_id": op.OperationID}}}) + return string(b) +} diff --git a/management/backend/server.go b/management/backend/server.go new file mode 100644 index 0000000..2a2c1f4 --- /dev/null +++ b/management/backend/server.go @@ -0,0 +1,776 @@ +package management + +import ( + "context" + "crypto/tls" + "crypto/x509" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/gofiber/fiber/v3" + fiberCors "github.com/gofiber/fiber/v3/middleware/cors" + "github.com/sirupsen/logrus" +) + +type principal struct { + Token string + TokenSpec +} + +type Server struct { + cfg Config + store *Store + app *fiber.App + log *logrus.Logger +} + +func NewServer(cfg Config, store *Store) *Server { + if store == nil { + panic("management: nil store") + } + logger := logrus.New() + logger.SetFormatter(&logrus.JSONFormatter{}) + s := &Server{cfg: cfg, store: store, log: logger} + s.app = fiber.New(fiber.Config{BodyLimit: 1 << 20, ErrorHandler: s.errorHandler}) + s.app.Use(s.originGuard) + s.app.Use(fiberCors.New(fiberCors.Config{AllowOriginsFunc: s.originAllowed, AllowMethods: []string{http.MethodGet, http.MethodPut, http.MethodPost, http.MethodOptions}, AllowHeaders: []string{"Authorization", "Content-Type", "If-Match", "X-Request-ID"}, AllowCredentials: true})) + s.app.Use(func(c fiber.Ctx) error { + started := time.Now() + err := c.Next() + s.log.WithFields(logrus.Fields{"method": c.Method(), "path": c.Path(), "status": c.Response().StatusCode(), "duration_ms": time.Since(started).Milliseconds(), "request_id": c.Get("X-Request-ID")}).Debug("management request") + return err + }) + s.registerRoutes() + if err := s.Recover(context.Background()); err != nil { + s.log.WithError(err).Warn("operation recovery requires operator reconciliation") + } + return s +} + +func (s *Server) App() *fiber.App { return s.app } +func (s *Server) Listen() error { return s.app.Listen(s.cfg.Addr) } + +func (s *Server) errorHandler(c fiber.Ctx, err error) error { + if appErr, ok := err.(*AppError); ok { + return s.fail(c, appErr) + } + return s.fail(c, newAppError(http.StatusInternalServerError, "INTERNAL_ERROR", "internal server error", nil)) +} + +func (s *Server) registerRoutes() { + s.app.Get("/healthz/live", s.live) + + s.app.Get("/admin/v1/providers", s.listProviders) + s.app.Get("/admin/v1/providers/:provider_id", s.getProvider) + s.app.Put("/admin/v1/providers/:provider_id", s.putProvider) + + s.app.Get("/admin/v1/trunks", s.listTrunks) + s.app.Get("/admin/v1/trunks/:trunk_id", s.getTrunk) + s.app.Put("/admin/v1/trunks/:trunk_id", s.putTrunk) + s.app.Post("/admin/v1/trunks/:trunk_id/validate", s.validateTrunk) + s.app.Post("/admin/v1/trunks/:trunk_id/verifications", s.addVerification) + s.app.Post("/admin/v1/trunks/:trunk_id/publish", s.publishTrunk) + s.app.Post("/admin/v1/trunks/:trunk_id/disable", s.disableTrunk) + s.app.Post("/admin/v1/trunks/:trunk_id/rollback", s.rollbackTrunk) + s.app.Get("/admin/v1/trunks/:trunk_id/publications", s.listPublications) + s.app.Get("/admin/v1/trunks/:trunk_id/audit", s.listTrunkAudit) + + s.app.Get("/admin/v1/cells", s.listCells) + s.app.Get("/admin/v1/cells/:cell_id", s.getCell) + s.app.Put("/admin/v1/cells/:cell_id", s.putCell) + s.app.Post("/admin/v1/cells/:cell_id/observations", s.ingestObservation) + s.app.Get("/admin/v1/egress-pools", s.listEgressPools) + + s.app.Get("/admin/v1/sip-status", s.sipStatus) + s.app.Get("/admin/v1/cells/:cell_id/sip-status", s.cellSIPStatus) + s.app.Get("/admin/v1/providers/:provider_id/status", s.providerStatus) + s.app.Get("/admin/v1/statistics/outbound/summary", s.outboundSummary) + s.app.Get("/admin/v1/statistics/outbound/timeseries", s.outboundTimeseries) + s.app.Get("/admin/v1/call-attempts", s.callAttempts) + s.app.Get("/admin/v1/call-attempts/:attempt_id", s.callAttempt) + s.app.Get("/admin/v1/audit", s.audit) + s.app.Get("/admin/v1/operations/:operation_id", s.getOperation) + + s.app.Get("/readonly/v1/sip/trunks", s.listReadonlyTrunks) + s.app.Get("/readonly/v1/sip/trunks/:trunk_id", s.getReadonlyTrunk) + + s.app.Use(func(c fiber.Ctx) error { + return s.fail(c, newAppError(http.StatusNotFound, "NOT_FOUND", "resource not found", nil)) + }) +} + +func (s *Server) originAllowed(origin string) bool { + if origin == "" || origin == "*" { + return false + } + allowed := s.cfg.AllowedOrigins + if len(allowed) == 0 { + allowed = DefaultConfig().AllowedOrigins + } + return allowed[origin] && origin != "*" +} + +func (s *Server) originGuard(c fiber.Ctx) error { + origin := strings.TrimSpace(c.Get("Origin")) + if origin != "" && !s.originAllowed(origin) { + return s.fail(c, newAppError(http.StatusForbidden, "ORIGIN_FORBIDDEN", "browser origin is not allowlisted", nil)) + } + return c.Next() +} + +func (s *Server) live(c fiber.Ctx) error { + return c.JSON(map[string]any{"status": "ok", "mode": s.cfg.PublicMode()}) +} + +func (s *Server) authorize(c fiber.Ctx, realm, scope, resourceType, resourceID string) (principal, *AppError) { + header := strings.TrimSpace(c.Get("Authorization")) + if !strings.HasPrefix(header, "Bearer ") || strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")) == "" { + return principal{}, newAppError(http.StatusUnauthorized, "UNAUTHORIZED", "a bearer token is required", nil) + } + token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")) + spec, ok := s.cfg.Tokens[token] + if !ok || spec.Realm != realm { + return principal{}, newAppError(http.StatusUnauthorized, "UNAUTHORIZED", "token is not valid for this API", nil) + } + if !containsString(spec.Scopes, scope) { + return principal{}, newAppError(http.StatusForbidden, "FORBIDDEN", "credential lacks the required scope", map[string]any{"required_scope": scope}) + } + if resourceID != "" && !resourceAllowed(spec, resourceType, resourceID) { + return principal{}, newAppError(http.StatusForbidden, "RESOURCE_FORBIDDEN", "credential is not authorized for this resource", map[string]any{"resource_type": resourceType, "resource_id": resourceID}) + } + return principal{Token: token, TokenSpec: spec}, nil +} + +func resourceAllowed(spec TokenSpec, resourceType, resourceID string) bool { + allowed, ok := spec.Resources[resourceType] + if !ok || len(allowed) == 0 { + return true + } + return containsString(allowed, "*") || containsString(allowed, resourceID) +} + +func (s *Server) checkFilterResources(c fiber.Ctx, p principal) *AppError { + for _, id := range parseCSV(c.Query("cell_ids")) { + if !resourceAllowed(p.TokenSpec, "cell", id) { + return newAppError(http.StatusForbidden, "RESOURCE_FORBIDDEN", "one or more requested Cells are outside the authorized scope", map[string]any{"cell_id": id}) + } + } + for _, id := range parseCSV(c.Query("cell_id")) { + if !resourceAllowed(p.TokenSpec, "cell", id) { + return newAppError(http.StatusForbidden, "RESOURCE_FORBIDDEN", "requested Cell is outside the authorized scope", map[string]any{"cell_id": id}) + } + } + if id := c.Query("provider_id"); id != "" && !resourceAllowed(p.TokenSpec, "provider", id) { + return newAppError(http.StatusForbidden, "RESOURCE_FORBIDDEN", "requested provider is outside the authorized scope", map[string]any{"provider_id": id}) + } + if id := c.Query("trunk_id"); id != "" && !resourceAllowed(p.TokenSpec, "trunk", id) { + return newAppError(http.StatusForbidden, "RESOURCE_FORBIDDEN", "requested trunk is outside the authorized scope", map[string]any{"trunk_id": id}) + } + if id := c.Query("egress_pool_id"); id != "" && !resourceAllowed(p.TokenSpec, "egress_pool", id) { + return newAppError(http.StatusForbidden, "RESOURCE_FORBIDDEN", "requested egress pool is outside the authorized scope", map[string]any{"egress_pool_id": id}) + } + return nil +} + +func (s *Server) fail(c fiber.Ctx, err *AppError) error { + if err == nil { + err = newAppError(http.StatusInternalServerError, "INTERNAL_ERROR", "internal server error", nil) + } + requestID := strings.TrimSpace(c.Get("X-Request-ID")) + body := errorResponse{Error: ErrorBody{Code: err.Code, Message: err.Message, Fields: err.Fields, RequestID: requestID}} + return c.Status(err.Status).JSON(body) +} + +func (s *Server) context(_ fiber.Ctx) (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), 10*time.Second) +} + +func requireHeader(c fiber.Ctx, name string) (string, *AppError) { + value := strings.TrimSpace(c.Get(name)) + if value == "" { + status := http.StatusPreconditionRequired + code := "PRECONDITION_REQUIRED" + if name == "X-Request-ID" { + status = http.StatusBadRequest + code = "REQUEST_ID_REQUIRED" + } + return "", newAppError(status, code, name+" header is required", nil) + } + if name == "X-Request-ID" && !validRequestID(value) { + return "", newAppError(http.StatusBadRequest, "INVALID_REQUEST_ID", "X-Request-ID is invalid", nil) + } + return strings.Trim(value, `"`), nil +} + +func parseIfMatch(c fiber.Ctx) (int64, *AppError) { + value, err := requireHeader(c, "If-Match") + if err != nil { + return 0, err + } + value = strings.TrimSpace(strings.Trim(value, `"`)) + if value == "" || value == "*" { + return 0, newAppError(http.StatusPreconditionRequired, "IF_MATCH_REQUIRED", "If-Match must contain an exact revision", nil) + } + revision, parseErr := strconv.ParseInt(value, 10, 64) + if parseErr != nil || revision < 0 { + return 0, newAppError(http.StatusBadRequest, "INVALID_IF_MATCH", "If-Match must be a non-negative revision", nil) + } + return revision, nil +} + +func decodeJSON(c fiber.Ctx, dst any) *AppError { + body := c.Body() + if len(body) == 0 { + return newAppError(http.StatusBadRequest, "INVALID_JSON", "request body is required", nil) + } + decoder := json.NewDecoder(strings.NewReader(string(body))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(dst); err != nil { + return newAppError(http.StatusBadRequest, "INVALID_JSON", "request body is invalid", nil) + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + return newAppError(http.StatusBadRequest, "INVALID_JSON", "request body must contain one JSON value", nil) + } + return nil +} + +func writeStored(c fiber.Ctx, status int, raw string) error { + c.Status(status) + c.Set("Content-Type", "application/json; charset=utf-8") + return c.SendString(raw) +} + +func (s *Server) beginOperation(ctx context.Context, p principal, action, resourceType, resourceID, requestID string, payload any, expected int64) (Operation, bool, *AppError) { + digest, _, err := hashJSON(payload) + if err != nil { + return Operation{}, false, newAppError(500, "INTERNAL_ERROR", "could not create request digest", nil) + } + op, replay, err := s.store.CreateOperation(ctx, p.Principal, action, resourceType, resourceID, requestID, digest, expected) + if err != nil { + return Operation{}, false, asAppError(err) + } + if !replay { + return op, false, nil + } + if op.RequestDigest != digest { + return Operation{}, false, newAppError(http.StatusConflict, "IDEMPOTENCY_CONFLICT", "the same request ID was used with a different payload", map[string]any{"operation_id": op.OperationID}) + } + if op.State == "succeeded" && op.ResponseJSON != "" { + return op, true, nil + } + if (op.State == "failed" || op.State == "pending" || op.State == "needs_reconciliation") && op.ErrorJSON != "" { + return op, true, nil + } + return op, true, newAppError(http.StatusConflict, "OPERATION_IN_PROGRESS", "the operation is still in progress; query it by operation_id", map[string]any{"operation_id": op.OperationID}) +} + +func (s *Server) replayOrError(c fiber.Ctx, op Operation, beginErr *AppError) error { + if beginErr != nil { + return s.fail(c, beginErr) + } + if op.State == "succeeded" && op.ResponseJSON != "" { + return writeStored(c, op.HTTPStatus, op.ResponseJSON) + } + if op.ErrorJSON != "" { + return writeStored(c, op.HTTPStatus, op.ErrorJSON) + } + return s.fail(c, newAppError(http.StatusConflict, "OPERATION_IN_PROGRESS", "the operation is still in progress; query it by operation_id", map[string]any{"operation_id": op.OperationID})) +} + +func (s *Server) finishSuccess(ctx context.Context, op Operation, status int, value any) error { + body, err := json.Marshal(value) + if err != nil { + return err + } + return s.store.FinishOperation(ctx, op.OperationID, "succeeded", status, string(body), "") +} + +func (s *Server) finishError(ctx context.Context, op Operation, appErr *AppError, c fiber.Ctx) error { + requestID := strings.TrimSpace(c.Get("X-Request-ID")) + body, _ := json.Marshal(errorResponse{Error: ErrorBody{Code: appErr.Code, Message: appErr.Message, Fields: appErr.Fields, RequestID: requestID}}) + state := "failed" + if appErr.Code == "PUBLISH_PENDING" || appErr.Code == "PUBLISH_PARTIAL" || appErr.Code == "OPERATION_IN_PROGRESS" { + state = "pending" + } + if err := s.store.FinishOperation(ctx, op.OperationID, state, appErr.Status, "", string(body)); err != nil { + return err + } + return s.fail(c, appErr) +} + +func (s *Server) listProviders(c fiber.Ctx) error { + p, authErr := s.authorize(c, "admin", "sip.provider.read", "", "") + if authErr != nil { + return s.fail(c, authErr) + } + _ = p + ctx, cancel := s.context(c) + defer cancel() + providers, err := s.store.ListProviders(ctx) + if err != nil { + return s.fail(c, asAppError(err)) + } + visible := providers[:0] + for _, provider := range providers { + if resourceAllowed(p.TokenSpec, "provider", provider.ProviderID) { + visible = append(visible, provider) + } + } + return c.JSON(map[string]any{"mode": s.cfg.PublicMode(), "providers": visible}) +} + +func (s *Server) getProvider(c fiber.Ctx) error { + id := c.Params("provider_id") + p, authErr := s.authorize(c, "admin", "sip.provider.read", "provider", id) + if authErr != nil { + return s.fail(c, authErr) + } + _ = p + ctx, cancel := s.context(c) + defer cancel() + provider, err := s.store.GetProvider(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return s.fail(c, newAppError(404, "PROVIDER_NOT_FOUND", "provider does not exist", nil)) + } + if err != nil { + return s.fail(c, asAppError(err)) + } + return c.JSON(map[string]any{"mode": s.cfg.PublicMode(), "provider": provider}) +} + +func (s *Server) putProvider(c fiber.Ctx) error { + id := c.Params("provider_id") + p, authErr := s.authorize(c, "admin", "sip.provider.write", "provider", id) + if authErr != nil { + return s.fail(c, authErr) + } + expected, err := parseIfMatch(c) + if err != nil { + return s.fail(c, err) + } + requestID, err := requireHeader(c, "X-Request-ID") + if err != nil { + return s.fail(c, err) + } + var input ProviderInput + if err := decodeJSON(c, &input); err != nil { + return s.fail(c, err) + } + ctx, cancel := s.context(c) + defer cancel() + op, replay, beginErr := s.beginOperation(ctx, p, "provider.upsert", "provider", id, requestID, map[string]any{"if_match": expected, "input": input}, expected) + if replay || beginErr != nil { + return s.replayOrError(c, op, beginErr) + } + provider, status, opErr := s.store.UpsertProvider(ctx, id, input, expected, p.Principal, requestID) + if opErr != nil { + appErr := asAppError(opErr) + _ = s.store.FinishOperation(ctx, op.OperationID, "failed", appErr.Status, "", errorJSON(appErr, requestID)) + return s.fail(c, appErr) + } + body := map[string]any{"mode": s.cfg.PublicMode(), "provider": provider} + if err := s.finishSuccess(ctx, op, status, body); err != nil { + return s.fail(c, asAppError(err)) + } + return c.Status(status).JSON(body) +} + +func errorJSON(err *AppError, requestID string) string { + b, _ := json.Marshal(errorResponse{Error: ErrorBody{Code: err.Code, Message: err.Message, Fields: err.Fields, RequestID: requestID}}) + return string(b) +} + +func (s *Server) listTrunks(c fiber.Ctx) error { + p, authErr := s.authorize(c, "admin", "sip.trunk.read", "", "") + if authErr != nil { + return s.fail(c, authErr) + } + if err := s.checkFilterResources(c, p); err != nil { + return s.fail(c, err) + } + ctx, cancel := s.context(c) + defer cancel() + trunks, err := s.store.ListTrunks(ctx, s.cfg.PublicMode(), c.Query("provider_id")) + if err != nil { + return s.fail(c, asAppError(err)) + } + if cellID := c.Query("cell_id"); cellID != "" { + filtered := trunks[:0] + for _, trunk := range trunks { + if containsString(trunk.CompatibleCellIDs, cellID) { + filtered = append(filtered, trunk) + } + } + trunks = filtered + } + if state := c.Query("status"); state != "" { + filtered := trunks[:0] + for _, trunk := range trunks { + if trunk.Status == state || trunk.ActiveStatus == state { + filtered = append(filtered, trunk) + } + } + trunks = filtered + } + visible := trunks[:0] + for _, trunk := range trunks { + if resourceAllowed(p.TokenSpec, "trunk", trunk.TrunkID) && resourceAllowed(p.TokenSpec, "provider", trunk.ProviderID) { + visible = append(visible, trunk) + } + } + return c.JSON(map[string]any{"mode": s.cfg.PublicMode(), "trunks": visible}) +} + +func (s *Server) getTrunk(c fiber.Ctx) error { + id := c.Params("trunk_id") + p, authErr := s.authorize(c, "admin", "sip.trunk.read", "trunk", id) + if authErr != nil { + return s.fail(c, authErr) + } + _ = p + ctx, cancel := s.context(c) + defer cancel() + trunk, err := s.store.GetTrunk(ctx, id, s.cfg.PublicMode()) + if errors.Is(err, sql.ErrNoRows) { + return s.fail(c, newAppError(404, "TRUNK_NOT_FOUND", "trunk does not exist", nil)) + } + if err != nil { + return s.fail(c, asAppError(err)) + } + return c.JSON(trunk) +} + +func (s *Server) putTrunk(c fiber.Ctx) error { + id := c.Params("trunk_id") + p, authErr := s.authorize(c, "admin", "sip.trunk.write", "trunk", id) + if authErr != nil { + return s.fail(c, authErr) + } + expected, err := parseIfMatch(c) + if err != nil { + return s.fail(c, err) + } + requestID, err := requireHeader(c, "X-Request-ID") + if err != nil { + return s.fail(c, err) + } + var input TrunkConfig + if err := decodeJSON(c, &input); err != nil { + return s.fail(c, err) + } + ctx, cancel := s.context(c) + defer cancel() + op, replay, beginErr := s.beginOperation(ctx, p, "trunk.upsert", "trunk", id, requestID, map[string]any{"if_match": expected, "input": input}, expected) + if replay || beginErr != nil { + return s.replayOrError(c, op, beginErr) + } + trunk, status, opErr := s.store.PutTrunk(ctx, id, input, expected, p.Principal, requestID) + if opErr != nil { + appErr := asAppError(opErr) + _ = s.store.FinishOperation(ctx, op.OperationID, "failed", appErr.Status, "", errorJSON(appErr, requestID)) + return s.fail(c, appErr) + } + trunk.Mode = s.cfg.PublicMode() + body := map[string]any{"mode": s.cfg.PublicMode(), "trunk": trunk} + if err := s.finishSuccess(ctx, op, status, body); err != nil { + return s.fail(c, asAppError(err)) + } + return c.Status(status).JSON(body) +} + +func (s *Server) validateTrunk(c fiber.Ctx) error { + id := c.Params("trunk_id") + p, authErr := s.authorize(c, "admin", "sip.trunk.write", "trunk", id) + if authErr != nil { + return s.fail(c, authErr) + } + _ = p + var body struct { + Revision int64 `json:"revision"` + } + if len(c.Body()) > 0 { + if err := decodeJSON(c, &body); err != nil { + return s.fail(c, err) + } + } + ctx, cancel := s.context(c) + defer cancel() + if body.Revision == 0 { + trunk, err := s.store.GetTrunk(ctx, id, s.cfg.PublicMode()) + if err != nil { + return s.fail(c, asAppError(err)) + } + body.Revision = trunk.LatestRevision + } + result, err := s.store.BuildValidation(ctx, id, body.Revision, s.cfg.PublicMode()) + if errors.Is(err, sql.ErrNoRows) { + return s.fail(c, newAppError(404, "VERSION_NOT_FOUND", "trunk version does not exist", nil)) + } + if err != nil { + return s.fail(c, asAppError(err)) + } + return c.JSON(result) +} + +func (s *Server) listCells(c fiber.Ctx) error { + p, authErr := s.authorize(c, "admin", "sip.cell.read", "", "") + if authErr != nil { + return s.fail(c, authErr) + } + _ = p + ctx, cancel := s.context(c) + defer cancel() + cells, err := s.store.ListCells(ctx) + if err != nil { + return s.fail(c, asAppError(err)) + } + visible := cells[:0] + for i := range cells { + if resourceAllowed(p.TokenSpec, "cell", cells[i].CellID) { + cells[i].Mode = s.cfg.PublicMode() + visible = append(visible, cells[i]) + } + } + return c.JSON(map[string]any{"mode": s.cfg.PublicMode(), "cells": visible}) +} +func (s *Server) getCell(c fiber.Ctx) error { + id := c.Params("cell_id") + p, authErr := s.authorize(c, "admin", "sip.cell.read", "cell", id) + if authErr != nil { + return s.fail(c, authErr) + } + _ = p + ctx, cancel := s.context(c) + defer cancel() + cell, err := s.store.GetCell(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return s.fail(c, newAppError(404, "CELL_NOT_FOUND", "cell does not exist", nil)) + } + if err != nil { + return s.fail(c, asAppError(err)) + } + cell.Mode = s.cfg.PublicMode() + return c.JSON(cell) +} + +func (s *Server) putCell(c fiber.Ctx) error { + id := c.Params("cell_id") + p, authErr := s.authorize(c, "admin", "sip.cell.write", "cell", id) + if authErr != nil { + return s.fail(c, authErr) + } + expected, err := parseIfMatch(c) + if err != nil { + return s.fail(c, err) + } + requestID, err := requireHeader(c, "X-Request-ID") + if err != nil { + return s.fail(c, err) + } + var input struct { + CellConfig + CloudInstanceID string `json:"cloud_instance_id,omitempty"` + InstanceName string `json:"instance_name,omitempty"` + Region string `json:"region,omitempty"` + } + if err := decodeJSON(c, &input); err != nil { + return s.fail(c, err) + } + if input.ManagementURL != "" { + allowPrivate := s.cfg.AllowPrivateAgent + allowlist := s.cfg.AgentAllowlist + if s.cfg.Mode != ModeReal { + allowPrivate = true + allowlist = nil + } + if err := validateManagementURL(input.ManagementURL, allowPrivate, allowlist); err != nil { + return s.fail(c, newAppError(422, "INVALID_MANAGEMENT_URL", err.Error(), nil)) + } + } + ctx, cancel := s.context(c) + defer cancel() + op, replay, beginErr := s.beginOperation(ctx, p, "cell.upsert", "cell", id, requestID, map[string]any{"if_match": expected, "input": input}, expected) + if replay || beginErr != nil { + return s.replayOrError(c, op, beginErr) + } + cell, status, opErr := s.store.UpsertCell(ctx, id, input.CellConfig, expected, p.Principal, requestID, input.CloudInstanceID, input.InstanceName, input.Region) + if opErr != nil { + appErr := asAppError(opErr) + _ = s.store.FinishOperation(ctx, op.OperationID, "failed", appErr.Status, "", errorJSON(appErr, requestID)) + return s.fail(c, appErr) + } + cell.Mode = s.cfg.PublicMode() + body := map[string]any{"mode": s.cfg.PublicMode(), "cell": cell} + if err := s.finishSuccess(ctx, op, status, body); err != nil { + return s.fail(c, asAppError(err)) + } + return c.Status(status).JSON(body) +} + +func (s *Server) listEgressPools(c fiber.Ctx) error { + p, authErr := s.authorize(c, "admin", "sip.cell.read", "", "") + if authErr != nil { + return s.fail(c, authErr) + } + _ = p + ctx, cancel := s.context(c) + defer cancel() + pools, err := s.store.ListEgressPools(ctx) + if err != nil { + return s.fail(c, asAppError(err)) + } + visible := pools[:0] + for _, pool := range pools { + if resourceAllowed(p.TokenSpec, "egress_pool", fmt.Sprint(pool["egress_pool_id"])) { + visible = append(visible, pool) + } + } + return c.JSON(map[string]any{"mode": s.cfg.PublicMode(), "egress_pools": visible}) +} + +func (s *Server) listPublications(c fiber.Ctx) error { + id := c.Params("trunk_id") + p, authErr := s.authorize(c, "admin", "sip.trunk.read", "trunk", id) + if authErr != nil { + return s.fail(c, authErr) + } + _ = p + ctx, cancel := s.context(c) + defer cancel() + rows, err := s.store.GetPublicationRows(ctx, id, nil) + if errors.Is(err, sql.ErrNoRows) { + return s.fail(c, newAppError(404, "TRUNK_NOT_FOUND", "trunk does not exist", nil)) + } + if err != nil { + return s.fail(c, asAppError(err)) + } + return c.JSON(map[string]any{"mode": s.cfg.PublicMode(), "publications": rows}) +} +func (s *Server) listTrunkAudit(c fiber.Ctx) error { + id := c.Params("trunk_id") + p, authErr := s.authorize(c, "admin", "sip.audit.read", "trunk", id) + if authErr != nil { + return s.fail(c, authErr) + } + _ = p + ctx, cancel := s.context(c) + defer cancel() + rows, err := s.store.ListAudit(ctx, id, "", "", 200) + if err != nil { + return s.fail(c, asAppError(err)) + } + return c.JSON(map[string]any{"mode": s.cfg.PublicMode(), "audit": rows}) +} + +func (s *Server) getOperation(c fiber.Ctx) error { + id := c.Params("operation_id") + p, authErr := s.authorize(c, "admin", "sip.operation.read", "", "") + if authErr != nil { + return s.fail(c, authErr) + } + _ = p + ctx, cancel := s.context(c) + defer cancel() + op, err := s.store.GetOperation(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return s.fail(c, newAppError(404, "OPERATION_NOT_FOUND", "operation does not exist", nil)) + } + if err != nil { + return s.fail(c, asAppError(err)) + } + if !resourceAllowed(p.TokenSpec, op.ResourceType, op.ResourceID) { + return s.fail(c, newAppError(403, "RESOURCE_FORBIDDEN", "operation is outside the authorized scope", nil)) + } + out := map[string]any{"operation_id": op.OperationID, "action": op.Action, "resource_type": op.ResourceType, "resource_id": op.ResourceID, "request_id": op.RequestID, "state": op.State, "http_status": op.HTTPStatus, "created_at": op.CreatedAt, "updated_at": op.UpdatedAt, "expires_at": op.ExpiresAt} + if op.ResponseJSON != "" { + var v any + _ = json.Unmarshal([]byte(op.ResponseJSON), &v) + out["response"] = v + } + if op.ErrorJSON != "" { + var v any + _ = json.Unmarshal([]byte(op.ErrorJSON), &v) + out["error"] = v + } + return c.JSON(out) +} + +func (s *Server) listReadonlyTrunks(c fiber.Ctx) error { + p, authErr := s.authorize(c, "saas", "sip.trunk.read", "", "") + if authErr != nil { + return s.fail(c, authErr) + } + ctx, cancel := s.context(c) + defer cancel() + trunks, err := s.store.ListTrunks(ctx, s.cfg.PublicMode(), "") + if err != nil { + return s.fail(c, asAppError(err)) + } + out := make([]ReadonlyTrunk, 0, len(trunks)) + for _, trunk := range trunks { + if trunk.ActiveRevision == 0 || trunk.ActiveStatus != "published" || !resourceAllowed(p.TokenSpec, "trunk", trunk.TrunkID) || !resourceAllowed(p.TokenSpec, "provider", trunk.ProviderID) { + continue + } + out = append(out, ReadonlyTrunk{Mode: s.cfg.PublicMode(), TrunkID: trunk.TrunkID, ProviderID: trunk.ProviderID, Revision: trunk.ActiveRevision, Status: "published", UpdatedAt: trunk.UpdatedAt, Config: trunk.Active}) + } + return c.JSON(map[string]any{"mode": s.cfg.PublicMode(), "trunks": out}) +} +func (s *Server) getReadonlyTrunk(c fiber.Ctx) error { + id := c.Params("trunk_id") + p, authErr := s.authorize(c, "saas", "sip.trunk.read", "trunk", id) + if authErr != nil { + return s.fail(c, authErr) + } + _ = p + ctx, cancel := s.context(c) + defer cancel() + trunk, err := s.store.GetTrunk(ctx, id, s.cfg.PublicMode()) + if errors.Is(err, sql.ErrNoRows) || trunk.ActiveRevision == 0 || trunk.ActiveStatus != "published" { + return s.fail(c, newAppError(404, "TRUNK_NOT_FOUND", "published trunk does not exist", nil)) + } + return c.JSON(ReadonlyTrunk{Mode: s.cfg.PublicMode(), TrunkID: id, ProviderID: trunk.ProviderID, Revision: trunk.ActiveRevision, Status: "published", UpdatedAt: trunk.UpdatedAt, Config: trunk.Active}) +} + +// httpClient is intentionally used only for the real Cell Agent protocol. Mock mode never dials a network address. +func (s *Server) httpClient() (*http.Client, error) { + rootPEM, certFile, keyFile := os.Getenv("MANAGEMENT_AGENT_CA"), os.Getenv("MANAGEMENT_AGENT_CLIENT_CERT"), os.Getenv("MANAGEMENT_AGENT_CLIENT_KEY") + if rootPEM == "" || certFile == "" || keyFile == "" { + return nil, fmt.Errorf("mTLS client certificate is not configured") + } + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, fmt.Errorf("load mTLS client certificate: %w", err) + } + pool := x509.NewCertPool() + if rootPEM != "" { + pemBytes, err := os.ReadFile(rootPEM) + if err != nil { + return nil, fmt.Errorf("read Cell CA: %w", err) + } + if !pool.AppendCertsFromPEM(pemBytes) { + return nil, fmt.Errorf("invalid Cell CA") + } + } + return &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, Transport: &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{cert}, RootCAs: pool, ServerName: ""}}}, nil +} + +func validateAgentURL(raw string, cfg Config) (*url.URL, error) { + u, err := url.Parse(raw) + if err != nil { + return nil, err + } + if err := validateManagementURL(raw, cfg.AllowPrivateAgent, cfg.AgentAllowlist); err != nil { + return nil, err + } + return u, nil +} diff --git a/management/backend/server_test.go b/management/backend/server_test.go new file mode 100644 index 0000000..27f75b2 --- /dev/null +++ b/management/backend/server_test.go @@ -0,0 +1,458 @@ +package management + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func testServer(t *testing.T, apply bool) (*Server, *Store) { + t.Helper() + store, err := NewMemoryStore() + if err != nil { + t.Fatal(err) + } + cfg := DefaultConfig() + cfg.MockCellApply = apply + server := NewServer(cfg, store) + if err := store.SeedDemo(context.Background()); err != nil { + store.Close() + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + return server, store +} + +func request(t *testing.T, server *Server, method, path, token, requestID, ifMatch string, body any) *http.Response { + t.Helper() + var data []byte + if body != nil { + data, _ = json.Marshal(body) + } + req := httptest.NewRequest(method, path, bytes.NewReader(data)) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + if requestID != "" { + req.Header.Set("X-Request-ID", requestID) + } + if ifMatch != "" { + req.Header.Set("If-Match", ifMatch) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := server.App().Test(req) + if err != nil { + t.Fatal(err) + } + return resp +} + +func responseJSON(t *testing.T, resp *http.Response) map[string]any { + t.Helper() + defer resp.Body.Close() + var body map[string]any + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatal(err) + } + return body +} + +func TestAuthorizationAndIdempotency(t *testing.T) { + server, store := testServer(t, true) + resp := request(t, server, http.MethodGet, "/admin/v1/trunks", "dev-saas-token", "", "", nil) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("saas token on admin API: %d", resp.StatusCode) + } + resp = request(t, server, http.MethodGet, "/readonly/v1/sip/trunks", "dev-saas-token", "", "", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("readonly API: %d", resp.StatusCode) + } + resp = request(t, server, http.MethodGet, "/admin/v1/trunks/demo-trunk", "dev-admin-token", "", "", nil) + body := responseJSON(t, resp) + latest := int(body["latest_revision"].(float64)) + input := TrunkConfig{ProviderID: "demo-provider", DisplayName: "更新线路", Enabled: true, Sip: SipConfig{Host: "198.51.100.21", Port: 5060, Transport: "udp", AuthMode: "ip"}, CodecProfile: CodecProfile{Allowed: []string{"PCMA"}, Preferred: "PCMA"}, CallerIDs: []string{"BD93205882"}, DialPrefix: "7089", EgressPoolID: "egress-main", MaxConcurrency: 100, MaxCPS: 5} + resp = request(t, server, http.MethodPut, "/admin/v1/trunks/demo-trunk", "dev-config-token", "trunk-update-1", strconvI(latest), input) + if resp.StatusCode != http.StatusOK { + t.Fatalf("put trunk: %d %s", resp.StatusCode, readBody(resp)) + } + first := responseJSON(t, resp) + resp = request(t, server, http.MethodPut, "/admin/v1/trunks/demo-trunk", "dev-config-token", "trunk-update-1", strconvI(latest), input) + if resp.StatusCode != http.StatusOK { + t.Fatalf("idempotent retry: %d", resp.StatusCode) + } + second := responseJSON(t, resp) + if first["trunk"].(map[string]any)["latest_revision"] != second["trunk"].(map[string]any)["latest_revision"] { + t.Fatal("retry created a second revision") + } + if _, err := store.db.Exec("UPDATE operations SET expires_at=? WHERE request_id=?", utcString(time.Now().Add(-time.Hour)), "trunk-update-1"); err != nil { + t.Fatal(err) + } + resp = request(t, server, http.MethodPut, "/admin/v1/trunks/demo-trunk", "dev-config-token", "trunk-update-1", strconvI(latest), input) + if resp.StatusCode != http.StatusConflict { + t.Fatalf("expired idempotency status: %d", resp.StatusCode) + } + if code := responseJSON(t, resp)["error"].(map[string]any)["code"]; code != "IDEMPOTENCY_EXPIRED" { + t.Fatalf("expired idempotency code=%v", code) + } + input.DisplayName = "different payload" + resp = request(t, server, http.MethodPut, "/admin/v1/trunks/demo-trunk", "dev-config-token", "trunk-update-1", strconvI(latest), input) + if resp.StatusCode != http.StatusConflict { + t.Fatalf("idempotency conflict: %d", resp.StatusCode) + } + resp = request(t, server, http.MethodGet, "/admin/v1/trunks/demo-trunk", "dev-admin-token", "", "", nil) + decoded := responseJSON(t, resp) + latestView := decoded["latest"].(map[string]any) + if _, ok := latestView["credential_ref"]; ok { + t.Fatal("credential reference leaked from read response") + } +} + +func TestPublicationBarrierAndRecovery(t *testing.T) { + server, store := testServer(t, false) + cfg, err := store.getTrunkConfig(context.Background(), "demo-trunk", 1) + if err != nil { + t.Fatal(err) + } + cfg.DisplayName = "待发布线路" + if _, _, err := store.PutTrunk(context.Background(), "demo-trunk", cfg, 1, "fixture:test", "fixture-draft"); err != nil { + t.Fatal(err) + } + resp := request(t, server, http.MethodPost, "/admin/v1/trunks/demo-trunk/publish", "dev-publisher-token", "publish-intent-1", "2", nil) + if resp.StatusCode != http.StatusConflict { + t.Fatalf("intent-only publish status: %d", resp.StatusCode) + } + if code := responseJSON(t, resp)["error"].(map[string]any)["code"]; code != "PUBLISH_PENDING" { + t.Fatalf("error code=%v", code) + } + trunk, _ := store.GetTrunk(context.Background(), "demo-trunk", ModeMock) + if trunk.ActiveRevision != 1 { + t.Fatalf("pending publication changed active revision: %d", trunk.ActiveRevision) + } + server.cfg.MockCellApply = true + resp = request(t, server, http.MethodPost, "/admin/v1/trunks/demo-trunk/publish", "dev-publisher-token", "publish-retry-1", "2", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("recovered publish: %d %s", resp.StatusCode, readBody(resp)) + } + trunk, _ = store.GetTrunk(context.Background(), "demo-trunk", ModeMock) + if trunk.ActiveRevision != 2 || trunk.Status != "published" { + t.Fatalf("publication did not finalize: %+v", trunk) + } +} + +func TestStatisticsSemantics(t *testing.T) { + server, _ := testServer(t, true) + resp := request(t, server, http.MethodGet, "/admin/v1/statistics/outbound/summary", "dev-admin-token", "", "", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("summary: %d %s", resp.StatusCode, readBody(resp)) + } + body := responseJSON(t, resp) + metrics := body["metrics"].(map[string]any) + for field, want := range map[string]float64{"attempts_total": 6, "started_confirmed": 6, "answered": 2, "terminal_not_answered": 3, "rejected": 1, "pending_not_answered": 1} { + if got := metrics[field].(float64); got != want { + t.Fatalf("%s=%v want %v", field, got, want) + } + } + if complete := body["complete"].(bool); !complete { + t.Fatal("rejected admission was incorrectly marked incomplete") + } + talk := metrics["talk_duration_seconds"].(map[string]any) + if got := talk["total"].(float64); got != 180 { + t.Fatalf("talk total=%v want 180", got) + } + if got := metrics["acd_seconds"].(float64); got != 90 { + t.Fatalf("acd=%v want 90", got) + } + realtime := body["realtime"].(map[string]any) + if _, ok := realtime["current_answered"]; !ok { + t.Fatal("realtime current_answered missing") + } + if _, ok := realtime["current_cps"]; !ok { + t.Fatal("realtime current_cps missing") + } + if body["definition_version"] != "sip-statistics.v1" || body["timezone"] != "UTC" { + t.Fatalf("statistics metadata missing: %#v", body) + } + resp = request(t, server, http.MethodGet, "/admin/v1/statistics/outbound/timeseries?granularity=minute", "dev-admin-token", "", "", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("timeseries: %d", resp.StatusCode) + } + series := responseJSON(t, resp) + if series["definition_version"] != "sip-statistics.v1" { + t.Fatalf("timeseries definition version = %#v", series["definition_version"]) + } +} + +func TestObservationBootAndSequence(t *testing.T) { + _, store := testServer(t, true) + ctx := context.Background() + now := time.Now().UTC() + if err := store.AdvanceCellBoot(ctx, "cell-a", "boot-next"); err != nil { + t.Fatal(err) + } + obs := ObservationInput{ObservationID: "obs-new", CellID: "cell-a", BootID: "boot-next", Sequence: 1, ObservedAt: now, ReceivedAt: now, Source: "mock", States: map[string]any{"cell_agent": "healthy"}} + if err := store.InsertObservation(ctx, obs); err != nil { + t.Fatal(err) + } + obs.ObservationID = "obs-zero" + obs.Sequence = 0 + if err := store.InsertObservation(ctx, obs); err == nil { + t.Fatal("zero sequence accepted") + } else if appErr, ok := err.(*AppError); !ok || appErr.Code != "INVALID_OBSERVATION" { + t.Fatalf("wrong invalid observation error: %v", err) + } + obs.Sequence = 1 + obs.ObservationID = "obs-old" + if err := store.InsertObservation(ctx, obs); err == nil { + t.Fatal("stale sequence accepted") + } else if appErr, ok := err.(*AppError); !ok || appErr.Code != "STALE_OBSERVATION" { + t.Fatalf("wrong stale error: %v", err) + } + obs.BootID = "old-boot" + obs.Sequence = 2 + if err := store.InsertObservation(ctx, obs); err == nil { + t.Fatal("old boot accepted") + } +} + +func TestStatusRejectsFutureObservationClock(t *testing.T) { + server, store := testServer(t, true) + ctx := context.Background() + if err := store.AdvanceCellBoot(ctx, "cell-a", "future-boot"); err != nil { + t.Fatal(err) + } + future := time.Now().UTC().Add(2 * time.Minute) + if err := store.InsertObservation(ctx, ObservationInput{ObservationID: "future-observation", CellID: "cell-a", BootID: "future-boot", Sequence: 1, ObservedAt: future, ReceivedAt: time.Now().UTC(), Source: "mock", States: map[string]any{"cell_agent": "healthy", "asterisk": "healthy", "ari": "healthy", "registration": "registered", "media": "healthy"}}); err != nil { + t.Fatal(err) + } + resp := request(t, server, http.MethodGet, "/admin/v1/cells/cell-a/sip-status", "dev-admin-token", "future-status", "", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("future status=%d %s", resp.StatusCode, readBody(resp)) + } + body := responseJSON(t, resp) + if body["availability"] != "unknown" || body["complete"] != false || body["clock_skew"] != true { + t.Fatalf("future observation was treated as healthy: %#v", body) + } +} + +func TestReadonlyKeepsPublishedRevisionWhileDraftExists(t *testing.T) { + server, store := testServer(t, true) + cfg, err := store.getTrunkConfig(context.Background(), "demo-trunk", 1) + if err != nil { + t.Fatal(err) + } + cfg.DisplayName = "draft-only" + if _, _, err := store.PutTrunk(context.Background(), "demo-trunk", cfg, 1, "fixture:test", "draft-only"); err != nil { + t.Fatal(err) + } + resp := request(t, server, http.MethodGet, "/readonly/v1/sip/trunks", "dev-saas-token", "", "", nil) + body := responseJSON(t, resp) + trunks := body["trunks"].([]any) + if len(trunks) != 1 { + t.Fatalf("published trunks=%d", len(trunks)) + } + if trunks[0].(map[string]any)["revision"].(float64) != 1 { + t.Fatal("readonly view followed unpublished latest revision") + } +} + +func TestPartialPublicationRetainsBarrierAndRetriesFailedCell(t *testing.T) { + server, store := testServer(t, true) + cfg, err := store.getTrunkConfig(context.Background(), "demo-trunk", 1) + if err != nil { + t.Fatal(err) + } + cfg.DisplayName = "partial" + if _, _, err := store.PutTrunk(context.Background(), "demo-trunk", cfg, 1, "fixture:test", "partial-draft"); err != nil { + t.Fatal(err) + } + server.cfg.MockCellFailures = map[string]string{"cell-b": "CELL_DOWN"} + resp := request(t, server, http.MethodPost, "/admin/v1/trunks/demo-trunk/publish", "dev-publisher-token", "partial-1", "2", nil) + if resp.StatusCode != http.StatusConflict { + t.Fatalf("partial publish=%d", resp.StatusCode) + } + _ = responseJSON(t, resp) + trunk, _ := store.GetTrunk(context.Background(), "demo-trunk", ModeMock) + if trunk.ActiveRevision != 1 { + t.Fatal("partial publication activated revision") + } + if allowed, reason, err := store.AdmissionAllowed(context.Background(), "demo-trunk"); err != nil || allowed || reason != "admission_barrier" { + t.Fatalf("admission after partial: %v %s", err, reason) + } + server.cfg.MockCellFailures = map[string]string{} + resp = request(t, server, http.MethodPost, "/admin/v1/trunks/demo-trunk/publish", "dev-publisher-token", "partial-2", "2", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("retry partial=%d %s", resp.StatusCode, readBody(resp)) + } + trunk, _ = store.GetTrunk(context.Background(), "demo-trunk", ModeMock) + if trunk.ActiveRevision != 2 { + t.Fatal("retry did not activate revision") + } +} + +func TestRollbackMissingRevisionDoesNotLeaveBarrier(t *testing.T) { + server, store := testServer(t, true) + resp := request(t, server, http.MethodPost, "/admin/v1/trunks/demo-trunk/rollback", "dev-publisher-token", "rollback-missing", "1", map[string]any{"target_revision": 99}) + if resp.StatusCode != http.StatusConflict { + t.Fatalf("missing rollback status=%d %s", resp.StatusCode, readBody(resp)) + } + if code := responseJSON(t, resp)["error"].(map[string]any)["code"]; code != "VERSION_NOT_FOUND" { + t.Fatalf("missing rollback code=%v", code) + } + allowed, reason, err := store.AdmissionAllowed(context.Background(), "demo-trunk") + if err != nil || !allowed || reason != "ok" { + t.Fatalf("missing rollback left barrier: allowed=%v reason=%s err=%v", allowed, reason, err) + } +} + +func TestRealPublicationNeedsVerification(t *testing.T) { + server, store := testServer(t, true) + server.cfg.Mode = ModeReal + cfg, err := store.getTrunkConfig(context.Background(), "demo-trunk", 1) + if err != nil { + t.Fatal(err) + } + cfg.DisplayName = "real-draft" + if _, _, err := store.PutTrunk(context.Background(), "demo-trunk", cfg, 1, "fixture:test", "real-draft"); err != nil { + t.Fatal(err) + } + resp := request(t, server, http.MethodPost, "/admin/v1/trunks/demo-trunk/publish", "dev-publisher-token", "real-publish", "2", nil) + if resp.StatusCode != http.StatusConflict { + t.Fatalf("real verification status=%d", resp.StatusCode) + } + if code := responseJSON(t, resp)["error"].(map[string]any)["code"]; code != "REAL_VERIFICATION_INCOMPLETE" { + t.Fatalf("real verification code=%v", code) + } +} + +func TestScopedResourcesAndCORS(t *testing.T) { + server, _ := testServer(t, true) + cfg := server.cfg + cfg.Tokens = map[string]TokenSpec{"scoped": {Principal: "operator:scoped", Realm: "admin", Scopes: []string{"sip.provider.read", "sip.trunk.read", "sip.status.read", "sip.statistics.read", "sip.calls.read"}, Resources: map[string][]string{"provider": {"other-provider"}, "trunk": {"other-trunk"}, "cell": {"other-cell"}}}} + server.cfg = cfg + resp := request(t, server, http.MethodGet, "/admin/v1/providers", "scoped", "", "", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("scoped list=%d", resp.StatusCode) + } + if len(responseJSON(t, resp)["providers"].([]any)) != 0 { + t.Fatal("scoped provider leaked") + } + req := httptest.NewRequest(http.MethodOptions, "/healthz/live", nil) + req.Header.Set("Origin", "https://evil.example") + resp, err := server.App().Test(req) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("evil origin=%d", resp.StatusCode) + } +} + +func strconvI(v int) string { return fmt.Sprintf("%d", v) } +func readBody(resp *http.Response) string { + b, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return string(b) +} + +func TestAttemptEventsAreIdempotentAndVersioned(t *testing.T) { + _, store := testServer(t, true) + ctx := context.Background() + started := time.Now().UTC().Add(-2 * time.Minute) + if err := store.UpsertAttempt(ctx, Attempt{AttemptID: "event-attempt", Mode: ModeMock, OriginStatus: "confirmed", AttemptStartedAt: &started, Source: "mock", FactVersion: 1}); err != nil { + t.Fatal(err) + } + answered := started.Add(30 * time.Second) + if err := store.RecordAttemptEvent(ctx, "event-answered", "event-attempt", "answered", answered, time.Now().UTC(), "cell-agent", 2, nil); err != nil { + t.Fatal(err) + } + if err := store.RecordAttemptEvent(ctx, "event-answered", "event-attempt", "answered", started.Add(5*time.Second), time.Now().UTC(), "cell-agent", 1, nil); err != nil { + t.Fatal(err) + } + ended := answered.Add(time.Minute) + if err := store.RecordAttemptEvent(ctx, "event-ended", "event-attempt", "ended", ended, time.Now().UTC(), "cell-agent", 2, map[string]any{"termination_reason": "completed"}); err != nil { + t.Fatal(err) + } + attempts, err := store.ListAttempts(ctx, AttemptFilter{Limit: 20}) + if err != nil { + t.Fatal(err) + } + var found *Attempt + for i := range attempts { + if attempts[i].AttemptID == "event-attempt" { + found = &attempts[i] + } + } + if found == nil || found.AnsweredAt == nil || !found.AnsweredAt.Equal(answered) || found.EndedAt == nil || !found.EndedAt.Equal(ended) || found.FactVersion != 2 { + t.Fatalf("stale event regressed fact: %#v", found) + } + if err := store.UpsertAttempt(ctx, Attempt{AttemptID: "event-attempt", Mode: ModeMock, OriginStatus: "confirmed", AttemptStartedAt: &started, AnsweredAt: &started, Source: "late-source", FactVersion: 1}); err != nil { + t.Fatal(err) + } + attempts, err = store.ListAttempts(ctx, AttemptFilter{Limit: 20}) + if err != nil { + t.Fatal(err) + } + for i := range attempts { + if attempts[i].AttemptID == "event-attempt" && (attempts[i].AnsweredAt == nil || !attempts[i].AnsweredAt.Equal(answered) || attempts[i].FactVersion != 2) { + t.Fatalf("stale upsert regressed fact: %#v", attempts[i]) + } + } + var eventCount int + if err := store.db.QueryRow("SELECT count(*) FROM attempt_events WHERE event_id IN ('event-answered','event-ended')").Scan(&eventCount); err != nil { + t.Fatal(err) + } + if eventCount != 2 { + t.Fatalf("event dedup count = %d", eventCount) + } +} + +func TestNewStoreUpgradesLegacyEgressPoolSchema(t *testing.T) { + path := t.TempDir() + "/legacy.db" + legacy, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + _, err = legacy.Exec(`CREATE TABLE egress_pools ( + egress_pool_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + fixed_ips_json TEXT NOT NULL DEFAULT '[]', + whitelist_status TEXT NOT NULL DEFAULT 'unknown', + whitelist_checked_at TEXT, + source TEXT NOT NULL DEFAULT 'mock', + updated_at TEXT NOT NULL + )`) + if err != nil { + legacy.Close() + t.Fatal(err) + } + if err := legacy.Close(); err != nil { + t.Fatal(err) + } + store, err := NewStore(path) + if err != nil { + t.Fatal(err) + } + defer store.Close() + var providerID string + if err := store.db.QueryRow("SELECT provider_id FROM egress_pools").Scan(&providerID); err != sql.ErrNoRows { + t.Fatalf("expected upgraded empty table, got %v", err) + } + var columnCount int + if err := store.db.QueryRow("SELECT count(*) FROM pragma_table_info('egress_pools') WHERE name='provider_id'").Scan(&columnCount); err != nil { + t.Fatal(err) + } + if columnCount != 1 { + t.Fatalf("provider_id column count = %d", columnCount) + } +} + +var _ = sql.ErrNoRows diff --git a/management/backend/sqlc.yaml b/management/backend/sqlc.yaml new file mode 100644 index 0000000..7417c4f --- /dev/null +++ b/management/backend/sqlc.yaml @@ -0,0 +1,12 @@ +version: "2" +sql: + - engine: "sqlite" + schema: "migrations/001_init.sql" + queries: "queries" + gen: + go: + package: "db" + out: "internal/db" + sql_package: "database/sql" + emit_json_tags: true + emit_empty_slices: true diff --git a/management/backend/stats.go b/management/backend/stats.go new file mode 100644 index 0000000..4a1abb0 --- /dev/null +++ b/management/backend/stats.go @@ -0,0 +1,573 @@ +package management + +import ( + "encoding/base64" + "encoding/json" + "errors" + "sort" + "strconv" + "strings" + "time" + + "database/sql" + "github.com/gofiber/fiber/v3" +) + +type statsQuery struct { + From *time.Time + To *time.Time + Mode string + ProviderID string + TrunkID string + CellID string + EgressPoolID string +} + +type metricAccumulator struct { + Attempts, StartedConfirmed, StartedUncertain, NotStarted, Rejected, Answered, TerminalNotAnswered, PendingNotAnswered, ActiveAnswered, AIFailed int + TalkSeconds []float64 + WaitSeconds []float64 + FailureReasons map[string]int + Starts []time.Time + Answers []time.Time +} + +func parseStatsQuery(c fiber.Ctx, serverMode string) (statsQuery, *AppError) { + mode := strings.ToLower(strings.TrimSpace(c.Query("mode"))) + if mode == "" { + mode = serverMode + } + if mode == "all" { + mode = "mixed" + } + if mode != "mock" && mode != "real" && mode != "mixed" { + return statsQuery{}, newAppError(422, "INVALID_MODE", "mode must be mock, mixed, or real", nil) + } + to := time.Now().UTC() + from := to.Add(-24 * time.Hour) + var err error + if raw := c.Query("from"); raw != "" { + from, err = time.Parse(time.RFC3339, raw) + if err != nil { + return statsQuery{}, newAppError(422, "INVALID_FROM", "from must be RFC3339 UTC", nil) + } + } + if raw := c.Query("to"); raw != "" { + to, err = time.Parse(time.RFC3339, raw) + if err != nil { + return statsQuery{}, newAppError(422, "INVALID_TO", "to must be RFC3339 UTC", nil) + } + } + from = from.UTC() + to = to.UTC() + if !to.After(from) { + return statsQuery{}, newAppError(422, "INVALID_RANGE", "to must be after from", nil) + } + if to.Sub(from) > 31*24*time.Hour { + return statsQuery{}, newAppError(422, "RANGE_TOO_LARGE", "statistics range cannot exceed 31 days", nil) + } + return statsQuery{From: &from, To: &to, Mode: mode, ProviderID: c.Query("provider_id"), TrunkID: c.Query("trunk_id"), CellID: c.Query("cell_id"), EgressPoolID: c.Query("egress_pool_id")}, nil +} + +func (s *Server) outboundSummary(c fiber.Ctx) error { + p, authErr := s.authorize(c, "admin", "sip.statistics.read", "", "") + if authErr != nil { + return s.fail(c, authErr) + } + if err := s.checkFilterResources(c, p); err != nil { + return s.fail(c, err) + } + q, appErr := parseStatsQuery(c, s.cfg.PublicMode()) + if appErr != nil { + return s.fail(c, appErr) + } + ctx, cancel := s.context(c) + defer cancel() + attempts, err := s.store.ListAttempts(ctx, AttemptFilter{From: q.From, To: q.To, ProviderID: q.ProviderID, TrunkID: q.TrunkID, CellID: q.CellID, EgressPoolID: q.EgressPoolID, Mode: modeFilter(q.Mode)}) + if err != nil { + return s.fail(c, asAppError(err)) + } + timeline, err := s.store.ListAttempts(ctx, AttemptFilter{From: q.From, To: q.To, ProviderID: q.ProviderID, TrunkID: q.TrunkID, CellID: q.CellID, EgressPoolID: q.EgressPoolID, Mode: modeFilter(q.Mode), Timeline: true}) + if err != nil { + return s.fail(c, asAppError(err)) + } + now := time.Now().UTC() + current, err := s.store.ListAttempts(ctx, AttemptFilter{ProviderID: q.ProviderID, TrunkID: q.TrunkID, CellID: q.CellID, EgressPoolID: q.EgressPoolID, Mode: modeFilter(q.Mode), Timeline: true}) + if err != nil { + return s.fail(c, asAppError(err)) + } + cpsFrom := now.Truncate(time.Second).Add(-time.Second) + cpsTo := cpsFrom.Add(time.Second) + recent, err := s.store.ListAttempts(ctx, AttemptFilter{From: &cpsFrom, To: &cpsTo, ProviderID: q.ProviderID, TrunkID: q.TrunkID, CellID: q.CellID, EgressPoolID: q.EgressPoolID, Mode: modeFilter(q.Mode)}) + if err != nil { + return s.fail(c, asAppError(err)) + } + attempts = filterAttemptResources(p, attempts) + timeline = filterAttemptResources(p, timeline) + current = filterAttemptResources(p, current) + recent = filterAttemptResources(p, recent) + current = activeAttempts(current, now) + result := summaryResult(q, attempts, timeline, current, recent, now, cpsFrom, cpsTo) + return c.JSON(result) +} + +func filterAttemptResources(p principal, attempts []Attempt) []Attempt { + if len(p.Resources) == 0 { + return attempts + } + out := attempts[:0] + for _, attempt := range attempts { + if resourceAllowed(p.TokenSpec, "provider", attempt.ProviderID) && resourceAllowed(p.TokenSpec, "trunk", attempt.TrunkID) && resourceAllowed(p.TokenSpec, "cell", attempt.CellID) && resourceAllowed(p.TokenSpec, "egress_pool", attempt.EgressPoolID) { + out = append(out, attempt) + } + } + return out +} + +func activeAttempts(attempts []Attempt, asOf time.Time) []Attempt { + active := make([]Attempt, 0, len(attempts)) + for _, attempt := range attempts { + if attempt.AnsweredAt != nil && (attempt.EndedAt == nil || attempt.EndedAt.After(asOf)) { + active = append(active, attempt) + } + } + return active +} + +func modeFilter(mode string) string { + if mode == "mixed" { + return "" + } + return mode +} + +func summaryResult(q statsQuery, attempts, timeline, current, recent []Attempt, sampledAt, cpsFrom, cpsTo time.Time) map[string]any { + m := accumulate(attempts, *q.To) + complete := m.StartedUncertain == 0 && validFacts(attempts) + missing := []string{} + if len(attempts) == 0 { + missing = append(missing, "attempt_facts") + } + if m.StartedUncertain > 0 { + missing = append(missing, "origin_confirmation") + } + if !validFacts(attempts) { + missing = append(missing, "fact_order") + } + dataAsOf := any(nil) + for _, a := range attempts { + if !a.UpdatedAt.IsZero() && (dataAsOf == nil || a.UpdatedAt.Format(time.RFC3339Nano) > dataAsOf.(string)) { + dataAsOf = utcString(a.UpdatedAt) + } + } + var answerRate any + if m.StartedConfirmed > 0 { + answerRate = float64(m.Answered) / float64(m.StartedConfirmed) + } + realtime := accumulate(current, sampledAt) + talkStats := durationStats(m.TalkSeconds) + metrics := map[string]any{"attempts_total": m.Attempts, "started_confirmed": m.StartedConfirmed, "started_uncertain": m.StartedUncertain, "not_started": m.NotStarted, "rejected": m.Rejected, "answered": m.Answered, "terminal_not_answered": m.TerminalNotAnswered, "pending_not_answered": m.PendingNotAnswered, "active_answered": realtime.ActiveAnswered, "ai_failed_after_answer": m.AIFailed, "answer_rate": answerRate, "talk_duration_seconds": talkStats, "acd_seconds": talkStats["avg"], "total_talk_seconds": talkStats["total"], "wait_duration_seconds": durationStats(m.WaitSeconds), "peak_concurrency": peakConcurrency(timeline, *q.From, *q.To), "peak_cps": peakCPS(m.Starts, *q.From, *q.To)} + return map[string]any{"mode": q.Mode, "from": utcString(*q.From), "to": utcString(*q.To), "timezone": "UTC", "definition_version": "sip-statistics.v1", "filters": map[string]string{"provider_id": q.ProviderID, "trunk_id": q.TrunkID, "cell_id": q.CellID, "egress_pool_id": q.EgressPoolID}, "complete": complete, "missing_sources": missing, "unresolved_count": m.StartedUncertain + m.PendingNotAnswered, "data_as_of": dataAsOf, "generated_at": utcString(time.Now()), "metrics": metrics, "realtime": map[string]any{"current_answered": realtime.ActiveAnswered, "current_cps": peakCPS(accumulate(recent, cpsTo).Starts, cpsFrom, cpsTo), "cps_bucket_start": utcString(cpsFrom), "cps_bucket_end": utcString(cpsTo), "sampled_at": utcString(sampledAt), "complete": validFacts(current) && validFacts(recent), "source": "attempt_facts"}, "failure_reasons": failureReasonList(m.FailureReasons), "semantics": map[string]string{"started_confirmed": "only attempts with a persisted confirmed origin fact", "answered": "answered_at is present and not after ended_at", "terminal_not_answered": "ended_at is present while answered_at is absent", "complete": "false when origin or time-order facts are uncertain"}} +} + +func accumulate(attempts []Attempt, to time.Time) metricAccumulator { + m := metricAccumulator{FailureReasons: map[string]int{}} + for _, a := range attempts { + if a.OriginStatus == "rejected" { + m.Rejected++ + continue + } + m.Attempts++ + switch a.OriginStatus { + case "confirmed": + m.StartedConfirmed++ + case "uncertain": + m.StartedUncertain++ + case "not_started": + m.NotStarted++ + } + if a.AttemptStartedAt != nil { + m.Starts = append(m.Starts, *a.AttemptStartedAt) + } + if a.AnsweredAt != nil { + m.Answers = append(m.Answers, *a.AnsweredAt) + m.Answered++ + if a.EndedAt == nil || a.EndedAt.After(to) { + m.ActiveAnswered++ + } + } else if a.EndedAt != nil { + m.TerminalNotAnswered++ + reason := strings.ToLower(strings.TrimSpace(a.TerminationReason)) + if reason == "" { + reason = "unknown" + } + m.FailureReasons[reason]++ + } else if a.OriginStatus == "confirmed" || a.OriginStatus == "uncertain" { + m.PendingNotAnswered++ + } + if a.AIStatus == "failed" && a.AnsweredAt != nil { + m.AIFailed++ + } + if a.AnsweredAt != nil && a.EndedAt != nil && !a.EndedAt.Before(*a.AnsweredAt) { + m.TalkSeconds = append(m.TalkSeconds, a.EndedAt.Sub(*a.AnsweredAt).Seconds()) + if a.AttemptStartedAt != nil && !a.AnsweredAt.Before(*a.AttemptStartedAt) { + m.WaitSeconds = append(m.WaitSeconds, a.AnsweredAt.Sub(*a.AttemptStartedAt).Seconds()) + } + } + } + return m +} +func validFacts(attempts []Attempt) bool { + for _, a := range attempts { + if a.AnsweredAt != nil && a.AttemptStartedAt != nil && a.AnsweredAt.Before(*a.AttemptStartedAt) { + return false + } + if a.EndedAt != nil && a.AnsweredAt != nil && a.EndedAt.Before(*a.AnsweredAt) { + return false + } + } + return true +} +func durationStats(values []float64) map[string]any { + if len(values) == 0 { + return map[string]any{"count": 0, "total": 0, "p50": nil, "p95": nil, "avg": nil} + } + sort.Float64s(values) + sum := 0.0 + for _, v := range values { + sum += v + } + return map[string]any{"count": len(values), "total": sum, "p50": percentile(values, .50), "p95": percentile(values, .95), "avg": sum / float64(len(values))} +} +func percentile(v []float64, p float64) float64 { + if len(v) == 0 { + return 0 + } + i := int(float64(len(v)-1)*p + 0.5) + if i < 0 { + i = 0 + } + if i >= len(v) { + i = len(v) - 1 + } + return v[i] +} +func failureReasonList(values map[string]int) []map[string]any { + keys := make([]string, 0, len(values)) + for k := range values { + keys = append(keys, k) + } + sort.Strings(keys) + out := make([]map[string]any, 0, len(keys)) + for _, k := range keys { + out = append(out, map[string]any{"reason": k, "count": values[k]}) + } + return out +} + +func peakConcurrency(attempts []Attempt, from, to time.Time) int { + type event struct { + t time.Time + delta int + } + events := []event{} + for _, a := range attempts { + if a.AnsweredAt == nil { + continue + } + start := *a.AnsweredAt + if start.Before(from) { + start = from + } + if !start.Before(to) { + continue + } + end := to + if a.EndedAt != nil && a.EndedAt.Before(end) { + end = *a.EndedAt + } + if !end.After(start) { + continue + } + events = append(events, event{start, 1}, event{end, -1}) + } + sort.Slice(events, func(i, j int) bool { + if events[i].t.Equal(events[j].t) { + return events[i].delta < events[j].delta + } + return events[i].t.Before(events[j].t) + }) + current, max := 0, 0 + for _, e := range events { + current += e.delta + if current > max { + max = current + } + } + return max +} +func peakCPS(starts []time.Time, from, to time.Time) int { + counts := map[string]int{} + for _, t := range starts { + if t.Before(from) || !t.Before(to) { + continue + } + key := t.UTC().Truncate(time.Second).Format(time.RFC3339) + counts[key]++ + } + max := 0 + for _, v := range counts { + if v > max { + max = v + } + } + return max +} + +func (s *Server) outboundTimeseries(c fiber.Ctx) error { + p, authErr := s.authorize(c, "admin", "sip.statistics.read", "", "") + if authErr != nil { + return s.fail(c, authErr) + } + if err := s.checkFilterResources(c, p); err != nil { + return s.fail(c, err) + } + q, appErr := parseStatsQuery(c, s.cfg.PublicMode()) + if appErr != nil { + return s.fail(c, appErr) + } + gran := strings.ToLower(c.Query("granularity")) + if gran == "" { + gran = "hour" + } + step := time.Hour + switch gran { + case "minute": + step = time.Minute + if q.To.Sub(*q.From) > 24*time.Hour { + return s.fail(c, newAppError(422, "GRANULARITY_TOO_FINE", "minute series cannot exceed 24 hours", nil)) + } + case "hour": + step = time.Hour + case "day": + step = 24 * time.Hour + default: + return s.fail(c, newAppError(422, "INVALID_GRANULARITY", "granularity must be minute, hour, or day", nil)) + } + ctx, cancel := s.context(c) + defer cancel() + attempts, err := s.store.ListAttempts(ctx, AttemptFilter{From: q.From, To: q.To, ProviderID: q.ProviderID, TrunkID: q.TrunkID, CellID: q.CellID, EgressPoolID: q.EgressPoolID, Mode: modeFilter(q.Mode)}) + if err != nil { + return s.fail(c, asAppError(err)) + } + timeline, err := s.store.ListAttempts(ctx, AttemptFilter{From: q.From, To: q.To, ProviderID: q.ProviderID, TrunkID: q.TrunkID, CellID: q.CellID, EgressPoolID: q.EgressPoolID, Mode: modeFilter(q.Mode), Timeline: true}) + if err != nil { + return s.fail(c, asAppError(err)) + } + attempts = filterAttemptResources(p, attempts) + timeline = filterAttemptResources(p, timeline) + buckets := make([]map[string]any, 0) + start := q.From.Truncate(step) + for bucket := start; bucket.Before(*q.To); bucket = bucket.Add(step) { + end := bucket.Add(step) + if end.After(*q.To) { + end = *q.To + } + windowFrom := bucket + if windowFrom.Before(*q.From) { + windowFrom = *q.From + } + var subset []Attempt + for _, a := range attempts { + if a.AttemptStartedAt != nil && !a.AttemptStartedAt.Before(windowFrom) && a.AttemptStartedAt.Before(end) { + subset = append(subset, a) + } + } + m := accumulate(subset, end) + buckets = append(buckets, map[string]any{"bucket_start": utcString(bucket), "bucket_end": utcString(end), "attempts_total": m.Attempts, "started_confirmed": m.StartedConfirmed, "started_uncertain": m.StartedUncertain, "answered": m.Answered, "terminal_not_answered": m.TerminalNotAnswered, "pending_not_answered": m.PendingNotAnswered, "peak_concurrency": peakConcurrency(timelineForBucket(timeline, windowFrom, end), windowFrom, end), "peak_cps": peakCPS(m.Starts, windowFrom, end)}) + } + return c.JSON(map[string]any{"mode": q.Mode, "from": utcString(*q.From), "to": utcString(*q.To), "timezone": "UTC", "definition_version": "sip-statistics.v1", "filters": map[string]string{"provider_id": q.ProviderID, "trunk_id": q.TrunkID, "cell_id": q.CellID, "egress_pool_id": q.EgressPoolID}, "granularity": gran, "complete": validFacts(attempts) && countUncertain(attempts) == 0, "series": buckets}) +} +func timelineForBucket(attempts []Attempt, from, to time.Time) []Attempt { + out := make([]Attempt, 0, len(attempts)) + for _, attempt := range attempts { + if attempt.AnsweredAt != nil && attempt.AnsweredAt.Before(to) && (attempt.EndedAt == nil || attempt.EndedAt.After(from)) { + out = append(out, attempt) + } + } + return out +} + +func countUncertain(v []Attempt) int { + n := 0 + for _, a := range v { + if a.OriginStatus == "uncertain" { + n++ + } + } + return n +} + +func (s *Server) callAttempts(c fiber.Ctx) error { + p, authErr := s.authorize(c, "admin", "sip.calls.read", "", "") + if authErr != nil { + return s.fail(c, authErr) + } + if err := s.checkFilterResources(c, p); err != nil { + return s.fail(c, err) + } + q, appErr := parseStatsQuery(c, s.cfg.PublicMode()) + if appErr != nil { + return s.fail(c, appErr) + } + limit := 50 + if raw := c.Query("limit"); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n < 1 || n > 200 { + return s.fail(c, newAppError(422, "INVALID_LIMIT", "limit must be between 1 and 200", nil)) + } + limit = n + } + ctx, cancel := s.context(c) + defer cancel() + attempts, err := s.store.ListAttempts(ctx, AttemptFilter{From: q.From, To: q.To, ProviderID: q.ProviderID, TrunkID: q.TrunkID, CellID: q.CellID, EgressPoolID: q.EgressPoolID, Mode: modeFilter(q.Mode), Limit: limit + 1}) + if err != nil { + return s.fail(c, asAppError(err)) + } + attempts = filterAttemptResources(p, attempts) + fingerprint := statsFingerprint(q) + cursor := c.Query("cursor") + if cursor != "" { + last, err := decodeCursor(cursor) + if err != nil || last.Filter != fingerprint { + return s.fail(c, newAppError(400, "INVALID_CURSOR", "cursor is invalid for the current filters", nil)) + } + filtered := attempts[:0] + for _, a := range attempts { + current := cursorTime(a) + if current.After(last.Time) || (current.Equal(last.Time) && a.AttemptID > last.ID) { + filtered = append(filtered, a) + } + } + attempts = filtered + } + next := "" + if len(attempts) > limit { + last := attempts[limit-1] + next = encodeCursor(last, fingerprint) + attempts = attempts[:limit] + } + out := make([]map[string]any, 0, len(attempts)) + for _, a := range attempts { + out = append(out, attemptView(a, false)) + } + return c.JSON(map[string]any{"mode": q.Mode, "from": utcString(*q.From), "to": utcString(*q.To), "complete": validFacts(attempts) && countUncertain(attempts) == 0, "attempts": out, "next_cursor": next}) +} + +type cursorValue struct { + Time time.Time + ID, Filter string +} + +func cursorTime(a Attempt) time.Time { + if a.AttemptStartedAt != nil { + return *a.AttemptStartedAt + } + return a.UpdatedAt +} +func statsFingerprint(q statsQuery) string { + b, _ := json.Marshal(map[string]any{"from": q.From, "to": q.To, "mode": q.Mode, "provider_id": q.ProviderID, "trunk_id": q.TrunkID, "cell_id": q.CellID, "egress_pool_id": q.EgressPoolID}) + return hashBytes(b) +} +func encodeCursor(a Attempt, filter string) string { + b, _ := json.Marshal(map[string]string{"time": utcString(cursorTime(a)), "id": a.AttemptID, "filter": filter}) + return base64.RawURLEncoding.EncodeToString(b) +} +func decodeCursor(raw string) (cursorValue, error) { + b, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return cursorValue{}, err + } + var v struct { + Time string `json:"time"` + ID string `json:"id"` + Filter string `json:"filter"` + } + if err := json.Unmarshal(b, &v); err != nil { + return cursorValue{}, err + } + t, err := time.Parse(time.RFC3339Nano, v.Time) + return cursorValue{Time: t, ID: v.ID, Filter: v.Filter}, err +} +func attemptView(a Attempt, detail bool) map[string]any { + out := map[string]any{"attempt_id": a.AttemptID, "execution_id": a.ExecutionID, "call_id": a.CallID, "tenant_key": a.TenantKey, "provider_id": a.ProviderID, "trunk_id": a.TrunkID, "cell_id": a.CellID, "egress_pool_id": a.EgressPoolID, "config_revision": a.ConfigRevision, "mode": a.Mode, "dialed_number": maskNumber(a.DialedNumber), "origin_status": a.OriginStatus, "termination_reason": a.TerminationReason, "sip_code": a.SIPCode, "q850": a.Q850, "ai_status": a.AIStatus, "source": a.Source, "fact_version": a.FactVersion, "updated_at": utcString(a.UpdatedAt)} + if a.AttemptStartedAt != nil { + out["attempt_started_at"] = utcString(*a.AttemptStartedAt) + } else { + out["attempt_started_at"] = nil + } + if a.AnsweredAt != nil { + out["answered_at"] = utcString(*a.AnsweredAt) + } else { + out["answered_at"] = nil + } + if a.EndedAt != nil { + out["ended_at"] = utcString(*a.EndedAt) + } else { + out["ended_at"] = nil + } + if !detail { + delete(out, "execution_id") + delete(out, "call_id") + } + return out +} +func (s *Server) callAttempt(c fiber.Ctx) error { + id := c.Params("attempt_id") + p, authErr := s.authorize(c, "admin", "sip.calls.read", "", "") + if authErr != nil { + return s.fail(c, authErr) + } + _ = p + ctx, cancel := s.context(c) + defer cancel() + a, err := s.store.GetAttempt(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return s.fail(c, newAppError(404, "ATTEMPT_NOT_FOUND", "call attempt does not exist", nil)) + } + if err != nil { + return s.fail(c, asAppError(err)) + } + return c.JSON(map[string]any{"mode": s.cfg.PublicMode(), "attempt": attemptView(a, true)}) +} + +func (s *Server) audit(c fiber.Ctx) error { + p, authErr := s.authorize(c, "admin", "sip.audit.read", "", "") + if authErr != nil { + return s.fail(c, authErr) + } + if err := s.checkFilterResources(c, p); err != nil { + return s.fail(c, err) + } + limit := 50 + if raw := c.Query("limit"); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n < 1 || n > 200 { + return s.fail(c, newAppError(422, "INVALID_LIMIT", "limit must be between 1 and 200", nil)) + } + limit = n + } + ctx, cancel := s.context(c) + defer cancel() + rows, err := s.store.ListAudit(ctx, c.Query("resource_id"), c.Query("request_id"), c.Query("actor"), limit) + if err != nil { + return s.fail(c, asAppError(err)) + } + visible := rows[:0] + for _, row := range rows { + if resourceAllowed(p.TokenSpec, row.ResourceType, row.ResourceID) { + visible = append(visible, row) + } + } + return c.JSON(map[string]any{"mode": s.cfg.PublicMode(), "audit": visible}) +} diff --git a/management/backend/status.go b/management/backend/status.go new file mode 100644 index 0000000..5edd486 --- /dev/null +++ b/management/backend/status.go @@ -0,0 +1,334 @@ +package management + +import ( + "context" + "database/sql" + "errors" + "net/http" + "strings" + "time" + + "github.com/gofiber/fiber/v3" +) + +func (s *Server) sipStatus(c fiber.Ctx) error { + p, authErr := s.authorize(c, "admin", "sip.status.read", "", "") + if authErr != nil { + return s.fail(c, authErr) + } + if err := s.checkFilterResources(c, p); err != nil { + return s.fail(c, err) + } + ctx, cancel := s.context(c) + defer cancel() + cells, err := s.store.ListCells(ctx) + if err != nil { + return s.fail(c, asAppError(err)) + } + trunks, err := s.store.ListTrunks(ctx, s.cfg.PublicMode(), c.Query("provider_id")) + if err != nil { + return s.fail(c, asAppError(err)) + } + visibleCells := cells[:0] + for _, cell := range cells { + if resourceAllowed(p.TokenSpec, "cell", cell.CellID) { + visibleCells = append(visibleCells, cell) + } + } + cells = visibleCells + visibleTrunks := trunks[:0] + for _, trunk := range trunks { + if resourceAllowed(p.TokenSpec, "trunk", trunk.TrunkID) && resourceAllowed(p.TokenSpec, "provider", trunk.ProviderID) { + visibleTrunks = append(visibleTrunks, trunk) + } + } + trunks = visibleTrunks + requested := parseCSV(c.Query("cell_ids")) + if len(requested) == 0 { + requested = parseCSV(c.Query("cell_id")) + } + items := make([]map[string]any, 0) + for _, cell := range cells { + if len(requested) > 0 && !containsString(requested, cell.CellID) { + continue + } + items = append(items, s.cellStatus(ctx, cell, "", trunks)) + } + complete := true + for _, item := range items { + if value, _ := item["complete"].(bool); !value { + complete = false + } + } + coverage, dataAsOf := statusCoverage(items) + return c.JSON(map[string]any{"mode": s.cfg.PublicMode(), "complete": complete, "generated_at": utcString(time.Now()), "data_as_of": dataAsOf, "coverage": coverage, "cells": items}) +} + +func (s *Server) cellSIPStatus(c fiber.Ctx) error { + cellID := c.Params("cell_id") + p, authErr := s.authorize(c, "admin", "sip.status.read", "cell", cellID) + if authErr != nil { + return s.fail(c, authErr) + } + _ = p + ctx, cancel := s.context(c) + defer cancel() + cell, err := s.store.GetCell(ctx, cellID) + if errors.Is(err, sql.ErrNoRows) { + return s.fail(c, newAppError(404, "CELL_NOT_FOUND", "cell does not exist", nil)) + } + if err != nil { + return s.fail(c, asAppError(err)) + } + trunkID := c.Query("trunk_id") + if trunkID != "" && !resourceAllowed(p.TokenSpec, "trunk", trunkID) { + return s.fail(c, newAppError(http.StatusForbidden, "RESOURCE_FORBIDDEN", "requested trunk is outside the authorized scope", nil)) + } + trunks, err := s.store.ListTrunks(ctx, s.cfg.PublicMode(), "") + if err != nil { + return s.fail(c, asAppError(err)) + } + visibleTrunks := trunks[:0] + for _, trunk := range trunks { + if resourceAllowed(p.TokenSpec, "trunk", trunk.TrunkID) && resourceAllowed(p.TokenSpec, "provider", trunk.ProviderID) { + visibleTrunks = append(visibleTrunks, trunk) + } + } + return c.JSON(s.cellStatus(ctx, cell, trunkID, visibleTrunks)) +} + +func (s *Server) providerStatus(c fiber.Ctx) error { + providerID := c.Params("provider_id") + p, authErr := s.authorize(c, "admin", "sip.status.read", "provider", providerID) + if authErr != nil { + return s.fail(c, authErr) + } + ctx, cancel := s.context(c) + defer cancel() + provider, err := s.store.GetProvider(ctx, providerID) + if errors.Is(err, sql.ErrNoRows) { + return s.fail(c, newAppError(404, "PROVIDER_NOT_FOUND", "provider does not exist", nil)) + } + if err != nil { + return s.fail(c, asAppError(err)) + } + trunks, err := s.store.ListTrunks(ctx, s.cfg.PublicMode(), providerID) + if err != nil { + return s.fail(c, asAppError(err)) + } + visibleTrunks := trunks[:0] + for _, trunk := range trunks { + if resourceAllowed(p.TokenSpec, "trunk", trunk.TrunkID) { + visibleTrunks = append(visibleTrunks, trunk) + } + } + trunks = visibleTrunks + cells, err := s.store.ListCells(ctx) + if err != nil { + return s.fail(c, asAppError(err)) + } + visibleCells := cells[:0] + for _, cell := range cells { + if resourceAllowed(p.TokenSpec, "cell", cell.CellID) { + visibleCells = append(visibleCells, cell) + } + } + cells = visibleCells + cellViews := make([]map[string]any, 0, len(cells)) + complete := true + for _, cell := range cells { + view := s.cellStatus(ctx, cell, "", trunks) + cellViews = append(cellViews, view) + if ok, _ := view["complete"].(bool); !ok { + complete = false + } + } + coverage, dataAsOf := statusCoverage(cellViews) + return c.JSON(map[string]any{"mode": s.cfg.PublicMode(), "complete": complete, "provider": provider, "trunks": trunks, "cells": cellViews, "generated_at": utcString(time.Now()), "data_as_of": dataAsOf, "coverage": coverage}) +} + +func (s *Server) cellStatus(ctx context.Context, cell Cell, trunkID string, trunks []AdminTrunk) map[string]any { + observations, err := s.store.LatestObservations(ctx, cell.CellID, trunkID) + view := map[string]any{"mode": s.cfg.PublicMode(), "cell_id": cell.CellID, "config_revision": cell.Revision, "config_status": cell.Config.Status, "egress_pool_id": cell.Config.EgressPoolID, "complete": false, "availability": "unknown", "eligibility": "unknown", "missing_sources": []string{"cell_agent", "asterisk", "ari", "registration", "media"}} + if cell.Config.Status == "disabled" { + view["availability"] = "disabled" + view["complete"] = true + view["missing_sources"] = []string{} + } else if err != nil { + view["error"] = "observation query failed" + return view + } else { + var selected *ObservationInput + if len(observations) > 0 { + selected = &observations[0] + } + if selected == nil { + view["reason"] = "no observation has been received for the current Cell boot" + } else { + age := time.Since(selected.ObservedAt).Seconds() + clockSkew := age < 0 + view["clock_skew"] = clockSkew + view["observed_at"] = utcString(selected.ObservedAt) + view["received_at"] = utcString(selected.ReceivedAt) + if clockSkew { + view["observation_age_seconds"] = nil + } else { + view["observation_age_seconds"] = int64(age) + } + view["boot_id"] = selected.BootID + view["sequence"] = selected.Sequence + view["states"] = selected.States + view["occupancy"] = selected.Occupancy + if eligibility, ok := selected.States["eligibility"]; ok { + view["eligibility"] = eligibility + } + missing := missingSources(selected.States) + view["missing_sources"] = missing + if !clockSkew && age <= 15 && len(missing) == 0 { + view["availability"] = "healthy" + view["complete"] = true + } else if !clockSkew && age <= 30 { + view["availability"] = "stale" + view["reason"] = "observation is stale or incomplete" + } else if clockSkew { + view["availability"] = "unknown" + view["reason"] = "observation clock is ahead of the management server" + } else { + view["availability"] = "unknown" + view["reason"] = "observation is too old" + } + } + } + if trunkID != "" { + view["trunk_id"] = trunkID + view["publication"] = s.publicationState(ctx, cell.CellID, trunkID, trunks) + } else { + matrix := make([]map[string]any, 0, len(trunks)) + for _, trunk := range trunks { + publication := s.publicationState(ctx, cell.CellID, trunk.TrunkID, trunks) + matrix = append(matrix, map[string]any{"provider_id": trunk.ProviderID, "trunk_id": trunk.TrunkID, "status": trunk.Status, "publication": publication}) + } + view["trunks"] = matrix + } + return view +} + +func statusCoverage(items []map[string]any) (map[string]any, *time.Time) { + covered := make([]string, 0, len(items)) + missing := make([]string, 0) + var latest *time.Time + for _, item := range items { + cellID, _ := item["cell_id"].(string) + availability, _ := item["availability"].(string) + complete, _ := item["complete"].(bool) + if complete || availability == "disabled" { + covered = append(covered, cellID) + } else { + missing = append(missing, cellID) + } + if observed, ok := item["observed_at"].(string); ok { + if parsed, err := time.Parse(time.RFC3339Nano, observed); err == nil && (latest == nil || parsed.After(*latest)) { + value := parsed + latest = &value + } + } + } + return map[string]any{"covered_cell_ids": covered, "missing_cell_ids": missing}, latest +} + +func missingSources(states map[string]any) []string { + if states == nil { + return []string{"cell_agent", "asterisk", "ari", "registration", "media"} + } + missing := make([]string, 0) + for _, key := range []string{"cell_agent", "asterisk", "ari", "registration", "media"} { + value, ok := states[key] + if !ok || value == nil || strings.EqualFold(toString(value), "unknown") || strings.EqualFold(toString(value), "missing") { + missing = append(missing, key) + } + } + return missing +} + +func toString(v any) string { + switch value := v.(type) { + case string: + return value + case bool: + if value { + return "true" + } + return "false" + default: + return "" + } +} + +func (s *Server) publicationState(ctx context.Context, cellID, trunkID string, trunks []AdminTrunk) map[string]any { + state := map[string]any{"state": "unknown", "cell_id": cellID, "trunk_id": trunkID} + var trunk *AdminTrunk + for i := range trunks { + if trunks[i].TrunkID == trunkID { + trunk = &trunks[i] + break + } + } + if trunk == nil { + return state + } + state["desired_revision"] = trunk.ActiveRevision + if trunk.ActiveRevision == 0 { + state["state"] = "not_published" + return state + } + pubs, err := s.store.GetPublicationRows(ctx, trunkID, &trunk.ActiveRevision) + if err != nil { + return state + } + for _, pub := range pubs { + if pub.CellID == cellID { + state["observed_revision"] = pub.LocalRevision + state["observed_digest"] = pub.LocalDigest + state["desired_digest"] = pub.TargetDigest + state["state"] = pub.Status + if pub.Status == "applied" && pub.LocalRevision == trunk.ActiveRevision && pub.LocalDigest == pub.TargetDigest { + state["state"] = "in_sync" + } + return state + } + } + state["state"] = "not_published" + return state +} + +func (s *Server) ingestObservation(c fiber.Ctx) error { + cellID := c.Params("cell_id") + p, authErr := s.authorize(c, "admin", "sip.cell.write", "cell", cellID) + if authErr != nil { + return s.fail(c, authErr) + } + _ = p + var input ObservationInput + if err := decodeJSON(c, &input); err != nil { + return s.fail(c, err) + } + if input.CellID == "" { + input.CellID = cellID + } + if input.CellID != cellID { + return s.fail(c, newAppError(422, "CELL_ID_MISMATCH", "body cell_id does not match the path", nil)) + } + input.ReceivedAt = time.Now() + if input.Source == "" { + input.Source = "mock" + } + if input.ObservationID == "" { + input.ObservationID = newID("obs") + } + ctx, cancel := s.context(c) + defer cancel() + if err := s.store.InsertObservation(ctx, input); err != nil { + return s.fail(c, asAppError(err)) + } + return c.Status(http.StatusCreated).JSON(map[string]any{"mode": s.cfg.PublicMode(), "observation_id": input.ObservationID, "accepted": true}) +} diff --git a/management/backend/store.go b/management/backend/store.go new file mode 100644 index 0000000..d713587 --- /dev/null +++ b/management/backend/store.go @@ -0,0 +1,1344 @@ +package management + +import ( + "context" + "database/sql" + _ "embed" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + dbgen "github.com/rogee/agent-call-management/internal/db" + _ "modernc.org/sqlite" +) + +//go:embed migrations/001_init.sql +var migrationSQL string + +type Store struct { + db *sql.DB + queries *dbgen.Queries + mu sync.Mutex +} + +func NewStore(path string) (*Store, error) { + if path == "" { + path = "./data/management.db" + } + if path != ":memory:" && !strings.HasPrefix(path, "file:") { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return nil, fmt.Errorf("create database directory: %w", err) + } + } + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + db.SetConnMaxLifetime(0) + s := &Store{db: db, queries: dbgen.New(db)} + if _, err := db.Exec("PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL;"); err != nil { + db.Close() + return nil, fmt.Errorf("configure database: %w", err) + } + if _, err := db.Exec(migrationSQL); err != nil { + db.Close() + return nil, fmt.Errorf("migrate database: %w", err) + } + if err := ensureSchemaCompatibility(db); err != nil { + db.Close() + return nil, err + } + if _, err := db.Exec("INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES(1, ?)", utcString(time.Now())); err != nil { + db.Close() + return nil, fmt.Errorf("record migration: %w", err) + } + return s, nil +} + +func ensureSchemaCompatibility(db *sql.DB) error { + rows, err := db.Query("PRAGMA table_info(egress_pools)") + if err != nil { + return fmt.Errorf("inspect database schema: %w", err) + } + defer rows.Close() + hasProvider := false + for rows.Next() { + var cid, name, typ string + var notNull, pk int + var defaultValue any + if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &pk); err != nil { + return err + } + if name == "provider_id" { + hasProvider = true + } + } + if err := rows.Err(); err != nil { + return err + } + if !hasProvider { + if _, err := db.Exec("ALTER TABLE egress_pools ADD COLUMN provider_id TEXT NOT NULL DEFAULT ''"); err != nil { + return fmt.Errorf("upgrade egress pool schema: %w", err) + } + } + return nil +} + +func NewMemoryStore() (*Store, error) { + return NewStore("file:management-" + newID("test") + "?mode=memory&cache=shared") +} + +func (s *Store) Close() error { + if s == nil || s.db == nil { + return nil + } + return s.db.Close() +} + +func (s *Store) withTx(ctx context.Context, fn func(*sql.Tx) error) error { + s.mu.Lock() + defer s.mu.Unlock() + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + if err := fn(tx); err != nil { + _ = tx.Rollback() + return err + } + return tx.Commit() +} + +func (s *Store) withRead(_ context.Context, fn func(*sql.DB) error) error { + s.mu.Lock() + defer s.mu.Unlock() + return fn(s.db) +} + +func (s *Store) GetProvider(ctx context.Context, id string) (Provider, error) { + var out Provider + err := s.withRead(ctx, func(_ *sql.DB) error { + row, err := s.queries.GetProviderRow(ctx, id) + if err != nil { + return err + } + trunkCount, err := s.trunkCount(ctx, id) + if err != nil { + return err + } + out = Provider{ProviderID: row.ProviderID, DisplayName: row.DisplayName, Notes: row.Notes, Lifecycle: row.Lifecycle, Revision: row.Revision, TrunkCount: trunkCount, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, UpdatedBy: row.UpdatedBy} + return nil + }) + return out, err +} + +func (s *Store) ListProviders(ctx context.Context) ([]Provider, error) { + var out []Provider + err := s.withRead(ctx, func(_ *sql.DB) error { + rows, err := s.queries.ListProviderRows(ctx) + if err != nil { + return err + } + out = make([]Provider, 0, len(rows)) + for _, row := range rows { + out = append(out, Provider{ProviderID: row.ProviderID, DisplayName: row.DisplayName, Notes: row.Notes, Lifecycle: row.Lifecycle, Revision: row.Revision, TrunkCount: int(row.TrunkCount), CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, UpdatedBy: row.UpdatedBy}) + } + return nil + }) + return out, err +} + +func (s *Store) trunkCount(ctx context.Context, providerID string) (int, error) { + var count int + err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM trunks WHERE provider_id = ?", providerID).Scan(&count) + return count, err +} + +func (s *Store) UpsertProvider(ctx context.Context, id string, input ProviderInput, expected int64, actor, requestID string) (Provider, int, error) { + if !validID(id) { + return Provider{}, 0, newAppError(422, "INVALID_ID", "provider_id is invalid", map[string]any{"provider_id": id}) + } + if input.Lifecycle == "" { + input.Lifecycle = "active" + } + if input.Lifecycle != "active" && input.Lifecycle != "archived" { + return Provider{}, 0, newAppError(422, "INVALID_LIFECYCLE", "lifecycle must be active or archived", nil) + } + if strings.TrimSpace(input.DisplayName) == "" || len(input.DisplayName) > 256 || strings.ContainsAny(input.DisplayName, "\r\n") { + return Provider{}, 0, newAppError(422, "INVALID_DISPLAY_NAME", "display_name is invalid", nil) + } + if len(input.Notes) > 2000 || strings.ContainsAny(input.Notes, "\r\n") { + return Provider{}, 0, newAppError(422, "INVALID_NOTES", "notes is invalid", nil) + } + var status int + err := s.withTx(ctx, func(tx *sql.Tx) error { + var currentRevision int64 + err := tx.QueryRowContext(ctx, "SELECT revision FROM providers WHERE provider_id=?", id).Scan(¤tRevision) + exists := err == nil + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if exists && currentRevision != expected { + return newAppError(409, "REVISION_CONFLICT", "provider revision changed", map[string]any{"expected_revision": expected, "current_revision": currentRevision}) + } + if !exists && expected != 0 { + return newAppError(409, "REVISION_CONFLICT", "new provider requires If-Match: 0", map[string]any{"expected_revision": expected, "current_revision": 0}) + } + if input.Lifecycle == "archived" { + var enabled, occupied int + if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM trunks WHERE provider_id=? AND active_status <> 'disabled'", id).Scan(&enabled); err != nil { + return err + } + if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM attempts WHERE provider_id=? AND (ended_at IS NULL OR ended_at='') AND origin_status IN ('confirmed','uncertain')", id).Scan(&occupied); err != nil { + return err + } + if enabled > 0 || occupied > 0 { + return newAppError(409, "PROVIDER_HAS_DEPENDENCIES", "provider cannot be archived while enabled trunks or active calls remain", map[string]any{"enabled_trunks": enabled, "active_attempts": occupied}) + } + } + now := utcString(time.Now()) + revision := int64(1) + if exists { + revision = currentRevision + 1 + _, err = tx.ExecContext(ctx, "UPDATE providers SET display_name=?, notes=?, lifecycle=?, revision=?, updated_at=?, updated_by=? WHERE provider_id=?", input.DisplayName, input.Notes, input.Lifecycle, revision, now, actor, id) + } else { + _, err = tx.ExecContext(ctx, "INSERT INTO providers(provider_id,display_name,notes,lifecycle,revision,created_at,updated_at,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?)", id, input.DisplayName, input.Notes, input.Lifecycle, revision, now, now, actor, actor) + } + if err != nil { + return err + } + details, _ := json.Marshal(map[string]any{"after": input, "provider_revision": revision}) + if err := insertAudit(ctx, tx, "provider", id, "upsert", revision, actor, requestID, string(details), now); err != nil { + return err + } + status = 200 + if !exists { + status = 201 + } + return nil + }) + if err != nil { + return Provider{}, 0, err + } + out, err := s.GetProvider(ctx, id) + return out, status, err +} + +func (s *Store) GetCell(ctx context.Context, id string) (Cell, error) { + var cell Cell + err := s.withRead(ctx, func(db *sql.DB) error { + var configJSON string + err := db.QueryRowContext(ctx, "SELECT cell_id,revision,config_json,cloud_instance_id,instance_name,region,boot_id,updated_at,updated_by FROM cells WHERE cell_id=?", id).Scan(&cell.CellID, &cell.Revision, &configJSON, &cell.CloudInstanceID, &cell.InstanceName, &cell.Region, &cell.BootID, &cell.UpdatedAt, &cell.UpdatedBy) + if err != nil { + return err + } + if err := json.Unmarshal([]byte(configJSON), &cell.Config); err != nil { + return fmt.Errorf("decode cell config: %w", err) + } + cell.Mode = ModeMock + return nil + }) + return cell, err +} + +func (s *Store) ListCells(ctx context.Context) ([]Cell, error) { + var out []Cell + err := s.withRead(ctx, func(db *sql.DB) error { + rows, err := db.QueryContext(ctx, "SELECT cell_id,revision,config_json,cloud_instance_id,instance_name,region,boot_id,updated_at,updated_by FROM cells ORDER BY cell_id") + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var cell Cell + var configJSON string + if err := rows.Scan(&cell.CellID, &cell.Revision, &configJSON, &cell.CloudInstanceID, &cell.InstanceName, &cell.Region, &cell.BootID, &cell.UpdatedAt, &cell.UpdatedBy); err != nil { + return err + } + if err := json.Unmarshal([]byte(configJSON), &cell.Config); err != nil { + return err + } + cell.Mode = ModeMock + out = append(out, cell) + } + return rows.Err() + }) + return out, err +} + +func (s *Store) UpsertCell(ctx context.Context, id string, input CellConfig, expected int64, actor, requestID, cloudInstanceID, instanceName, region string) (Cell, int, error) { + if !validID(id) { + return Cell{}, 0, newAppError(422, "INVALID_ID", "cell_id is invalid", nil) + } + if err := validateCellConfig(input); err != nil { + return Cell{}, 0, err + } + if cloudInstanceID == "" { + cloudInstanceID = id + } + if len(cloudInstanceID) > 256 || len(instanceName) > 256 || len(region) > 128 || strings.ContainsAny(cloudInstanceID+instanceName+region, "\r\n") { + return Cell{}, 0, newAppError(422, "INVALID_CELL_METADATA", "cell metadata is invalid", nil) + } + configJSON, err := json.Marshal(input) + if err != nil { + return Cell{}, 0, err + } + var status int + err = s.withTx(ctx, func(tx *sql.Tx) error { + var currentRevision int64 + err := tx.QueryRowContext(ctx, "SELECT revision FROM cells WHERE cell_id=?", id).Scan(¤tRevision) + exists := err == nil + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if exists && currentRevision != expected { + return newAppError(409, "REVISION_CONFLICT", "cell revision changed", map[string]any{"expected_revision": expected, "current_revision": currentRevision}) + } + if !exists && expected != 0 { + return newAppError(409, "REVISION_CONFLICT", "new cell requires If-Match: 0", nil) + } + now := utcString(time.Now()) + revision := int64(1) + if exists { + revision = currentRevision + 1 + _, err = tx.ExecContext(ctx, "UPDATE cells SET revision=?, config_json=?, cloud_instance_id=?, instance_name=?, region=?, updated_at=?, updated_by=? WHERE cell_id=?", revision, string(configJSON), cloudInstanceID, instanceName, region, now, actor, id) + } else { + _, err = tx.ExecContext(ctx, "INSERT INTO cells(cell_id,revision,config_json,cloud_instance_id,instance_name,region,boot_id,created_at,updated_at,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?)", id, revision, string(configJSON), cloudInstanceID, instanceName, region, "", now, now, actor) + } + if err != nil { + return err + } + details, _ := json.Marshal(map[string]any{"after": input, "cell_revision": revision}) + if err := insertAudit(ctx, tx, "cell", id, "upsert", revision, actor, requestID, string(details), now); err != nil { + return err + } + status = 200 + if !exists { + status = 201 + } + return nil + }) + if err != nil { + return Cell{}, 0, err + } + out, err := s.GetCell(ctx, id) + return out, status, err +} + +func validateCellConfig(input CellConfig) error { + if !validID(input.EgressPoolID) || input.EgressPoolID == "" { + return newAppError(422, "INVALID_EGRESS_POOL", "egress_pool_id is invalid", nil) + } + if len(input.CodecCapabilities) == 0 || input.MaxConcurrency < 1 || input.MaxConcurrency > 1000000 { + return newAppError(422, "INVALID_CELL_CAPACITY", "codec_capabilities and max_concurrency are invalid", nil) + } + for _, codec := range input.CodecCapabilities { + if codec != "PCMA" && codec != "PCMU" { + return newAppError(422, "UNSUPPORTED_CODEC", "codec_capabilities contains an unsupported codec", nil) + } + } + if input.Status != "healthy" && input.Status != "draining" && input.Status != "disabled" { + return newAppError(422, "INVALID_CELL_STATUS", "status must be healthy, draining, or disabled", nil) + } + if strings.ContainsAny(input.ManagementURL, "\r\n") { + return newAppError(422, "INVALID_MANAGEMENT_URL", "management_url is invalid", nil) + } + return nil +} + +func (s *Store) UpsertEgressPool(ctx context.Context, id, name string, ips []string, whitelistStatus, source string, checkedAt *time.Time) error { + if !validID(id) || name == "" { + return fmt.Errorf("invalid egress pool") + } + b, err := json.Marshal(ips) + if err != nil { + return err + } + var checked any + if checkedAt != nil { + checked = utcString(*checkedAt) + } + _, err = s.db.ExecContext(ctx, "INSERT INTO egress_pools(egress_pool_id,provider_id,display_name,fixed_ips_json,whitelist_status,whitelist_checked_at,source,updated_at) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(egress_pool_id) DO UPDATE SET display_name=excluded.display_name,fixed_ips_json=excluded.fixed_ips_json,whitelist_status=excluded.whitelist_status,whitelist_checked_at=excluded.whitelist_checked_at,source=excluded.source,updated_at=excluded.updated_at", id, "", name, string(b), whitelistStatus, checked, source, utcString(time.Now())) + return err +} + +func (s *Store) SetEgressPoolProvider(ctx context.Context, poolID, providerID string) error { + if !validID(poolID) || !validID(providerID) { + return fmt.Errorf("invalid egress/provider id") + } + if _, err := s.GetProvider(ctx, providerID); err != nil { + return err + } + _, err := s.db.ExecContext(ctx, "UPDATE egress_pools SET provider_id=?,updated_at=? WHERE egress_pool_id=?", providerID, utcString(time.Now()), poolID) + return err +} + +func (s *Store) ListEgressPools(ctx context.Context) ([]map[string]any, error) { + var out []map[string]any + err := s.withRead(ctx, func(db *sql.DB) error { + rows, err := db.QueryContext(ctx, "SELECT egress_pool_id,provider_id,display_name,fixed_ips_json,whitelist_status,whitelist_checked_at,source,updated_at FROM egress_pools ORDER BY egress_pool_id") + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var id, providerID, name, ipsJSON, status, source, updated string + var checked sql.NullString + if err := rows.Scan(&id, &providerID, &name, &ipsJSON, &status, &checked, &source, &updated); err != nil { + return err + } + var ips []string + if err := json.Unmarshal([]byte(ipsJSON), &ips); err != nil { + return err + } + item := map[string]any{"egress_pool_id": id, "provider_id": providerID, "display_name": name, "fixed_ips": ips, "whitelist_status": status, "source": source, "updated_at": updated} + if checked.Valid { + item["whitelist_checked_at"] = checked.String + } else { + item["whitelist_checked_at"] = nil + } + out = append(out, item) + } + return rows.Err() + }) + return out, err +} + +func (s *Store) compatibleCellIDs(ctx context.Context, cfg TrunkConfig) ([]string, error) { + cells, err := s.ListCells(ctx) + if err != nil { + return nil, err + } + var ids []string + for _, cell := range cells { + if cell.Config.Status == "disabled" || cell.Config.EgressPoolID != cfg.EgressPoolID || len(codecIntersection(cfg.CodecProfile.Allowed, cell.Config.CodecCapabilities)) == 0 { + continue + } + ids = append(ids, cell.CellID) + } + return sortedStrings(ids), nil +} + +func (s *Store) GetTrunk(ctx context.Context, id, mode string) (AdminTrunk, error) { + return s.buildAdminTrunk(ctx, id, mode) +} + +func (s *Store) ListTrunks(ctx context.Context, mode, providerID string) ([]AdminTrunk, error) { + var ids []string + err := s.withRead(ctx, func(db *sql.DB) error { + query := "SELECT trunk_id FROM trunks" + args := []any{} + if providerID != "" { + query += " WHERE provider_id=?" + args = append(args, providerID) + } + query += " ORDER BY updated_at DESC, trunk_id ASC" + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return err + } + ids = append(ids, id) + } + return rows.Err() + }) + if err != nil { + return nil, err + } + out := make([]AdminTrunk, 0, len(ids)) + for _, id := range ids { + trunk, err := s.GetTrunk(ctx, id, mode) + if err != nil { + return nil, err + } + out = append(out, trunk) + } + return out, nil +} + +func (s *Store) PutTrunk(ctx context.Context, id string, cfg TrunkConfig, expected int64, actor, requestID string) (AdminTrunk, int, error) { + if !validID(id) { + return AdminTrunk{}, 0, newAppError(422, "INVALID_ID", "trunk_id is invalid", nil) + } + if err := s.validateTrunkConfig(ctx, cfg, false); err != nil { + return AdminTrunk{}, 0, err + } + configJSON, err := json.Marshal(cfg) + if err != nil { + return AdminTrunk{}, 0, err + } + digest := hashBytes(configJSON) + var status int + err = s.withTx(ctx, func(tx *sql.Tx) error { + var currentRevision int64 + var currentProvider string + err := tx.QueryRowContext(ctx, "SELECT latest_revision,provider_id FROM trunks WHERE trunk_id=?", id).Scan(¤tRevision, ¤tProvider) + exists := err == nil + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if exists && currentRevision != expected { + return newAppError(409, "REVISION_CONFLICT", "trunk revision changed", map[string]any{"expected_revision": expected, "current_revision": currentRevision}) + } + if !exists && expected != 0 { + return newAppError(409, "REVISION_CONFLICT", "new trunk requires If-Match: 0", nil) + } + now := utcString(time.Now()) + revision := int64(1) + if exists { + revision = currentRevision + 1 + _, err = tx.ExecContext(ctx, "UPDATE trunks SET provider_id=?,latest_revision=?,updated_at=?,updated_by=? WHERE trunk_id=?", cfg.ProviderID, revision, now, actor, id) + } else { + _, err = tx.ExecContext(ctx, "INSERT INTO trunks(trunk_id,provider_id,latest_revision,active_revision,active_status,updated_at,updated_by) VALUES(?,?,?,?,?,?,?)", id, cfg.ProviderID, revision, 0, "draft", now, actor) + } + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "INSERT INTO trunk_versions(trunk_id,revision,state,config_json,config_sha256,created_at,created_by) VALUES(?,?,?,?,?,?,?)", id, revision, "draft", string(configJSON), digest, now, actor) + if err != nil { + return err + } + details, _ := json.Marshal(map[string]any{"before_provider_id": currentProvider, "after": redactedConfig(cfg), "config_sha256": digest}) + if err := insertAudit(ctx, tx, "trunk", id, "upsert", revision, actor, requestID, string(details), now); err != nil { + return err + } + status = 200 + if !exists { + status = 201 + } + return nil + }) + if err != nil { + return AdminTrunk{}, 0, err + } + trunk, err := s.GetTrunk(ctx, id, ModeMock) + return trunk, status, err +} + +func (s *Store) validateTrunkConfig(ctx context.Context, cfg TrunkConfig, forReal bool) error { + if !validID(cfg.ProviderID) || cfg.ProviderID == "" { + return newAppError(422, "PROVIDER_REQUIRED", "provider_id is required", nil) + } + provider, err := s.GetProvider(ctx, cfg.ProviderID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return newAppError(422, "PROVIDER_NOT_FOUND", "provider does not exist", nil) + } + return err + } + if provider.Lifecycle != "active" { + return newAppError(422, "PROVIDER_ARCHIVED", "trunk must reference an active provider", nil) + } + if strings.TrimSpace(cfg.DisplayName) == "" || len(cfg.DisplayName) > 256 || strings.ContainsAny(cfg.DisplayName, "\r\n") { + return newAppError(422, "INVALID_DISPLAY_NAME", "display_name is invalid", nil) + } + if err := validateHost(cfg.Sip.Host); err != nil { + return newAppError(422, "INVALID_SIP_HOST", err.Error(), nil) + } + if cfg.Sip.Port < 1 || cfg.Sip.Port > 65535 || (cfg.Sip.Transport != "udp" && cfg.Sip.Transport != "tcp" && cfg.Sip.Transport != "tls") || (cfg.Sip.AuthMode != "ip" && cfg.Sip.AuthMode != "digest") { + return newAppError(422, "INVALID_SIP_CONFIG", "sip transport, port, or auth_mode is invalid", nil) + } + if cfg.Sip.CredentialRef != "" && (len(cfg.Sip.CredentialRef) > 256 || strings.ContainsAny(cfg.Sip.CredentialRef, "\r\n=\\\"'{}[]")) { + return newAppError(422, "INVALID_CREDENTIAL_REF", "credential_ref must be a secret-store reference, not a secret or config fragment", nil) + } + if cfg.Sip.AuthMode == "digest" && cfg.Sip.CredentialRef == "" { + return newAppError(422, "CREDENTIAL_REQUIRED", "digest authentication requires a credential reference", nil) + } + if forReal && strings.HasPrefix(cfg.Sip.CredentialRef, "missing:") { + return newAppError(409, "CREDENTIAL_UNAVAILABLE", "the referenced SIP credential is not available", nil) + } + if len(cfg.CodecProfile.Allowed) == 0 || !containsString(cfg.CodecProfile.Allowed, cfg.CodecProfile.Preferred) { + return newAppError(422, "INVALID_CODEC_PROFILE", "preferred codec must be in allowed", nil) + } + for _, codec := range cfg.CodecProfile.Allowed { + if codec != "PCMA" && codec != "PCMU" { + return newAppError(422, "UNSUPPORTED_CODEC", "only PCMA and PCMU are supported", nil) + } + } + if len(cfg.CallerIDs) == 0 || len(cfg.CallerIDs) > 100 { + return newAppError(422, "INVALID_CALLER_IDS", "at least one caller ID is required", nil) + } + for _, callerID := range cfg.CallerIDs { + if callerID == "" || len(callerID) > 128 || strings.ContainsAny(callerID, "\r\n,;{}[]") { + return newAppError(422, "INVALID_CALLER_ID", "caller_ids contains an invalid value", nil) + } + } + if err := safePrefix(cfg.DialPrefix); err != nil { + return newAppError(422, "INVALID_DIAL_PREFIX", err.Error(), nil) + } + if !validID(cfg.EgressPoolID) || cfg.EgressPoolID == "" || cfg.MaxConcurrency < 1 || cfg.MaxCPS < 1 || cfg.MaxConcurrency > 1000000 || cfg.MaxCPS > 1000000 { + return newAppError(422, "INVALID_CAPACITY", "egress_pool_id, max_concurrency, or max_cps is invalid", nil) + } + var fixedIPsJSON, poolProviderID string + err = s.withRead(ctx, func(db *sql.DB) error { + return db.QueryRowContext(ctx, "SELECT fixed_ips_json,provider_id FROM egress_pools WHERE egress_pool_id=?", cfg.EgressPoolID).Scan(&fixedIPsJSON, &poolProviderID) + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return newAppError(422, "EGRESS_POOL_NOT_FOUND", "egress pool does not exist", nil) + } + return err + } + if poolProviderID != "" && poolProviderID != cfg.ProviderID { + return newAppError(403, "EGRESS_PROVIDER_MISMATCH", "egress pool is owned by another provider", map[string]any{"egress_pool_id": cfg.EgressPoolID}) + } + var ips []string + if err := json.Unmarshal([]byte(fixedIPsJSON), &ips); err != nil { + return err + } + for _, ip := range ips { + if ip == "123.56.71.98" && cfg.Sip.Host == ip { + return newAppError(422, "EGRESS_IP_USED_AS_SIP_HOST", "the fixed egress allowlist IP cannot be used as the SIP host", nil) + } + } + if forReal && cfg.Sip.AuthMode == "digest" { + return newAppError(409, "REAL_CAPABILITY_UNSUPPORTED", "the real Cell adapter is not approved for Digest authentication", nil) + } + return nil +} + +func (s *Store) BuildValidation(ctx context.Context, id string, revision int64, mode string) (map[string]any, error) { + cfg, err := s.getTrunkConfig(ctx, id, revision) + if err != nil { + return nil, err + } + issues := []map[string]any{} + if validationErr := s.validateTrunkConfig(ctx, cfg, mode == ModeReal); validationErr != nil { + appErr := asAppError(validationErr) + issues = append(issues, map[string]any{"code": appErr.Code, "field": validationField(appErr.Code), "message": appErr.Message}) + } + cells, err := s.compatibleCellIDs(ctx, cfg) + if err != nil { + return nil, err + } + if len(cells) == 0 { + issues = append(issues, map[string]any{"code": "NO_COMPATIBLE_CELL", "field": "egress_pool_id", "message": "no registered Cell matches the egress pool and codec"}) + } + return map[string]any{"mode": mode, "trunk_id": id, "revision": revision, "valid": len(issues) == 0, "issues": issues, "target_cell_ids": cells, "impact": map[string]any{"reload_required": true, "active_calls": s.activeAttemptCount(ctx, id)}}, nil +} + +func validationField(code string) string { + return map[string]string{ + "PROVIDER_REQUIRED": "provider_id", "PROVIDER_NOT_FOUND": "provider_id", "PROVIDER_ARCHIVED": "provider_id", + "INVALID_DISPLAY_NAME": "display_name", "INVALID_SIP_HOST": "sip.host", "INVALID_SIP_CONFIG": "sip", + "INVALID_CREDENTIAL_REF": "sip.credential_ref", "CREDENTIAL_REQUIRED": "sip.credential_ref", "CREDENTIAL_UNAVAILABLE": "sip.credential_ref", + "INVALID_CODEC_PROFILE": "codec_profile", "UNSUPPORTED_CODEC": "codec_profile", "INVALID_CALLER_IDS": "caller_ids", "INVALID_CALLER_ID": "caller_ids", + "INVALID_DIAL_PREFIX": "dial_prefix", "INVALID_CAPACITY": "egress_pool_id", "EGRESS_POOL_NOT_FOUND": "egress_pool_id", + "EGRESS_PROVIDER_MISMATCH": "egress_pool_id", "EGRESS_IP_USED_AS_SIP_HOST": "sip.host", "REAL_CAPABILITY_UNSUPPORTED": "sip.auth_mode", + }[code] +} + +func (s *Store) getTrunkConfig(ctx context.Context, id string, revision int64) (TrunkConfig, error) { + var raw string + err := s.withRead(ctx, func(db *sql.DB) error { + return db.QueryRowContext(ctx, "SELECT config_json FROM trunk_versions WHERE trunk_id=? AND revision=?", id, revision).Scan(&raw) + }) + if err != nil { + return TrunkConfig{}, err + } + var cfg TrunkConfig + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + return TrunkConfig{}, err + } + return cfg, nil +} + +func (s *Store) activeAttemptCount(ctx context.Context, trunkID string) int { + var count int + _ = s.withRead(ctx, func(db *sql.DB) error { + return db.QueryRowContext(ctx, "SELECT COUNT(*) FROM attempts WHERE trunk_id=? AND origin_status='confirmed' AND answered_at IS NOT NULL AND ended_at IS NULL", trunkID).Scan(&count) + }) + return count +} + +func (s *Store) buildAdminTrunk(ctx context.Context, id, mode string) (AdminTrunk, error) { + var meta dbgen.Trunk + err := s.withRead(ctx, func(db *sql.DB) error { + return db.QueryRowContext(ctx, "SELECT trunk_id,provider_id,latest_revision,active_revision,active_status,updated_at,updated_by FROM trunks WHERE trunk_id=?", id).Scan(&meta.TrunkID, &meta.ProviderID, &meta.LatestRevision, &meta.ActiveRevision, &meta.ActiveStatus, &meta.UpdatedAt, &meta.UpdatedBy) + }) + if err != nil { + return AdminTrunk{}, err + } + latestCfg, err := s.getTrunkConfig(ctx, id, meta.LatestRevision) + if err != nil { + return AdminTrunk{}, err + } + activeCfg := TrunkConfig{} + if meta.ActiveRevision > 0 { + activeCfg, err = s.getTrunkConfig(ctx, id, meta.ActiveRevision) + if err != nil { + return AdminTrunk{}, err + } + } + compatible, err := s.compatibleCellIDs(ctx, latestCfg) + if err != nil { + return AdminTrunk{}, err + } + versions, err := s.listVersions(ctx, id) + if err != nil { + return AdminTrunk{}, err + } + status := "draft" + if meta.ActiveStatus == "disabled" { + status = "disabled" + } else if meta.ActiveRevision > 0 && meta.ActiveRevision == meta.LatestRevision && meta.ActiveStatus == "published" { + status = "published" + } + return AdminTrunk{Mode: mode, TrunkID: id, ProviderID: meta.ProviderID, LatestRevision: meta.LatestRevision, ActiveRevision: meta.ActiveRevision, ActiveStatus: meta.ActiveStatus, Status: status, UpdatedAt: meta.UpdatedAt, CompatibleCellIDs: compatible, Latest: ptrTrunkView(id, latestCfg, versionDigest(versions, meta.LatestRevision)), Active: nilIfRevision(meta.ActiveRevision, id, activeCfg, versionDigest(versions, meta.ActiveRevision)), Versions: versions}, nil +} + +func ptrTrunkView(id string, cfg TrunkConfig, digest string) *TrunkView { + configured := cfg.Sip.CredentialRef != "" + cfg.Sip.CredentialRef = "" + return &TrunkView{TrunkConfig: cfg, TrunkID: id, CredentialConfigured: configured, AsteriskAllow: asteriskCodecs(cfg.CodecProfile.Allowed), ConfigSHA256: digest} +} +func nilIfRevision(revision int64, id string, cfg TrunkConfig, digest string) *TrunkView { + if revision == 0 { + return nil + } + return ptrTrunkView(id, cfg, digest) +} +func asteriskCodecs(codecs []string) []string { + var out []string + for _, codec := range codecs { + if codec == "PCMA" { + out = append(out, "alaw") + } + if codec == "PCMU" { + out = append(out, "ulaw") + } + } + return out +} +func versionDigest(versions []RevisionInfo, revision int64) string { + for _, version := range versions { + if version.Revision == revision { + return version.ConfigSHA256 + } + } + return "" +} + +func (s *Store) listVersions(ctx context.Context, id string) ([]RevisionInfo, error) { + var out []RevisionInfo + err := s.withRead(ctx, func(db *sql.DB) error { + rows, err := db.QueryContext(ctx, "SELECT revision,state,config_sha256,created_at,created_by FROM trunk_versions WHERE trunk_id=? ORDER BY revision DESC", id) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var v RevisionInfo + if err := rows.Scan(&v.Revision, &v.State, &v.ConfigSHA256, &v.CreatedAt, &v.CreatedBy); err != nil { + return err + } + out = append(out, v) + } + return rows.Err() + }) + return out, err +} + +func (s *Store) GetPublicationRows(ctx context.Context, trunkID string, revision *int64) ([]Publication, error) { + var out []Publication + err := s.withRead(ctx, func(db *sql.DB) error { + query := "SELECT trunk_id,revision,cell_id,status,error_code,target_digest,local_revision,local_digest,applied_at,operation_id,updated_at FROM publications WHERE trunk_id=?" + args := []any{trunkID} + if revision != nil { + query += " AND revision=?" + args = append(args, *revision) + } + query += " ORDER BY revision DESC, cell_id ASC" + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var p Publication + var ec, applied sql.NullString + if err := rows.Scan(&p.TrunkID, &p.Revision, &p.CellID, &p.Status, &ec, &p.TargetDigest, &p.LocalRevision, &p.LocalDigest, &applied, &p.OperationID, &p.UpdatedAt); err != nil { + return err + } + if ec.Valid { + p.ErrorCode = &ec.String + } + if applied.Valid { + p.AppliedAt = &applied.String + } + out = append(out, p) + } + return rows.Err() + }) + return out, err +} + +func (s *Store) StagePublication(ctx context.Context, trunkID string, revision int64, cfg TrunkConfig, cellIDs []string, operationID, reason string) error { + digest, raw, err := hashJSON(cfg) + if err != nil { + return err + } + _ = raw + now := utcString(time.Now()) + return s.withTx(ctx, func(tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, "INSERT INTO admission_barriers(trunk_id,operation_id,target_revision,reason,state,created_at,updated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(trunk_id) DO UPDATE SET operation_id=excluded.operation_id,target_revision=excluded.target_revision,reason=excluded.reason,state='active',updated_at=excluded.updated_at", trunkID, operationID, revision, reason, "active", now, now); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, "UPDATE trunk_versions SET state='publishing' WHERE trunk_id=? AND revision=?", trunkID, revision); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, "DELETE FROM publications WHERE trunk_id=? AND revision=?", trunkID, revision); err != nil { + return err + } + for _, cellID := range cellIDs { + if _, err := tx.ExecContext(ctx, "INSERT INTO publications(trunk_id,revision,cell_id,status,error_code,target_digest,local_revision,local_digest,operation_id,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)", trunkID, revision, cellID, "pending", nil, digest, 0, "", operationID, now); err != nil { + return err + } + } + return nil + }) +} + +func (s *Store) UpdatePublication(ctx context.Context, trunkID string, revision int64, cellID, status, errorCode string, localRevision int64, localDigest, operationID string) error { + if status != "pending" && status != "applied" && status != "failed" { + return fmt.Errorf("invalid publication status") + } + now := utcString(time.Now()) + var applied any + if status == "applied" { + applied = now + } + return s.withTx(ctx, func(tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, "UPDATE publications SET status=?,error_code=?,local_revision=?,local_digest=?,applied_at=?,operation_id=?,updated_at=? WHERE trunk_id=? AND revision=? AND cell_id=?", status, nullableString(errorCode), localRevision, localDigest, applied, operationID, now, trunkID, revision, cellID) + return err + }) +} + +func nullableString(v string) any { + if v == "" { + return nil + } + return v +} + +func (s *Store) FinalizePublication(ctx context.Context, trunkID string, revision int64, cfg TrunkConfig, operationID, actor, requestID, action string) (bool, error) { + var pubs []Publication + var allApplied bool + err := s.withRead(ctx, func(db *sql.DB) error { + rows, err := db.QueryContext(ctx, "SELECT trunk_id,revision,cell_id,status,error_code,target_digest,local_revision,local_digest,applied_at,operation_id,updated_at FROM publications WHERE trunk_id=? AND revision=?", trunkID, revision) + if err != nil { + return err + } + defer rows.Close() + allApplied = true + for rows.Next() { + var p Publication + var ec, applied sql.NullString + if err := rows.Scan(&p.TrunkID, &p.Revision, &p.CellID, &p.Status, &ec, &p.TargetDigest, &p.LocalRevision, &p.LocalDigest, &applied, &p.OperationID, &p.UpdatedAt); err != nil { + return err + } + if ec.Valid { + p.ErrorCode = &ec.String + } + pubs = append(pubs, p) + if p.Status != "applied" { + allApplied = false + } + } + if len(pubs) == 0 { + allApplied = false + } + return rows.Err() + }) + if err != nil || !allApplied { + return false, err + } + now := utcString(time.Now()) + return true, s.withTx(ctx, func(tx *sql.Tx) error { + activeStatus := "published" + if !cfg.Enabled { + activeStatus = "disabled" + } + if _, err := tx.ExecContext(ctx, "UPDATE trunks SET active_revision=?,active_status=?,updated_at=?,updated_by=? WHERE trunk_id=?", revision, activeStatus, now, actor, trunkID); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, "UPDATE trunk_versions SET state=CASE WHEN revision=? THEN 'published' ELSE state END WHERE trunk_id=? AND revision=?", revision, trunkID, revision); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, "UPDATE admission_barriers SET state='released',updated_at=? WHERE trunk_id=? AND operation_id=?", now, trunkID, operationID); err != nil { + return err + } + cellResults := make([]map[string]any, 0, len(pubs)) + for _, pub := range pubs { + item := map[string]any{"cell_id": pub.CellID, "status": pub.Status, "local_revision": pub.LocalRevision} + if pub.ErrorCode != nil { + item["error_code"] = *pub.ErrorCode + } + cellResults = append(cellResults, item) + } + details, _ := json.Marshal(map[string]any{"action": action, "revision": revision, "cells": cellResults}) + return insertAudit(ctx, tx, "trunk", trunkID, action, revision, actor, requestID, string(details), now) + }) +} + +func (s *Store) ActiveConfig(ctx context.Context, trunkID string) (TrunkConfig, int64, error) { + var rev int64 + err := s.withRead(ctx, func(db *sql.DB) error { + return db.QueryRowContext(ctx, "SELECT active_revision FROM trunks WHERE trunk_id=?", trunkID).Scan(&rev) + }) + if err != nil { + return TrunkConfig{}, 0, err + } + if rev == 0 { + return TrunkConfig{}, 0, sql.ErrNoRows + } + cfg, err := s.getTrunkConfig(ctx, trunkID, rev) + return cfg, rev, err +} + +func (s *Store) MarkPublicationFailure(ctx context.Context, trunkID string, revision int64, operationID string, actor, requestID, action string, code string) error { + now := utcString(time.Now()) + return s.withTx(ctx, func(tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, "UPDATE admission_barriers SET state='pending',updated_at=? WHERE trunk_id=? AND operation_id=?", now, trunkID, operationID); err != nil { + return err + } + details, _ := json.Marshal(map[string]any{"action": action, "revision": revision, "error_code": code, "safety": "admission remains blocked until reconciliation"}) + return insertAudit(ctx, tx, "trunk", trunkID, action+"_failed", revision, actor, requestID, string(details), now) + }) +} + +func insertAudit(ctx context.Context, tx *sql.Tx, resourceType, resourceID, action string, revision int64, actor, requestID, details, now string) error { + _, err := tx.ExecContext(ctx, "INSERT INTO audit_entries(audit_id,resource_type,resource_id,action,revision,actor,request_id,details_json,created_at) VALUES(?,?,?,?,?,?,?,?,?)", newID("audit"), resourceType, resourceID, action, revision, actor, nullableString(requestID), details, now) + return err +} + +func (s *Store) ListAudit(ctx context.Context, resourceID, requestID, actor string, limit int) ([]AuditEntry, error) { + if limit < 1 || limit > 200 { + limit = 50 + } + var out []AuditEntry + err := s.withRead(ctx, func(db *sql.DB) error { + query := "SELECT audit_id,resource_type,resource_id,action,revision,actor,request_id,details_json,created_at FROM audit_entries WHERE 1=1" + args := []any{} + if resourceID != "" { + query += " AND resource_id=?" + args = append(args, resourceID) + } + if requestID != "" { + query += " AND request_id=?" + args = append(args, requestID) + } + if actor != "" { + query += " AND actor=?" + args = append(args, actor) + } + query += " ORDER BY created_at DESC,audit_id DESC LIMIT ?" + args = append(args, limit) + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var a AuditEntry + var rid sql.NullString + if err := rows.Scan(&a.AuditID, &a.ResourceType, &a.ResourceID, &a.Action, &a.Revision, &a.Actor, &rid, &a.DetailsJSON, &a.CreatedAt); err != nil { + return err + } + if rid.Valid { + a.RequestID = &rid.String + } + out = append(out, a) + } + return rows.Err() + }) + return out, err +} + +func (s *Store) FindOperation(ctx context.Context, actor, action, resourceType, resourceID, requestID string) (Operation, error) { + var op Operation + err := s.withRead(ctx, func(db *sql.DB) error { + var response, failure sql.NullString + err := db.QueryRowContext(ctx, "SELECT operation_id,actor,action,resource_type,resource_id,request_id,request_digest,expected_revision,state,http_status,response_json,error_json,created_at,updated_at,expires_at FROM operations WHERE actor=? AND action=? AND resource_type=? AND resource_id=? AND request_id=?", actor, action, resourceType, resourceID, requestID).Scan(&op.OperationID, &op.Actor, &op.Action, &op.ResourceType, &op.ResourceID, &op.RequestID, &op.RequestDigest, &op.ExpectedRevision, &op.State, &op.HTTPStatus, &response, &failure, &op.CreatedAt, &op.UpdatedAt, &op.ExpiresAt) + if response.Valid { + op.ResponseJSON = response.String + } + if failure.Valid { + op.ErrorJSON = failure.String + } + return err + }) + if err == nil { + err = s.withRead(ctx, func(db *sql.DB) error { + var response, failure sql.NullString + if err := db.QueryRowContext(ctx, "SELECT response_json,error_json FROM operations WHERE operation_id=?", op.OperationID).Scan(&response, &failure); err != nil { + return err + } + if response.Valid { + op.ResponseJSON = response.String + } + if failure.Valid { + op.ErrorJSON = failure.String + } + return nil + }) + } + return op, err +} + +func (s *Store) CreateOperation(ctx context.Context, actor, action, resourceType, resourceID, requestID, digest string, expected int64) (Operation, bool, error) { + op := Operation{OperationID: newID("op"), Actor: actor, Action: action, ResourceType: resourceType, ResourceID: resourceID, RequestID: requestID, RequestDigest: digest, ExpectedRevision: expected, State: "in_progress", HTTPStatus: 200, CreatedAt: utcString(time.Now()), UpdatedAt: utcString(time.Now()), ExpiresAt: utcString(time.Now().Add(30 * 24 * time.Hour))} + var existing Operation + err := s.withTx(ctx, func(tx *sql.Tx) error { + var response, failure sql.NullString + err := tx.QueryRowContext(ctx, "SELECT operation_id,actor,action,resource_type,resource_id,request_id,request_digest,expected_revision,state,http_status,response_json,error_json,created_at,updated_at,expires_at FROM operations WHERE actor=? AND action=? AND resource_type=? AND resource_id=? AND request_id=?", actor, action, resourceType, resourceID, requestID).Scan(&existing.OperationID, &existing.Actor, &existing.Action, &existing.ResourceType, &existing.ResourceID, &existing.RequestID, &existing.RequestDigest, &existing.ExpectedRevision, &existing.State, &existing.HTTPStatus, &response, &failure, &existing.CreatedAt, &existing.UpdatedAt, &existing.ExpiresAt) + if err == nil { + if expiry, parseErr := parseStoredTime(existing.ExpiresAt); parseErr == nil && time.Now().After(expiry) { + return newAppError(409, "IDEMPOTENCY_EXPIRED", "the durable idempotency record has expired", map[string]any{"operation_id": existing.OperationID}) + } + if response.Valid { + existing.ResponseJSON = response.String + } + if failure.Valid { + existing.ErrorJSON = failure.String + } + return nil + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + _, err = tx.ExecContext(ctx, "INSERT INTO operations(operation_id,actor,action,resource_type,resource_id,request_id,request_digest,expected_revision,state,http_status,created_at,updated_at,expires_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)", op.OperationID, op.Actor, op.Action, op.ResourceType, op.ResourceID, op.RequestID, op.RequestDigest, op.ExpectedRevision, op.State, op.HTTPStatus, op.CreatedAt, op.UpdatedAt, op.ExpiresAt) + return err + }) + if err != nil { + return Operation{}, false, err + } + if existing.OperationID != "" { + return existing, true, nil + } + return op, false, nil +} + +func (s *Store) FinishOperation(ctx context.Context, id, state string, status int, responseJSON, errorJSON string) error { + return s.withTx(ctx, func(tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, "UPDATE operations SET state=?,http_status=?,response_json=?,error_json=?,updated_at=? WHERE operation_id=?", state, status, nullableString(responseJSON), nullableString(errorJSON), utcString(time.Now()), id) + return err + }) +} + +func (s *Store) GetOperation(ctx context.Context, id string) (Operation, error) { + var op Operation + err := s.withRead(ctx, func(db *sql.DB) error { + var r, e sql.NullString + err := db.QueryRowContext(ctx, "SELECT operation_id,actor,action,resource_type,resource_id,request_id,request_digest,expected_revision,state,http_status,response_json,error_json,created_at,updated_at,expires_at FROM operations WHERE operation_id=?", id).Scan(&op.OperationID, &op.Actor, &op.Action, &op.ResourceType, &op.ResourceID, &op.RequestID, &op.RequestDigest, &op.ExpectedRevision, &op.State, &op.HTTPStatus, &r, &e, &op.CreatedAt, &op.UpdatedAt, &op.ExpiresAt) + if r.Valid { + op.ResponseJSON = r.String + } + if e.Valid { + op.ErrorJSON = e.String + } + return err + }) + return op, err +} + +func (s *Store) AdvanceCellBoot(ctx context.Context, cellID, bootID string) error { + if !validID(cellID) || bootID == "" || len(bootID) > 128 || strings.ContainsAny(bootID, "\r\n") { + return newAppError(422, "INVALID_CELL_BOOT", "cell_id or boot_id is invalid", nil) + } + return s.withTx(ctx, func(tx *sql.Tx) error { + result, err := tx.ExecContext(ctx, "UPDATE cells SET boot_id=?,updated_at=? WHERE cell_id=?", bootID, utcString(time.Now()), cellID) + if err != nil { + return err + } + if affected, _ := result.RowsAffected(); affected == 0 { + return newAppError(404, "CELL_NOT_FOUND", "cell does not exist", nil) + } + return nil + }) +} + +func (s *Store) InsertObservation(ctx context.Context, input ObservationInput) error { + if !validID(input.ObservationID) { + input.ObservationID = newID("obs") + } + if !validID(input.CellID) || input.BootID == "" || len(input.BootID) > 128 || strings.ContainsAny(input.BootID, "\r\n") || input.Sequence < 1 { + return newAppError(422, "INVALID_OBSERVATION", "cell_id, boot_id, or sequence is invalid", nil) + } + if input.ObservedAt.IsZero() { + input.ObservedAt = time.Now() + } + if input.ReceivedAt.IsZero() { + input.ReceivedAt = time.Now() + } + states, _ := json.Marshal(input.States) + occupancy, _ := json.Marshal(input.Occupancy) + if string(states) == "null" { + states = []byte("{}") + } + if string(occupancy) == "null" { + occupancy = []byte("{}") + } + return s.withTx(ctx, func(tx *sql.Tx) error { + var currentBoot string + if err := tx.QueryRowContext(ctx, "SELECT boot_id FROM cells WHERE cell_id=?", input.CellID).Scan(¤tBoot); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return newAppError(404, "CELL_NOT_FOUND", "cell does not exist", nil) + } + return err + } + if currentBoot != "" && currentBoot != input.BootID { + return newAppError(409, "STALE_BOOT_ID", "observation belongs to a retired Cell boot", map[string]any{"current_boot_id": currentBoot}) + } + if currentBoot == "" { + if _, err := tx.ExecContext(ctx, "UPDATE cells SET boot_id=? WHERE cell_id=?", input.BootID, input.CellID); err != nil { + return err + } + } + var last int64 + err := tx.QueryRowContext(ctx, "SELECT COALESCE(MAX(sequence),-1) FROM observations WHERE cell_id=? AND boot_id=?", input.CellID, input.BootID).Scan(&last) + if err != nil { + return err + } + if input.Sequence <= last { + return newAppError(409, "STALE_OBSERVATION", "observation sequence is not newer", map[string]any{"last_sequence": last}) + } + _, err = tx.ExecContext(ctx, "INSERT INTO observations(observation_id,cell_id,trunk_id,boot_id,sequence,observed_at,received_at,source,config_revision,states_json,occupancy_json,sample_id) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", input.ObservationID, input.CellID, nullableString(input.TrunkID), input.BootID, input.Sequence, utcString(input.ObservedAt), utcString(input.ReceivedAt), input.Source, input.ConfigRevision, string(states), string(occupancy), input.SampleID) + return err + }) +} + +func (s *Store) LatestObservations(ctx context.Context, cellID, trunkID string) ([]ObservationInput, error) { + var out []ObservationInput + err := s.withRead(ctx, func(db *sql.DB) error { + query := "SELECT o.observation_id,o.cell_id,COALESCE(o.trunk_id,''),o.boot_id,o.sequence,o.observed_at,o.received_at,o.source,o.config_revision,o.states_json,o.occupancy_json,o.sample_id FROM observations o JOIN cells c ON c.cell_id=o.cell_id WHERE c.boot_id=o.boot_id" + args := []any{} + if cellID != "" { + query += " AND o.cell_id=?" + args = append(args, cellID) + } + if trunkID != "" { + query += " AND o.trunk_id=?" + args = append(args, trunkID) + } + query += " AND o.sequence=(SELECT MAX(o2.sequence) FROM observations o2 WHERE o2.cell_id=o.cell_id AND o2.boot_id=o.boot_id AND COALESCE(o2.trunk_id,'')=COALESCE(o.trunk_id,'') ) ORDER BY o.cell_id,o.trunk_id" + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var o ObservationInput + var obs, rec string + var states, occ string + if err := rows.Scan(&o.ObservationID, &o.CellID, &o.TrunkID, &o.BootID, &o.Sequence, &obs, &rec, &o.Source, &o.ConfigRevision, &states, &occ, &o.SampleID); err != nil { + return err + } + o.ObservedAt, _ = parseStoredTime(obs) + o.ReceivedAt, _ = parseStoredTime(rec) + _ = json.Unmarshal([]byte(states), &o.States) + _ = json.Unmarshal([]byte(occ), &o.Occupancy) + out = append(out, o) + } + return rows.Err() + }) + return out, err +} + +func (s *Store) UpsertAttempt(ctx context.Context, a Attempt) error { + if !validID(a.AttemptID) || (a.Mode != ModeMock && a.Mode != ModeReal) { + return newAppError(422, "INVALID_ATTEMPT", "attempt_id or mode is invalid", nil) + } + if a.OriginStatus != "confirmed" && a.OriginStatus != "uncertain" && a.OriginStatus != "not_started" && a.OriginStatus != "rejected" { + return newAppError(422, "INVALID_ORIGIN_STATUS", "origin_status is invalid", nil) + } + if a.AttemptStartedAt != nil && a.AnsweredAt != nil && a.AnsweredAt.Before(*a.AttemptStartedAt) { + return newAppError(422, "INVALID_ATTEMPT_FACT", "answered_at precedes attempt_started_at", nil) + } + if a.AttemptStartedAt != nil && a.EndedAt != nil && a.EndedAt.Before(*a.AttemptStartedAt) { + return newAppError(422, "INVALID_ATTEMPT_FACT", "ended_at precedes attempt_started_at", nil) + } + if a.AnsweredAt != nil && a.EndedAt != nil && a.EndedAt.Before(*a.AnsweredAt) { + return newAppError(422, "INVALID_ATTEMPT_FACT", "ended_at precedes answered_at", nil) + } + if a.UpdatedAt.IsZero() { + a.UpdatedAt = time.Now() + } + return s.withTx(ctx, func(tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, "INSERT INTO attempts(attempt_id,execution_id,call_id,tenant_key,provider_id,trunk_id,cell_id,egress_pool_id,config_revision,mode,dialed_number,attempt_started_at,origin_status,answered_at,ended_at,termination_reason,sip_code,q850,ai_status,source,fact_version,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(attempt_id) DO UPDATE SET execution_id=excluded.execution_id,call_id=excluded.call_id,tenant_key=excluded.tenant_key,provider_id=excluded.provider_id,trunk_id=excluded.trunk_id,cell_id=excluded.cell_id,egress_pool_id=excluded.egress_pool_id,config_revision=excluded.config_revision,mode=excluded.mode,dialed_number=excluded.dialed_number,attempt_started_at=excluded.attempt_started_at,origin_status=excluded.origin_status,answered_at=excluded.answered_at,ended_at=excluded.ended_at,termination_reason=excluded.termination_reason,sip_code=excluded.sip_code,q850=excluded.q850,ai_status=excluded.ai_status,source=excluded.source,fact_version=MAX(attempts.fact_version,excluded.fact_version),updated_at=excluded.updated_at WHERE attempts.fact_version<=excluded.fact_version", a.AttemptID, a.ExecutionID, a.CallID, a.TenantKey, a.ProviderID, a.TrunkID, a.CellID, a.EgressPoolID, a.ConfigRevision, a.Mode, a.DialedNumber, nullableTime(a.AttemptStartedAt), a.OriginStatus, nullableTime(a.AnsweredAt), nullableTime(a.EndedAt), a.TerminationReason, a.SIPCode, a.Q850, a.AIStatus, a.Source, a.FactVersion, utcString(a.UpdatedAt)) + return err + }) +} +func nullableTime(v *time.Time) any { + if v == nil { + return nil + } + return utcString(*v) +} + +func (s *Store) RecordAttemptEvent(ctx context.Context, eventID, attemptID, eventType string, eventTime, receivedAt time.Time, source string, version int64, payload map[string]any) error { + if eventID == "" { + eventID = newID("event") + } + if !validID(eventID) || !validID(attemptID) || version < 1 || source == "" { + return newAppError(422, "INVALID_ATTEMPT_EVENT", "event identity, source, or fact_version is invalid", nil) + } + if eventType != "originated" && eventType != "answered" && eventType != "ended" && eventType != "ai_failed" { + return newAppError(422, "INVALID_ATTEMPT_EVENT", "event_type is invalid", nil) + } + if receivedAt.IsZero() { + receivedAt = time.Now() + } + if eventTime.IsZero() { + eventTime = receivedAt + } + b, err := json.Marshal(payload) + if err != nil { + return newAppError(422, "INVALID_ATTEMPT_EVENT", "event payload is not JSON serializable", nil) + } + return s.withTx(ctx, func(tx *sql.Tx) error { + res, err := tx.ExecContext(ctx, "INSERT OR IGNORE INTO attempt_events(event_id,attempt_id,event_type,event_time,received_at,source,fact_version,payload_json) VALUES(?,?,?,?,?,?,?,?)", eventID, attemptID, eventType, utcString(eventTime), utcString(receivedAt), source, version, string(b)) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return nil + } + where := "WHERE attempt_id=? AND fact_version<=?" + switch eventType { + case "originated": + _, err = tx.ExecContext(ctx, "UPDATE attempts SET attempt_started_at=?,origin_status='confirmed',fact_version=MAX(fact_version,?),updated_at=? "+where+" AND origin_status<>'rejected'", utcString(eventTime), version, utcString(receivedAt), attemptID, version) + case "answered": + _, err = tx.ExecContext(ctx, "UPDATE attempts SET answered_at=?,fact_version=MAX(fact_version,?),updated_at=? "+where, utcString(eventTime), version, utcString(receivedAt), attemptID, version) + case "ended": + reason, _ := payload["termination_reason"].(string) + if reason == "" { + reason = "unknown" + } + _, err = tx.ExecContext(ctx, "UPDATE attempts SET ended_at=?,termination_reason=?,fact_version=MAX(fact_version,?),updated_at=? "+where, utcString(eventTime), reason, version, utcString(receivedAt), attemptID, version) + case "ai_failed": + _, err = tx.ExecContext(ctx, "UPDATE attempts SET ai_status='failed',fact_version=MAX(fact_version,?),updated_at=? "+where, version, utcString(receivedAt), attemptID, version) + } + return err + }) +} + +func (s *Store) ListAttempts(ctx context.Context, filter AttemptFilter) ([]Attempt, error) { + var out []Attempt + err := s.withRead(ctx, func(db *sql.DB) error { + query := "SELECT attempt_id,execution_id,call_id,tenant_key,provider_id,trunk_id,cell_id,egress_pool_id,config_revision,mode,dialed_number,attempt_started_at,origin_status,answered_at,ended_at,termination_reason,sip_code,q850,ai_status,source,fact_version,updated_at FROM attempts WHERE 1=1" + args := []any{} + if filter.Timeline && filter.From != nil && filter.To != nil { + query += " AND answered_at IS NOT NULL AND answered_at?)" + args = append(args, utcString(*filter.To), utcString(*filter.From)) + } else if filter.From != nil && filter.To != nil { + query += " AND ((attempt_started_at>=? AND attempt_started_at=? AND updated_at=? OR (attempt_started_at IS NULL AND updated_at>=?))" + args = append(args, utcString(*filter.From), utcString(*filter.From)) + } else if filter.To != nil { + query += " AND (attempt_started_at 0 { + query += " LIMIT ?" + args = append(args, filter.Limit) + } + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + a, err := scanAttempt(rows) + if err != nil { + return err + } + out = append(out, a) + } + return rows.Err() + }) + return out, err +} + +type AttemptFilter struct { + From, To *time.Time + ProviderID, TrunkID, CellID, EgressPoolID string + Mode string + Limit int + Timeline bool +} + +func scanAttempt(scanner interface{ Scan(...any) error }) (Attempt, error) { + var a Attempt + var started, answered, ended, updated sql.NullString + var sip sql.NullInt64 + err := scanner.Scan(&a.AttemptID, &a.ExecutionID, &a.CallID, &a.TenantKey, &a.ProviderID, &a.TrunkID, &a.CellID, &a.EgressPoolID, &a.ConfigRevision, &a.Mode, &a.DialedNumber, &started, &a.OriginStatus, &answered, &ended, &a.TerminationReason, &sip, &a.Q850, &a.AIStatus, &a.Source, &a.FactVersion, &updated) + if err != nil { + return a, err + } + if started.Valid { + t, _ := parseStoredTime(started.String) + a.AttemptStartedAt = &t + } + if answered.Valid { + t, _ := parseStoredTime(answered.String) + a.AnsweredAt = &t + } + if ended.Valid { + t, _ := parseStoredTime(ended.String) + a.EndedAt = &t + } + if sip.Valid { + x := int(sip.Int64) + a.SIPCode = &x + } + a.UpdatedAt, _ = parseStoredTime(updated.String) + return a, nil +} + +func (s *Store) GetAttempt(ctx context.Context, id string) (Attempt, error) { + var out Attempt + err := s.withRead(ctx, func(db *sql.DB) error { + row := db.QueryRowContext(ctx, "SELECT attempt_id,execution_id,call_id,tenant_key,provider_id,trunk_id,cell_id,egress_pool_id,config_revision,mode,dialed_number,attempt_started_at,origin_status,answered_at,ended_at,termination_reason,sip_code,q850,ai_status,source,fact_version,updated_at FROM attempts WHERE attempt_id=?", id) + var e error + out, e = scanAttempt(row) + return e + }) + return out, err +} diff --git a/management/backend/util.go b/management/backend/util.go new file mode 100644 index 0000000..17219e0 --- /dev/null +++ b/management/backend/util.go @@ -0,0 +1,151 @@ +package management + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net" + "net/url" + "regexp" + "sort" + "strings" + "time" +) + +var idPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`) +var requestPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$`) +var safePrefixPattern = regexp.MustCompile(`^[0-9]{0,32}$`) + +func newID(prefix string) string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano()) + } + return prefix + "-" + hex.EncodeToString(b[:]) +} + +func hashJSON(v any) (string, []byte, error) { + b, err := json.Marshal(v) + if err != nil { + return "", nil, err + } + h := sha256.Sum256(b) + return hex.EncodeToString(h[:]), b, nil +} + +func hashBytes(b []byte) string { + h := sha256.Sum256(b) + return hex.EncodeToString(h[:]) +} + +func utcString(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) } +func parseStoredTime(v string) (time.Time, error) { return time.Parse(time.RFC3339Nano, v) } + +func validID(v string) bool { return idPattern.MatchString(v) } +func validRequestID(v string) bool { return requestPattern.MatchString(v) } + +func maskNumber(v string) string { + if v == "" { + return "" + } + if len(v) <= 5 { + return strings.Repeat("*", len(v)) + } + return v[:3] + strings.Repeat("*", len(v)-5) + v[len(v)-2:] +} + +func containsString(values []string, value string) bool { + for _, item := range values { + if item == value { + return true + } + } + return false +} + +func codecIntersection(a, b []string) []string { + var out []string + for _, v := range a { + if containsString(b, v) && !containsString(out, v) { + out = append(out, v) + } + } + return out +} + +func sortedStrings(values []string) []string { + out := append([]string(nil), values...) + sort.Strings(out) + return out +} + +func validateHost(host string) error { + if host == "" || len(host) > 253 || strings.ContainsAny(host, "\r\n/\\@ \t") { + return fmt.Errorf("host must be a hostname or IP address") + } + if net.ParseIP(host) != nil { + ip := net.ParseIP(host) + if ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsPrivate() { + return fmt.Errorf("private or local SIP host is not allowed") + } + return nil + } + if strings.EqualFold(host, "localhost") || strings.HasSuffix(strings.ToLower(host), ".localhost") { + return fmt.Errorf("local SIP host is not allowed") + } + for _, label := range strings.Split(host, ".") { + if label == "" || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") { + return fmt.Errorf("invalid SIP host") + } + } + return nil +} + +func validateManagementURL(raw string, allowPrivate bool, allowlist map[string]bool) error { + if raw == "" { + return nil + } + u, err := url.Parse(raw) + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return fmt.Errorf("management_url must be an https URL without credentials, query, or fragment") + } + host := u.Hostname() + if !allowPrivate { + if ip := net.ParseIP(host); ip != nil && (ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast()) { + return fmt.Errorf("management_url host is outside the allowed management network") + } + } + if len(allowlist) > 0 && !allowlist[strings.ToLower(host)] { + return fmt.Errorf("management_url host is not in the configured allowlist") + } + return nil +} + +func safePrefix(prefix string) error { + if !safePrefixPattern.MatchString(prefix) { + return fmt.Errorf("dial_prefix must contain digits only") + } + return nil +} + +func redactedConfig(cfg TrunkConfig) map[string]any { + return map[string]any{ + "provider_id": cfg.ProviderID, "display_name": cfg.DisplayName, "enabled": cfg.Enabled, + "sip": map[string]any{"host": cfg.Sip.Host, "port": cfg.Sip.Port, "transport": cfg.Sip.Transport, "auth_mode": cfg.Sip.AuthMode, "register": cfg.Sip.Register, "credential_configured": cfg.Sip.CredentialRef != ""}, + "codec_profile": cfg.CodecProfile, "caller_ids": cfg.CallerIDs, "dial_prefix": cfg.DialPrefix, + "egress_pool_id": cfg.EgressPoolID, "max_concurrency": cfg.MaxConcurrency, "max_cps": cfg.MaxCPS, + } +} + +func parseCSV(v string) []string { + var out []string + for _, item := range strings.Split(v, ",") { + item = strings.TrimSpace(item) + if item != "" && !containsString(out, item) { + out = append(out, item) + } + } + return out +} diff --git a/management/backend/verification.go b/management/backend/verification.go new file mode 100644 index 0000000..62601bb --- /dev/null +++ b/management/backend/verification.go @@ -0,0 +1,151 @@ +package management + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "github.com/gofiber/fiber/v3" +) + +var requiredVerificationChecks = []string{"transport", "registration_auth", "caller_id_rules", "codec", "capacity", "whitelist"} + +func (s *Store) AddVerification(ctx context.Context, trunkID string, revision int64, input VerificationInput, actor, requestID string) error { + if !validID(trunkID) || revision < 1 || !containsString(requiredVerificationChecks, input.CheckName) { + return newAppError(422, "INVALID_VERIFICATION", "check_name or revision is invalid", nil) + } + if input.Result != "confirmed" && input.Result != "failed" && input.Result != "unknown" && input.Result != "not_applicable" { + return newAppError(422, "INVALID_VERIFICATION_RESULT", "result is invalid", nil) + } + if input.CheckedBy == "" { + input.CheckedBy = actor + } + if strings.ContainsAny(input.EvidenceRef+input.CheckedBy, "\r\n") || len(input.EvidenceRef) > 512 { + return newAppError(422, "INVALID_VERIFICATION_EVIDENCE", "verification evidence is invalid", nil) + } + now := utcString(time.Now()) + return s.withTx(ctx, func(tx *sql.Tx) error { + var exists int + if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM trunk_versions WHERE trunk_id=? AND revision=?", trunkID, revision).Scan(&exists); err != nil { + return err + } + if exists == 0 { + return newAppError(404, "VERSION_NOT_FOUND", "trunk version does not exist", nil) + } + _, err := tx.ExecContext(ctx, "INSERT INTO trunk_verifications(trunk_id,revision,check_name,result,evidence_ref,checked_by,checked_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(trunk_id,revision,check_name) DO UPDATE SET result=excluded.result,evidence_ref=excluded.evidence_ref,checked_by=excluded.checked_by,checked_at=excluded.checked_at", trunkID, revision, input.CheckName, input.Result, input.EvidenceRef, input.CheckedBy, now) + if err != nil { + return err + } + details, _ := json.Marshal(map[string]any{"check_name": input.CheckName, "result": input.Result, "evidence_ref": input.EvidenceRef}) + return insertAudit(ctx, tx, "trunk", trunkID, "verification", revision, actor, requestID, string(details), now) + }) +} + +func (s *Store) RealVerificationComplete(ctx context.Context, trunkID string, revision int64) (bool, []string, error) { + results := map[string]string{} + err := s.withRead(ctx, func(db *sql.DB) error { + rows, err := db.QueryContext(ctx, "SELECT check_name,result FROM trunk_verifications WHERE trunk_id=? AND revision=?", trunkID, revision) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var name, result string + if err := rows.Scan(&name, &result); err != nil { + return err + } + results[name] = result + } + return rows.Err() + }) + if err != nil { + return false, nil, err + } + missing := make([]string, 0) + for _, name := range requiredVerificationChecks { + if results[name] != "confirmed" { + missing = append(missing, name) + } + } + return len(missing) == 0, missing, nil +} + +func (s *Store) ListVerifications(ctx context.Context, trunkID string, revision int64) ([]map[string]any, error) { + var out []map[string]any + err := s.withRead(ctx, func(db *sql.DB) error { + rows, err := db.QueryContext(ctx, "SELECT check_name,result,evidence_ref,checked_by,checked_at FROM trunk_verifications WHERE trunk_id=? AND revision=? ORDER BY check_name", trunkID, revision) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var name, result, evidence, by, at string + if err := rows.Scan(&name, &result, &evidence, &by, &at); err != nil { + return err + } + out = append(out, map[string]any{"check_name": name, "result": result, "evidence_ref": evidence, "checked_by": by, "checked_at": at}) + } + return rows.Err() + }) + return out, err +} + +func (s *Server) addVerification(c fiber.Ctx) error { + trunkID := c.Params("trunk_id") + p, authErr := s.authorize(c, "admin", "sip.trunk.write", "trunk", trunkID) + if authErr != nil { + return s.fail(c, authErr) + } + expected, err := parseIfMatch(c) + if err != nil { + return s.fail(c, err) + } + requestID, err := requireHeader(c, "X-Request-ID") + if err != nil { + return s.fail(c, err) + } + var body struct { + Revision int64 `json:"revision"` + VerificationInput + } + if err := decodeJSON(c, &body); err != nil { + return s.fail(c, err) + } + if body.Revision < 1 { + return s.fail(c, newAppError(422, "REVISION_REQUIRED", "revision is required", nil)) + } + ctx, cancel := s.context(c) + defer cancel() + trunk, trunkErr := s.store.GetTrunk(ctx, trunkID, s.cfg.PublicMode()) + if errors.Is(trunkErr, sql.ErrNoRows) { + return s.fail(c, newAppError(404, "TRUNK_NOT_FOUND", "trunk does not exist", nil)) + } + if trunkErr != nil { + return s.fail(c, asAppError(trunkErr)) + } + if trunk.LatestRevision != expected || body.Revision > trunk.LatestRevision { + return s.fail(c, newAppError(409, "REVISION_CONFLICT", "verification must target the current or an existing revision", map[string]any{"current_revision": trunk.LatestRevision})) + } + op, replay, beginErr := s.beginOperation(ctx, p, "trunk.verification", "trunk", trunkID, requestID, body, expected) + if replay || beginErr != nil { + return s.replayOrError(c, op, beginErr) + } + if err := s.store.AddVerification(ctx, trunkID, body.Revision, body.VerificationInput, p.Principal, requestID); err != nil { + appErr := asAppError(err) + _ = s.store.FinishOperation(ctx, op.OperationID, "failed", appErr.Status, "", errorJSON(appErr, requestID)) + return s.fail(c, appErr) + } + checks, checksErr := s.store.ListVerifications(ctx, trunkID, body.Revision) + if checksErr != nil { + return s.fail(c, asAppError(checksErr)) + } + response := map[string]any{"mode": s.cfg.PublicMode(), "trunk_id": trunkID, "revision": body.Revision, "verifications": checks} + if err := s.finishSuccess(ctx, op, http.StatusOK, response); err != nil { + return s.fail(c, asAppError(err)) + } + return c.JSON(response) +} diff --git a/management/contracts/cell-agent.openapi.yaml b/management/contracts/cell-agent.openapi.yaml new file mode 100644 index 0000000..f37e4c0 --- /dev/null +++ b/management/contracts/cell-agent.openapi.yaml @@ -0,0 +1,232 @@ +# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema +openapi: 3.1.0 +info: + title: agent-call Cell Agent API + version: 1.0.0 + description: >- + Restricted mTLS API used by the SIP management backend to deliver a + versioned Trunk snapshot to one voice Cell. The Cell validates the SHA-256 + snapshot, applies it with an atomic file replacement, reloads Asterisk, + restores the previous file on reload failure, and returns applied only + after the reload succeeds. A disabled snapshot removes the Cell-local + Trunk fragment and reloads Asterisk. +servers: + - url: https://cell.internal:9443 + description: Cell management network only +tags: + - name: health + - name: trunk-apply +paths: + /healthz/live: + get: + tags: [health] + operationId: live + responses: + '200': + description: Cell Agent is alive + content: + application/json: + schema: {$ref: '#/components/schemas/Health'} + /v1/sip/trunks/{trunk_id}/apply: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [trunk-apply] + operationId: applyTrunk + security: [{CellManagementMtls: []}] + parameters: + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/Publication'} + responses: + '200': + description: Asterisk has loaded the exact snapshot + content: + application/json: + schema: {$ref: '#/components/schemas/Acknowledgement'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': + description: >- + Revision or expected local snapshot is stale; the Cell never + guesses over an unknown baseline + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + '502': + description: Asterisk rejected the apply or reload + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + /v1/sip/trunks/{trunk_id}/state: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [trunk-apply] + operationId: getTrunkState + security: [{CellManagementMtls: []}] + responses: + '200': + description: Durable Cell apply state + content: + application/json: + schema: {$ref: '#/components/schemas/State'} + '404': {$ref: '#/components/responses/NotFound'} +components: + securitySchemes: + CellManagementMtls: + type: mutualTLS + description: Management backend client certificate signed by the Cell CA. + parameters: + TrunkId: + name: trunk_id + in: path + required: true + schema: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'} + RequestId: + name: X-Request-ID + in: header + required: true + schema: {type: string, minLength: 1, maxLength: 128} + schemas: + Health: + type: object + additionalProperties: false + required: [status, mode, cell_id] + properties: + status: {type: string, const: ok} + mode: {type: string, const: real} + cell_id: {type: string} + CodecProfile: + type: object + additionalProperties: false + required: [allowed, preferred] + properties: + allowed: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, enum: [PCMA, PCMU]} + preferred: {type: string, enum: [PCMA, PCMU]} + SipConfig: + type: object + additionalProperties: false + required: [host, port, transport, auth_mode, register, credential_ref] + properties: + host: {type: string, minLength: 1, maxLength: 253} + port: {type: integer, minimum: 1, maximum: 65535} + transport: {type: string, enum: [udp, tcp, tls]} + auth_mode: {type: string, enum: [ip, digest]} + register: {type: boolean} + credential_ref: + type: [string, 'null'] + description: Secret-store reference only; plaintext is forbidden. + TrunkConfig: + type: object + additionalProperties: false + required: + - provider_id + - display_name + - enabled + - sip + - codec_profile + - caller_ids + - dial_prefix + - egress_pool_id + - max_concurrency + - max_cps + properties: + provider_id: {type: string} + display_name: {type: string, minLength: 1, maxLength: 256} + enabled: {type: boolean} + sip: {$ref: '#/components/schemas/SipConfig'} + codec_profile: {$ref: '#/components/schemas/CodecProfile'} + caller_ids: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, minLength: 1, maxLength: 128} + dial_prefix: {type: string, maxLength: 32} + egress_pool_id: {type: string} + max_concurrency: {type: integer, minimum: 1} + max_cps: {type: integer, minimum: 1} + Publication: + type: object + additionalProperties: false + required: + [mode, cell_id, trunk_id, revision, expected_local_revision, config, + config_sha256] + properties: + mode: {type: string, const: real} + cell_id: {type: string} + trunk_id: {type: string} + revision: {type: integer, minimum: 1} + expected_local_revision: {type: integer, minimum: 0} + config: {$ref: '#/components/schemas/TrunkConfig'} + config_sha256: + type: string + pattern: '^[0-9a-f]{64}$' + Acknowledgement: + type: object + additionalProperties: false + required: + [mode, cell_id, trunk_id, revision, config_sha256, status, idempotent] + properties: + mode: {type: string, const: real} + cell_id: {type: string} + trunk_id: {type: string} + revision: {type: integer, minimum: 1} + config_sha256: {type: string, pattern: '^[0-9a-f]{64}$'} + status: {type: string, const: applied} + idempotent: {type: boolean} + State: + type: object + additionalProperties: false + required: + [ + mode, + cell_id, + trunk_id, + desired_revision, + applied_revision, + status, + updated_at, + ] + properties: + mode: {type: string, const: real} + cell_id: {type: string} + trunk_id: {type: string} + desired_revision: {type: integer, minimum: 1} + applied_revision: {type: integer, minimum: 0} + status: {type: string, enum: [applying, applied, failed]} + last_error: {type: [string, 'null']} + updated_at: {type: string, format: date-time} + ErrorResponse: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: {type: string} + message: {type: string} + responses: + BadRequest: + description: Invalid publication or hash + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + Forbidden: + description: Certificate or Cell identity is not authorized + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + NotFound: + description: State does not exist + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} diff --git a/management/contracts/manifest.json b/management/contracts/manifest.json new file mode 100644 index 0000000..e0001dd --- /dev/null +++ b/management/contracts/manifest.json @@ -0,0 +1,13 @@ +{ + "source": "docs/contracts", + "files": { + "sip-management.openapi.yaml": { + "source_path": "docs/contracts/sip-management.openapi.yaml", + "sha256": "2a4974e241fd55b4f72514c69bf75e18244bc1dca93797d8f9bb5ae00c9bd9aa" + }, + "cell-agent.openapi.yaml": { + "source_path": "docs/contracts/cell-agent.openapi.yaml", + "sha256": "79dc697d7ce16e2a6aa7e350f30dd006dd847afe2cdc0ea29841f9c6d4310c41" + } + } +} diff --git a/management/contracts/sip-management.openapi.yaml b/management/contracts/sip-management.openapi.yaml new file mode 100644 index 0000000..35b2dac --- /dev/null +++ b/management/contracts/sip-management.openapi.yaml @@ -0,0 +1,1065 @@ +--- +# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema +openapi: 3.1.0 +info: + title: agent-call Asterisk/SIP Management API + version: 1.0.0 + description: >- + Independent Asterisk/SIP management backend. Admin write operations are + separate from the SaaS read-only Trunk directory and from ordinary + scheduling APIs. In mock mode publication records are intents only. In + real mode a publication is successful only after every selected Cell Agent + returns a matching mTLS acknowledgement. +servers: + - url: https://sip-admin.internal + description: Restricted operator management network + - url: https://sip-read.internal + description: SaaS read-only service network +tags: + - name: health + - name: admin-providers + - name: admin-trunks + - name: admin-cells + - name: admin-status + - name: admin-statistics + - name: admin-audit + - name: saas-readonly +paths: + /healthz/live: + get: + tags: [health] + operationId: live + responses: + '200': + description: Service is alive + content: + application/json: + schema: + $ref: '#/components/schemas/Health' + /admin/v1/providers: + get: + tags: [admin-providers] + operationId: listProviders + security: [{SipAdminBearer: []}] + responses: + '200': + description: Provider directory + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderList'} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/providers/{provider_id}: + parameters: + - {$ref: '#/components/parameters/ProviderId'} + get: + tags: [admin-providers] + operationId: getProvider + security: [{SipAdminBearer: []}] + responses: + '200': + description: Provider + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + put: + tags: [admin-providers] + operationId: upsertProvider + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderInput'} + responses: + '200': + description: Updated provider + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderResponse'} + '201': + description: Created provider + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderResponse'} + '400': {$ref: '#/components/responses/BadRequest'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks: + get: + tags: [admin-trunks] + operationId: listAdminTrunks + security: [{SipAdminBearer: []}] + responses: + '200': + description: All Trunks, including unpublished revisions + content: + application/json: + schema: + $ref: '#/components/schemas/AdminTrunkList' + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + /admin/v1/trunks/{trunk_id}: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [admin-trunks] + operationId: getAdminTrunk + security: [{SipAdminBearer: []}] + responses: + '200': + description: Trunk configuration and revisions + content: + application/json: + schema: + $ref: '#/components/schemas/AdminTrunk' + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + put: + tags: [admin-trunks] + operationId: createTrunkRevision + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TrunkConfig' + responses: + '200': + description: New draft revision for an existing Trunk + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '201': + description: New Trunk with its first draft revision + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '400': {$ref: '#/components/responses/BadRequest'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/validate: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: validateTrunk + security: [{SipAdminBearer: []}] + requestBody: + required: false + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + revision: {type: integer, minimum: 1} + responses: + '200': + description: Validation issues, compatible Cells, and impact preview + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/trunks/{trunk_id}/verifications: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: addTrunkVerification + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VerificationRequest' + responses: + '200': + description: Versioned verification record + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/publish: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: publishTrunk + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + responses: + '200': + description: >- + Published revision after all selected Cell acknowledgements + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/disable: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: disableTrunk + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + responses: + '200': + description: Trunk disabled after selected Cell acknowledgements + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/rollback: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: rollbackTrunk + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [target_revision] + properties: + target_revision: {type: integer, minimum: 1} + responses: + '200': + description: >- + New revision copied from the target after Cell acknowledgements + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/publications: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [admin-trunks] + operationId: listTrunkPublications + security: [{SipAdminBearer: []}] + responses: + '200': + description: Per-Cell publication intents + content: + application/json: + schema: + type: object + required: [mode, publications] + properties: + mode: {$ref: '#/components/schemas/Mode'} + publications: + type: array + items: {$ref: '#/components/schemas/Publication'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/trunks/{trunk_id}/audit: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [admin-trunks] + operationId: listTrunkAudit + security: [{SipAdminBearer: []}] + responses: + '200': + description: Immutable management audit entries + content: + application/json: + schema: + type: object + required: [mode, audit] + properties: + mode: {$ref: '#/components/schemas/Mode'} + audit: + type: array + items: {$ref: '#/components/schemas/AuditEntry'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/cells: + get: + tags: [admin-cells] + operationId: listCells + security: [{SipAdminBearer: []}] + responses: + '200': + description: Registered multi-machine voice Cells + content: + application/json: + schema: + type: object + required: [mode, cells] + properties: + mode: {$ref: '#/components/schemas/Mode'} + cells: + type: array + items: {$ref: '#/components/schemas/Cell'} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/cells/{cell_id}: + parameters: + - {$ref: '#/components/parameters/CellId'} + put: + tags: [admin-cells] + operationId: registerCell + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CellConfig'} + responses: + '200': + description: Updated Cell revision + content: + application/json: + schema: {$ref: '#/components/schemas/Cell'} + '201': + description: Registered Cell + content: + application/json: + schema: {$ref: '#/components/schemas/Cell'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/cells/{cell_id}/observations: + parameters: + - {$ref: '#/components/parameters/CellId'} + post: + tags: [admin-cells] + operationId: ingestCellObservation + security: [{SipAdminBearer: []}] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ObservationInput'} + responses: + '201': + description: >- + Observation accepted for the current boot and monotonic sequence + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/egress-pools: + get: + tags: [admin-cells] + operationId: listEgressPools + security: [{SipAdminBearer: []}] + responses: + '200': + description: Fixed egress pool directory + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/sip-status: + get: + tags: [admin-status] + operationId: getSipStatus + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/CellFilter'} + - {$ref: '#/components/parameters/ProviderFilter'} + responses: + '200': + description: >- + Cell/trunk status matrix with freshness and missing sources + content: + application/json: + schema: {$ref: '#/components/schemas/StatusResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/cells/{cell_id}/sip-status: + parameters: + - {$ref: '#/components/parameters/CellId'} + get: + tags: [admin-status] + operationId: getCellSipStatus + security: [{SipAdminBearer: []}] + responses: + '200': + description: One Cell status + content: + application/json: + schema: {$ref: '#/components/schemas/StatusItem'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/providers/{provider_id}/status: + parameters: + - {$ref: '#/components/parameters/ProviderId'} + get: + tags: [admin-status] + operationId: getProviderStatus + security: [{SipAdminBearer: []}] + responses: + '200': + description: Provider status matrix + content: + application/json: + schema: {$ref: '#/components/schemas/StatusResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/statistics/outbound/summary: + get: + tags: [admin-statistics] + operationId: getOutboundSummary + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/From'} + - {$ref: '#/components/parameters/To'} + - {$ref: '#/components/parameters/StatsMode'} + - {$ref: '#/components/parameters/ProviderFilter'} + - {$ref: '#/components/parameters/TrunkFilter'} + - {$ref: '#/components/parameters/CellFilter'} + - {$ref: '#/components/parameters/EgressFilter'} + responses: + '200': + description: Cohort and interval metrics with completeness metadata + content: + application/json: + schema: {$ref: '#/components/schemas/StatisticsSummary'} + '401': {$ref: '#/components/responses/Unauthorized'} + '422': {$ref: '#/components/responses/BadRequest'} + /admin/v1/statistics/outbound/timeseries: + get: + tags: [admin-statistics] + operationId: getOutboundTimeseries + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/From'} + - {$ref: '#/components/parameters/To'} + - {$ref: '#/components/parameters/StatsMode'} + - {$ref: '#/components/parameters/Granularity'} + - {$ref: '#/components/parameters/EgressFilter'} + responses: + '200': + description: Bounded UTC time series + content: + application/json: + schema: {$ref: '#/components/schemas/TimeseriesResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '422': {$ref: '#/components/responses/BadRequest'} + /admin/v1/call-attempts: + get: + tags: [admin-statistics] + operationId: listCallAttempts + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/From'} + - {$ref: '#/components/parameters/To'} + - {$ref: '#/components/parameters/StatsMode'} + - {$ref: '#/components/parameters/Limit'} + - {$ref: '#/components/parameters/Cursor'} + - {$ref: '#/components/parameters/EgressFilter'} + responses: + '200': + description: Redacted raw attempt facts + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/call-attempts/{attempt_id}: + parameters: + - name: attempt_id + in: path + required: true + schema: {type: string} + get: + tags: [admin-statistics] + operationId: getCallAttempt + security: [{SipAdminBearer: []}] + responses: + '200': + description: One redacted attempt fact + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/audit: + get: + tags: [admin-audit] + operationId: listAudit + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/Limit'} + - {$ref: '#/components/parameters/RequestFilter'} + responses: + '200': + description: Immutable audit entries + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/operations/{operation_id}: + parameters: + - name: operation_id + in: path + required: true + schema: {type: string} + get: + tags: [admin-audit] + operationId: getOperation + security: [{SipAdminBearer: []}] + responses: + '200': + description: Durable operation state for retry/recovery + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /readonly/v1/sip/trunks: + get: + tags: [saas-readonly] + operationId: listAuthorizedTrunks + security: [{SaasTrunkReadBearer: []}] + responses: + '200': + description: Published Trunks authorized for this SaaS principal + content: + application/json: + schema: + $ref: '#/components/schemas/ReadonlyTrunkList' + '401': {$ref: '#/components/responses/Unauthorized'} + /readonly/v1/sip/trunks/{trunk_id}: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [saas-readonly] + operationId: getAuthorizedTrunk + security: [{SaasTrunkReadBearer: []}] + responses: + '200': + description: Published, sanitized Trunk metadata + content: + application/json: + schema: {$ref: '#/components/schemas/ReadonlyTrunk'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} +components: + securitySchemes: + SipAdminBearer: + type: http + scheme: bearer + bearerFormat: opaque + description: >- + Dedicated operator/backend credential for SIP management writes. It is + not accepted by the SaaS read-only API or ordinary scheduling API. + SaasTrunkReadBearer: + type: http + scheme: bearer + bearerFormat: opaque + description: >- + Dedicated SaaS read-only credential. It cannot publish, modify, disable, + rollback, or access Cell management. + parameters: + ProviderId: + name: provider_id + in: path + required: true + schema: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'} + TrunkId: + name: trunk_id + in: path + required: true + schema: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'} + CellId: + name: cell_id + in: path + required: true + schema: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'} + IfMatch: + name: If-Match + in: header + required: true + description: Exact latest revision required for CAS; quotes are accepted. + schema: {type: integer, minimum: 0} + CellFilter: + name: cell_ids + in: query + required: false + schema: {type: string} + ProviderFilter: + name: provider_id + in: query + required: false + schema: {type: string} + TrunkFilter: + name: trunk_id + in: query + required: false + schema: {type: string} + From: + name: from + in: query + required: false + schema: {type: string, format: date-time} + To: + name: to + in: query + required: false + schema: {type: string, format: date-time} + StatsMode: + name: mode + in: query + required: false + schema: {type: string, enum: [mock, mixed, real]} + EgressFilter: + name: egress_pool_id + in: query + required: false + schema: {type: string} + Granularity: + name: granularity + in: query + required: false + schema: {type: string, enum: [minute, hour, day]} + Limit: + name: limit + in: query + required: false + schema: {type: integer, minimum: 1, maximum: 200} + Cursor: + name: cursor + in: query + required: false + schema: {type: string} + RequestFilter: + name: request_id + in: query + required: false + schema: {type: string} + RequestId: + name: X-Request-ID + in: header + required: true + schema: {type: string, minLength: 1, maxLength: 128} + responses: + BadRequest: + description: Invalid configuration or request + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + Unauthorized: + description: Missing or wrong authentication domain + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + Forbidden: + description: Credential lacks the required scope + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + Conflict: + description: CAS conflict or no compatible Cell + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + NotFound: + description: Resource is not visible or does not exist + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + schemas: + Mode: + type: string + enum: [mock, real] + StatsMode: + type: string + enum: [mock, mixed, real] + Health: + type: object + additionalProperties: false + required: [status, mode] + properties: + status: {type: string, const: ok} + mode: {$ref: '#/components/schemas/Mode'} + CodecProfile: + type: object + additionalProperties: false + required: [allowed, preferred] + properties: + allowed: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, enum: [PCMA, PCMU]} + preferred: {type: string, enum: [PCMA, PCMU]} + SipConfig: + type: object + additionalProperties: false + required: [host, port, transport, auth_mode, register] + properties: + host: {type: string, minLength: 1, maxLength: 253} + port: {type: integer, minimum: 1, maximum: 65535} + transport: {type: string, enum: [udp, tcp, tls]} + auth_mode: {type: string, enum: [ip, digest]} + register: {type: boolean} + credential_ref: + type: string + writeOnly: true + description: >- + Secret-store reference only; plaintext credentials are forbidden. + VerificationRequest: + type: object + additionalProperties: false + required: [revision, check_name, result] + properties: + revision: {type: integer, minimum: 1} + check_name: + type: string + enum: + - transport + - registration_auth + - caller_id_rules + - codec + - capacity + - whitelist + result: + type: string + enum: [confirmed, failed, unknown, not_applicable] + evidence_ref: {type: string, maxLength: 512} + checked_by: {type: string, maxLength: 128} + TrunkConfig: + type: object + additionalProperties: false + required: + - provider_id + - display_name + - enabled + - sip + - codec_profile + - caller_ids + - dial_prefix + - egress_pool_id + - max_concurrency + - max_cps + properties: + provider_id: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' + display_name: {type: string, minLength: 1, maxLength: 256} + enabled: {type: boolean} + sip: {$ref: '#/components/schemas/SipConfig'} + codec_profile: {$ref: '#/components/schemas/CodecProfile'} + caller_ids: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, minLength: 1, maxLength: 128} + dial_prefix: {type: string, maxLength: 32} + egress_pool_id: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' + max_concurrency: {type: integer, minimum: 1} + max_cps: {type: integer, minimum: 1} + CellConfig: + type: object + additionalProperties: false + required: [egress_pool_id, codec_capabilities, status, max_concurrency] + properties: + egress_pool_id: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' + codec_capabilities: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, enum: [PCMA, PCMU]} + status: {type: string, enum: [healthy, draining, disabled]} + max_concurrency: {type: integer, minimum: 1} + management_url: + type: string + format: uri + pattern: '^https://' + description: >- + mTLS Cell Agent endpoint. Required when mode=real; credentials and + query strings are not allowed. + RevisionInfo: + type: object + additionalProperties: false + required: [revision, state, created_at, created_by] + properties: + revision: {type: integer, minimum: 1} + state: {type: string, enum: [draft, publishing, published, superseded]} + config_sha256: {type: string, pattern: '^[a-f0-9]{64}$'} + created_at: {type: string, format: date-time} + created_by: {type: string} + AdminTrunk: + type: object + required: + - mode + - trunk_id + - latest_revision + - active_revision + - active_status + - status + - compatible_cell_ids + - latest + - active + - versions + properties: + mode: {$ref: '#/components/schemas/Mode'} + trunk_id: {type: string} + latest_revision: {type: integer, minimum: 1} + active_revision: {type: integer, minimum: 0} + active_status: {type: string, enum: [draft, published, disabled]} + status: {type: string, enum: [draft, published, disabled]} + updated_at: {type: string, format: date-time} + compatible_cell_ids: {type: array, items: {type: string}} + latest: {$ref: '#/components/schemas/TrunkView'} + active: {$ref: '#/components/schemas/TrunkView'} + versions: + type: array + items: + $ref: '#/components/schemas/RevisionInfo' + TrunkView: + allOf: + - {$ref: '#/components/schemas/TrunkConfig'} + - type: object + properties: + trunk_id: {type: string} + credential_configured: {type: boolean} + asterisk_allow: + type: array + items: {type: string, enum: [alaw, ulaw]} + config_sha256: {type: string, pattern: '^[a-f0-9]{64}$'} + ReadonlyTrunk: + type: object + required: + - mode + - trunk_id + - revision + - status + - config + properties: + mode: {$ref: '#/components/schemas/Mode'} + trunk_id: {type: string} + revision: {type: integer, minimum: 1} + status: {type: string, const: published} + updated_at: {type: string, format: date-time} + config: {$ref: '#/components/schemas/TrunkView'} + AdminTrunkList: + type: object + required: [mode, trunks] + properties: + mode: {$ref: '#/components/schemas/Mode'} + trunks: {type: array, items: {$ref: '#/components/schemas/AdminTrunk'}} + ReadonlyTrunkList: + type: object + required: [mode, trunks] + properties: + mode: {$ref: '#/components/schemas/Mode'} + trunks: + type: array + items: + $ref: '#/components/schemas/ReadonlyTrunk' + Cell: + type: object + required: [mode, cell_id, revision, config, updated_at, updated_by] + properties: + mode: {$ref: '#/components/schemas/Mode'} + cell_id: {type: string} + revision: {type: integer, minimum: 1} + config: {$ref: '#/components/schemas/CellConfig'} + updated_at: {type: string, format: date-time} + updated_by: {type: string} + Publication: + type: object + required: [trunk_id, revision, cell_id, status, updated_at] + properties: + trunk_id: {type: string} + revision: {type: integer, minimum: 1} + cell_id: {type: string} + status: {type: string, enum: [pending, applied, failed]} + error_code: {type: [string, 'null']} + updated_at: {type: string, format: date-time} + AuditEntry: + type: object + required: + - audit_id + - resource_type + - resource_id + - action + - revision + - actor + - details_json + - created_at + properties: + audit_id: {type: string} + resource_type: {type: string, const: trunk} + resource_id: {type: string} + action: + type: string + enum: + [ + upsert, + publish, + publish_failed, + disable, + disable_failed, + rollback, + rollback_failed, + ] + revision: {type: integer, minimum: 0} + actor: {type: string} + request_id: {type: [string, 'null']} + details_json: {type: string} + created_at: {type: string, format: date-time} + ProviderInput: + type: object + additionalProperties: false + required: [display_name, lifecycle] + properties: + display_name: {type: string, minLength: 1, maxLength: 256} + notes: {type: string, maxLength: 2000} + lifecycle: {type: string, enum: [active, archived]} + Provider: + allOf: + - {$ref: '#/components/schemas/ProviderInput'} + - type: object + required: [provider_id, revision, trunk_count, created_at, updated_at] + properties: + provider_id: {type: string} + revision: {type: integer, minimum: 1} + trunk_count: {type: integer, minimum: 0} + created_at: {type: string, format: date-time} + updated_at: {type: string, format: date-time} + updated_by: {type: string} + ProviderList: + type: object + required: [mode, providers] + properties: + mode: {$ref: '#/components/schemas/Mode'} + providers: {type: array, items: {$ref: '#/components/schemas/Provider'}} + ProviderResponse: + type: object + required: [mode, provider] + properties: + mode: {$ref: '#/components/schemas/Mode'} + provider: {$ref: '#/components/schemas/Provider'} + ObservationInput: + type: object + additionalProperties: false + required: [cell_id, boot_id, sequence, observed_at, source, states] + properties: + observation_id: {type: string} + cell_id: {type: string} + trunk_id: {type: string} + boot_id: {type: string, minLength: 1} + sequence: {type: integer, minimum: 1} + observed_at: {type: string, format: date-time} + source: {type: string} + config_revision: {type: integer, minimum: 0} + states: {type: object} + occupancy: {type: object} + sample_id: {type: string} + StatusItem: + type: object + required: [mode, cell_id, availability, complete, missing_sources] + properties: + mode: {$ref: '#/components/schemas/Mode'} + cell_id: {type: string} + availability: {type: string, enum: [healthy, stale, unknown, disabled]} + complete: {type: boolean} + observed_at: {type: [string, 'null'], format: date-time} + received_at: {type: [string, 'null'], format: date-time} + observation_age_seconds: {type: [integer, 'null'], minimum: 0} + clock_skew: {type: boolean} + reason: {type: string} + boot_id: {type: string} + sequence: {type: integer, minimum: 1} + config_revision: {type: integer, minimum: 1} + config_status: {type: string} + egress_pool_id: {type: string} + eligibility: {type: string} + missing_sources: {type: array, items: {type: string}} + states: {type: object} + occupancy: {type: object} + trunks: {type: array, items: {type: object}} + publication: {type: object} + StatusResponse: + type: object + required: [mode, complete, cells] + properties: + mode: {$ref: '#/components/schemas/Mode'} + complete: {type: boolean} + generated_at: {type: string, format: date-time} + data_as_of: {type: [string, 'null'], format: date-time} + coverage: {type: object} + cells: {type: array, items: {$ref: '#/components/schemas/StatusItem'}} + StatisticsSummary: + type: object + required: [mode, from, to, complete, metrics] + properties: + mode: {$ref: '#/components/schemas/StatsMode'} + from: {type: string, format: date-time} + to: {type: string, format: date-time} + timezone: {type: string} + definition_version: {type: string} + filters: {type: object} + complete: {type: boolean} + missing_sources: {type: array, items: {type: string}} + unresolved_count: {type: integer, minimum: 0} + data_as_of: {type: [string, 'null'], format: date-time} + metrics: {type: object} + failure_reasons: {type: array, items: {type: object}} + realtime: {type: object} + TimeseriesResponse: + type: object + required: [mode, from, to, granularity, complete, series] + properties: + mode: {$ref: '#/components/schemas/StatsMode'} + from: {type: string, format: date-time} + to: {type: string, format: date-time} + timezone: {type: string} + definition_version: {type: string} + filters: {type: object} + granularity: {type: string, enum: [minute, hour, day]} + complete: {type: boolean} + series: {type: array, items: {type: object}} + ErrorResponse: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: {type: string} + message: {type: string} + fields: {type: object} + request_id: {type: string} diff --git a/management/deploy/Dockerfile.backend b/management/deploy/Dockerfile.backend new file mode 100644 index 0000000..a582054 --- /dev/null +++ b/management/deploy/Dockerfile.backend @@ -0,0 +1,13 @@ +FROM golang:1.26-trixie AS build +WORKDIR /src +COPY . . +RUN go build -trimpath -ldflags='-s -w' -o /out/management ./cmd/management + +FROM debian:trixie-slim +RUN useradd --system --home-dir /nonexistent --shell /usr/sbin/nologin management && mkdir -p /data && chown management:management /data +COPY --from=build /out/management /usr/local/bin/management +USER management +ENV MANAGEMENT_ADDR=0.0.0.0:8080 MANAGEMENT_DB_PATH=/data/management.db MANAGEMENT_MODE=mock +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/management"] +CMD ["serve"] diff --git a/management/deploy/Dockerfile.frontend b/management/deploy/Dockerfile.frontend new file mode 100644 index 0000000..45337f2 --- /dev/null +++ b/management/deploy/Dockerfile.frontend @@ -0,0 +1,16 @@ +FROM oven/bun:1.3.3 AS build +WORKDIR /src +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile +COPY . . +ARG VITE_MANAGEMENT_API_URL=http://localhost:8080 +ENV VITE_MANAGEMENT_API_URL=${VITE_MANAGEMENT_API_URL} +RUN bun run build + +FROM oven/bun:1.3.3 +WORKDIR /app +ENV NODE_ENV=production +COPY --from=build /src/.output ./.output +USER bun +EXPOSE 3000 +CMD ["bun", ".output/server/index.mjs"] diff --git a/management/deploy/compose.images.yaml b/management/deploy/compose.images.yaml new file mode 100644 index 0000000..e129292 --- /dev/null +++ b/management/deploy/compose.images.yaml @@ -0,0 +1,27 @@ +services: + management-api: + # yamllint disable-line rule:line-length + image: ${MANAGEMENT_API_IMAGE:-git.ipao.vip/rogee/agent-call-management-api:latest} + environment: + MANAGEMENT_ADDR: 0.0.0.0:8080 + MANAGEMENT_DB_PATH: /data/management.db + MANAGEMENT_MODE: mock + MANAGEMENT_SEED_DEMO: "true" + MANAGEMENT_MOCK_CELL_APPLY: "false" + # yamllint disable-line rule:line-length + MANAGEMENT_ALLOWED_ORIGINS: ${MANAGEMENT_ALLOWED_ORIGINS:-http://localhost:3000} + volumes: + - management-data:/data + ports: + - "${MANAGEMENT_API_PORT:-8080}:8080" + + management-ui: + # yamllint disable-line rule:line-length + image: ${MANAGEMENT_UI_IMAGE:-git.ipao.vip/rogee/agent-call-management-ui:latest} + depends_on: + - management-api + ports: + - "${MANAGEMENT_UI_PORT:-3000}:3000" + +volumes: + management-data: diff --git a/management/deploy/compose.yaml b/management/deploy/compose.yaml new file mode 100644 index 0000000..f4ceeb2 --- /dev/null +++ b/management/deploy/compose.yaml @@ -0,0 +1,30 @@ +services: + management-api: + build: + context: ../backend + dockerfile: ../deploy/Dockerfile.backend + environment: + MANAGEMENT_ADDR: 0.0.0.0:8080 + MANAGEMENT_DB_PATH: /data/management.db + MANAGEMENT_MODE: mock + MANAGEMENT_SEED_DEMO: "true" + MANAGEMENT_MOCK_CELL_APPLY: "false" + MANAGEMENT_ALLOWED_ORIGINS: ${MANAGEMENT_ALLOWED_ORIGINS:-http://localhost:3000} + volumes: + - management-data:/data + ports: + - "${MANAGEMENT_API_PORT:-8080}:8080" + + management-ui: + build: + context: ../frontend + dockerfile: ../deploy/Dockerfile.frontend + args: + VITE_MANAGEMENT_API_URL: ${MANAGEMENT_UI_API_URL:-http://localhost:8080} + depends_on: + - management-api + ports: + - "${MANAGEMENT_UI_PORT:-3000}:3000" + +volumes: + management-data: diff --git a/management/frontend/.dockerignore b/management/frontend/.dockerignore new file mode 100644 index 0000000..552450b --- /dev/null +++ b/management/frontend/.dockerignore @@ -0,0 +1,4 @@ +node_modules +.output +dist +.git diff --git a/management/frontend/.gitignore b/management/frontend/.gitignore new file mode 100644 index 0000000..254d109 --- /dev/null +++ b/management/frontend/.gitignore @@ -0,0 +1,60 @@ +# Dependencies +node_modules +.pnp +.pnp.js + +# Build outputs +dist +build +.output +.vercel +.vinxi +*.tsbuildinfo + +# TanStack Router generated +src/routeTree.gen.ts + +# Environment variables +.env +.env*.local + +# IDEs and editors +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.idea +*.swp +*.swo +*~ +.DS_Store + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Turbo +.turbo +.nx + +# Better-T-Stack +.alchemy + +# Testing +coverage +.nyc_output + +# Misc +*.tgz +.cache +tmp +temp + +# clerk configuration (can include secrets) +/.clerk/ diff --git a/management/frontend/.oxfmtrc.json b/management/frontend/.oxfmtrc.json new file mode 100644 index 0000000..5ca6507 --- /dev/null +++ b/management/frontend/.oxfmtrc.json @@ -0,0 +1,31 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "arrowParens": "always", + "bracketSpacing": true, + "semi": true, + "useTabs": false, + "trailingComma": "none", + "jsxSingleQuote": true, + "singleQuote": true, + "tabWidth": 2, + "endOfLine": "lf", + "experimentalSortTailwindcss": true, + "ignorePatterns": [ + ".tanstack/**", + "dist/**", + "build/**", + "node_modules/**", + "scripts/**", + "src/routeTree.gen.ts", + "pnpm-lock.yaml", + "CHANGELOG.md" + ], + "overrides": [ + { + "files": ["*.md", "*.mdc"], + "options": { + "proseWrap": "preserve" + } + } + ] +} diff --git a/management/frontend/.oxlintrc.json b/management/frontend/.oxlintrc.json new file mode 100644 index 0000000..7aec1f6 --- /dev/null +++ b/management/frontend/.oxlintrc.json @@ -0,0 +1,71 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["eslint", "typescript", "unicorn", "oxc", "react", "import", "jsx-a11y"], + "env": { + "es6": true, + "browser": true, + "node": true + }, + "ignorePatterns": [".tanstack/**", "dist/**", "build/**", "src/routeTree.gen.ts", "scripts/**"], + "settings": { + "jsx-a11y": { + "components": { + "Image": "img", + "Link": "a" + } + } + }, + "categories": { + "correctness": "error", + "suspicious": "warn" + }, + "rules": { + "no-console": ["warn", { "allow": ["warn", "error"] }], + "no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }], + "no-unused-expressions": "warn", + "@typescript-eslint/no-explicit-any": "warn", + + "react/react-in-jsx-scope": "off", + "react/no-unknown-property": "off", + "react/jsx-no-target-blank": "off", + "react/no-children-prop": "off", + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + + "no-shadow": "off", + "import/no-anonymous-default-export": "warn", + "import/no-unassigned-import": "off", + + "jsx-a11y/alt-text": ["warn", { "elements": ["img"], "img": ["Image"] }], + "jsx-a11y/aria-props": "warn", + "jsx-a11y/aria-proptypes": "warn", + "jsx-a11y/aria-unsupported-elements": "warn", + "jsx-a11y/role-has-required-aria-props": "warn", + "jsx-a11y/role-supports-aria-props": "warn", + "jsx-a11y/prefer-tag-over-role": "off", + "jsx-a11y/anchor-is-valid": "off", + "jsx-a11y/no-autofocus": ["error", { "ignoreNonDOM": true }] + }, + "overrides": [ + { + "files": ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], + "rules": { + "constructor-super": "off", + "no-const-assign": "off", + "no-dupe-class-members": "off", + "no-dupe-keys": "off", + "no-func-assign": "off", + "no-import-assign": "off", + "no-new-native-nonconstructor": "off", + "no-obj-calls": "off", + "no-redeclare": "off", + "no-setter-return": "off", + "no-this-before-super": "off", + "no-unsafe-negation": "off", + "no-var": "error", + "prefer-rest-params": "error", + "prefer-spread": "error" + } + } + ] +} diff --git a/management/frontend/.upstream-commit b/management/frontend/.upstream-commit new file mode 100644 index 0000000..cd984f7 --- /dev/null +++ b/management/frontend/.upstream-commit @@ -0,0 +1 @@ +f3375fcce5d2b2fbc35caef776e5a1c61b030439 diff --git a/management/frontend/LICENSE b/management/frontend/LICENSE new file mode 100644 index 0000000..ac58917 --- /dev/null +++ b/management/frontend/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Kiranism + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/management/frontend/README.md b/management/frontend/README.md new file mode 100644 index 0000000..561a442 --- /dev/null +++ b/management/frontend/README.md @@ -0,0 +1,228 @@ +

Admin Dashboard with TanStack Start & Shadcn UI

+ +
Open source admin dashboard starter built with TanStack Start, shadcn/ui, Tailwind CSS, TypeScript
+ +
+ +
+ View Demo +
+
+
+ TanStack Start Dashboard Cover +
+ +

+ GitHub stars + Forks + MIT License + TanStack Start + Vite +

+ +## Overview + +This is an **open source admin dashboard starter** built with **TanStack Start, Shadcn UI, TypeScript, and Tailwind CSS**. + +It gives you a production-ready **dashboard UI** with charts, tables, forms, and a feature-based folder structure, perfect for **SaaS apps, internal tools, and admin panels**. + +### Tech Stack + +| Category | Technology | +| ------------------ | ----------------------------------------------------------------------------------------------------- | +| Framework | [TanStack Start](https://tanstack.com/start) | +| Language | [TypeScript](https://www.typescriptlang.org) | +| Build Tool | [Vite 7](https://vite.dev) | +| Deployment | [Nitro](https://nitro.build) (Vercel, Cloudflare, Node.js) | +| Styling | [Tailwind CSS v4](https://tailwindcss.com) | +| Components | [Shadcn-ui](https://ui.shadcn.com) | +| Routing | [TanStack Router](https://tanstack.com/router) (file-based, type-safe) | +| Data Fetching | [TanStack React Query](https://tanstack.com/query) | +| Tables | [TanStack Table](https://tanstack.com/table) | +| Forms | [TanStack Form](https://tanstack.com/form) + [Zod](https://zod.dev) | +| Charts | [Recharts](https://recharts.org) | +| State Management | [Zustand](https://zustand-demo.pmnd.rs) | +| Command+K | [kbar](https://kbar.vercel.app/) | +| Themes | [tweakcn](https://tweakcn.com/) | +| Linter / Formatter | [OxLint](https://oxc.rs/docs/guide/usage/linter) / [Oxfmt](https://oxc.rs/docs/guide/usage/formatter) | + +_If you are looking for a Next.js dashboard template, here is the [repo](https://git.new/shadcn-dashboard)._ + +## Features + +- **Admin dashboard layout** (sidebar, header, content area) + +- **Analytics overview** page with cards and Suspense-based independent loading + +- **Data tables** with React Query route loaders, client-side cache, search, filter & pagination + +- **Type-safe file-based routing** with TanStack Router (auto-generated route tree) + +- **Server functions** via `createServerFn()` for server-side logic + +- **Infobar component** to show helpful tips, status messages, or contextual info on any page + +- **Shadcn UI components** with Tailwind CSS styling + +- **Multi-theme support** with 10+ beautiful themes and easy theme switching + +- **Feature-based folder structure** for scalable projects + +- **Kanban board** with drag-n-drop (dnd-kit + Zustand) + +- **Chat interface** with conversation list, message bubbles, and auto-reply demo + +- **Notification center** with bell icon badge, popover preview, and full page view + +- **Command palette** (Cmd+K) for quick navigation + +- **Deploy anywhere** — Vercel, Cloudflare, Node.js via Nitro presets + +## Pages + +| Page | Description | +| :-------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------- | +| [Dashboard Overview](/dashboard/overview) | Cards with Recharts graphs. Suspense boundaries for independent loading/error per section. | +| [Product List (Table)](/dashboard/product) | TanStack Table + React Query (route loader prefetch + client cache) with URL search params for search, filter, pagination. | +| [Create Product Form](/dashboard/product/new) | TanStack Form + Zod with `useMutation` for create/update. Cache invalidation on success. | +| [Users (Table)](/dashboard/users) | Users table with React Query + URL state pattern. Same architecture as Products. | +| [React Query Demo](/dashboard/react-query) | Pokemon API showcase demonstrating route loader + `useSuspenseQuery` pattern with client-side cache. | +| [Kanban Board](/dashboard/kanban) | Drag n Drop task management board with dnd-kit and Zustand. Column sorting, task cards with priority badges. | +| [Chat](/dashboard/chat) | Messaging UI with conversation list, message bubbles, quick replies, file attachments. | +| [Notifications](/dashboard/notifications) | Notification center with bell icon badge, popover preview, and dedicated full page with tabs. | +| [Forms](/dashboard/forms/basic) | Basic, Multi-step, Sheet/Dialog, and Advanced form patterns with TanStack Form + Zod. | +| [Not Found](/notfound) | Custom 404 page via TanStack Router's `defaultNotFoundComponent`. | + +## Feature-based Organization + +```plaintext +src/ +├── routes/ # TanStack Router file-based routes +│ ├── __root.tsx # Root layout (providers, theme, HTML shell) +│ ├── index.tsx # Home (auth redirect) +│ ├── auth/ # Auth pages (sign-in, sign-up) +│ ├── dashboard.tsx # Dashboard layout (sidebar, header, KBar) +│ └── dashboard/ # Dashboard pages +│ ├── overview.tsx # Analytics with Suspense boundaries +│ ├── product/ # Product CRUD (route loaders + React Query) +│ ├── users.tsx # Users table (route loaders + React Query) +│ ├── react-query.tsx # React Query demo page +│ ├── kanban.tsx # Task board page +│ ├── chat.tsx # Messaging page +│ ├── notifications.tsx # Notifications page +│ ├── forms/ # Form examples +│ └── elements/ # UI showcase +│ +├── components/ # Shared components +│ ├── ui/ # UI primitives (buttons, inputs, kanban, etc.) +│ ├── layout/ # Layout components (header, sidebar, etc.) +│ ├── themes/ # Theme system (selector, mode toggle, config) +│ └── kbar/ # Command+K interface +│ +├── features/ # Feature-based modules +│ ├── overview/ # Dashboard analytics (charts, cards) +│ ├── products/ # Product listing, form, tables (React Query) +│ ├── users/ # User management table (React Query) +│ ├── react-query-demo/ # React Query demo (Pokemon API) +│ ├── kanban/ # Drag-drop task board +│ ├── chat/ # Messaging (conversations, bubbles, composer) +│ ├── notifications/ # Notification center & store +│ ├── auth/ # Auth components +│ └── forms/ # Form showcases +│ +├── lib/ # Core utilities (query-client, parsers, etc.) +├── hooks/ # Custom hooks (use-data-table, use-media-query, etc.) +├── config/ # Navigation, infobar, data table config +├── constants/ # Mock data +├── styles/ # Global CSS & theme files +│ └── themes/ # Individual theme CSS files (OKLCH) +└── types/ # TypeScript types +``` + +## Getting Started + +> [!NOTE] +> This admin dashboard starter uses **TanStack Start** with **React 19**, **Vite 7**, and **Shadcn UI**. Follow these steps to run it locally: + +Clone the repo: + +```bash +git clone https://github.com/Kiranism/tanstack-start-dashboard.git +cd tanstack-start-dashboard +``` + +Install dependencies and run: + +```bash +bun install +cp env.example.txt .env +bun run dev +``` + +You should now be able to access the application at http://localhost:3000. + +## Deploy + +### Vercel (Recommended) + +The project uses **Nitro** with the `vercel` preset. Just connect your GitHub repo to Vercel: + +- **Build Command:** `bun run build` +- **Output Directory:** leave blank (auto-detected) +- **Framework Preset:** Other + +### Other Platforms + +Change the Nitro preset in `vite.config.ts`: + +```ts +// Cloudflare Pages +nitro({ preset: 'cloudflare-pages' }); + +// Node.js server +nitro({ preset: 'node-server' }); + +// Netlify +nitro({ preset: 'netlify' }); +``` + +Then run locally with: + +```bash +bun run build +bun run start +``` + +## Key Differences from Next.js Version + +| Concept | Next.js | TanStack Start | +| ------------- | --------------------------------------- | ------------------------------------------------ | +| Routing | App Router (`app/`) | File-based (`routes/`) with type-safe params | +| Data Fetching | Server Components + `HydrationBoundary` | Route `loader` + `useSuspenseQuery` | +| Layouts | `layout.tsx` nesting | Layout routes with `` | +| Server Code | `'use server'` actions | `createServerFn()` | +| Build Tool | Webpack/Turbopack | Vite | +| Deployment | `next start` | Nitro (any platform) | +| URL State | nuqs | TanStack Router `useSearch()` + `validateSearch` | + +### Support + +If you find this template helpful, please consider giving it a star! + +[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-support-yellow?style=flat-square&logo=buymeacoffee)](https://buymeacoffee.com/kir4n) + +Cheers! + + diff --git a/management/frontend/bun.lock b/management/frontend/bun.lock new file mode 100644 index 0000000..5b11ee5 --- /dev/null +++ b/management/frontend/bun.lock @@ -0,0 +1,1371 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "tanstack-dashboard", + "dependencies": { + "@base-ui/react": "^1.6.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@fontsource-variable/dm-sans": "^5.1.3", + "@fontsource-variable/fira-code": "^5.1.2", + "@fontsource-variable/instrument-sans": "^5.1.1", + "@fontsource-variable/inter": "^5.1.2", + "@fontsource-variable/jetbrains-mono": "^5.1.3", + "@fontsource-variable/mulish": "^5.1.2", + "@fontsource-variable/noto-sans-mono": "^5.1.2", + "@fontsource-variable/outfit": "^5.1.2", + "@fontsource-variable/playfair-display": "^5.1.2", + "@fontsource/architects-daughter": "^5.0.17", + "@fontsource/geist-mono": "^5.2.7", + "@fontsource/geist-sans": "^5.2.5", + "@fontsource/merriweather": "^5.1.3", + "@fontsource/space-mono": "^5.0.20", + "@hookform/resolvers": "^5.7.1", + "@shadcn/react": "^0.2.1", + "@tailwindcss/vite": "^4.2.2", + "@tanstack/react-form": "^1.28.5", + "@tanstack/react-hotkeys": "^0.10.0", + "@tanstack/react-query": "^5.95.2", + "@tanstack/react-router": "^1.141.1", + "@tanstack/react-router-with-query": "^1.130.17", + "@tanstack/react-start": "^1.141.1", + "@tanstack/react-table": "^8.21.3", + "@tanstack/router-plugin": "^1.141.1", + "@tanstack/zod-adapter": "^1.141.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "embla-carousel-react": "^8.6.0", + "input-otp": "^1.4.2", + "lucide-react": "^1.28.0", + "match-sorter": "^8.2.0", + "motion": "^11.18.2", + "next-themes": "^0.4.6", + "nitro": "^3.0.260415-beta", + "radix-ui": "^1.6.7", + "react": "^19.0.0", + "react-day-picker": "^10.0.1", + "react-dom": "^19.0.0", + "react-dropzone": "^14.4.1", + "react-hook-form": "^7.84.0", + "react-resizable-panels": "^4", + "react-responsive": "^10.0.1", + "recharts": "3.8.0", + "sonner": "^1.7.4", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.2", + "uuid": "^11.1.0", + "vaul": "^1.1.2", + "vite-tsconfig-paths": "^5.1.4", + "zod": "^4.3.6", + "zustand": "^5.0.12", + }, + "devDependencies": { + "@faker-js/faker": "^9.9.0", + "@tabler/icons-react": "^3.40.0", + "@tanstack/react-query-devtools": "^5.95.2", + "@tanstack/react-router-devtools": "^1.141.1", + "@types/node": "^22.12.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@types/uuid": "^10.0.0", + "@vitejs/plugin-react": "^5.0.4", + "oxfmt": "^0.44.0", + "oxlint": "^1.59.0", + "tw-animate-css": "^1.4.0", + "typescript": "^5.7.2", + "vite": "^7.0.2", + }, + }, + }, + "overrides": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + + "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], + + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@base-ui/react": ["@base-ui/react@1.6.0", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@base-ui/utils": "0.3.1", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@date-fns/tz": "^1.2.0", "@types/react": "^17 || ^18 || ^19", "date-fns": "^4.0.0", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@date-fns/tz", "@types/react", "date-fns"] }, "sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw=="], + + "@base-ui/utils": ["@base-ui/utils@0.3.1", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg=="], + + "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], + + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + + "@dnd-kit/modifiers": ["@dnd-kit/modifiers@9.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw=="], + + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + + "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + + "@faker-js/faker": ["@faker-js/faker@9.9.0", "", {}, "sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA=="], + + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + + "@fontsource-variable/dm-sans": ["@fontsource-variable/dm-sans@5.2.8", "", {}, "sha512-AxkvMTvNWgfrmlyjiV05vlHYJa+nRQCf1EfvIrQAPBpFJW0O9VTz7oAFr9S3lvbWdmnFoBk7yFqQL86u64nl2g=="], + + "@fontsource-variable/fira-code": ["@fontsource-variable/fira-code@5.2.7", "", {}, "sha512-J2bxN7fz5rd8WpQYyau4o19WqTzxoTqaNj9jhsv4p21GSu1Rf34tbqsxqjyDCR+wDMHM3SajyFqtq+5uvRUQ7w=="], + + "@fontsource-variable/instrument-sans": ["@fontsource-variable/instrument-sans@5.2.8", "", {}, "sha512-mTCaukbdIjjoipj2E3Q5XoZM3ZxJWdzyHevf/LG/0PHlfF9Q85pxOM7B7A9MerFyxmRzz5kVlumgIvgDSG4CPg=="], + + "@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="], + + "@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.2.8", "", {}, "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q=="], + + "@fontsource-variable/mulish": ["@fontsource-variable/mulish@5.2.8", "", {}, "sha512-WK2SG2nQhQD+9dPOEw6V6tFgs8b5SodnUB5fuPOLj+2HIR2uPlwj+sUHMfMDGkz57us6xhEiJDaxRy8omad/DA=="], + + "@fontsource-variable/noto-sans-mono": ["@fontsource-variable/noto-sans-mono@5.2.10", "", {}, "sha512-N8w7TRt24aD17P4eV9GDNiV4M5FSbeZ91dfWPtlTi5EvH15KAyjGiC7rvXnG3VTo/nb6gQuIXNlTDgH3cz652g=="], + + "@fontsource-variable/outfit": ["@fontsource-variable/outfit@5.2.8", "", {}, "sha512-4oUDCZx/Tcz6HZP423w/niqEH31Gks5IsqHV2ZZz1qKHaVIZdj2f0/S1IK2n8jl6Xo0o3N+3RjNHlV9R73ozQA=="], + + "@fontsource-variable/playfair-display": ["@fontsource-variable/playfair-display@5.2.8", "", {}, "sha512-ZzVIXPOrL85yyOvZYoBzUszIJM+xKkHqni4IYn2CVLaGQQdJR8sBeC8yFNgjxSJ7ludTwta8qpULeOFuk5X75A=="], + + "@fontsource/architects-daughter": ["@fontsource/architects-daughter@5.2.7", "", {}, "sha512-W7tHXduV9kRQZDTqcU4Rnc/GtSq9cYUHOnhvcRPjy87u5x/oRqKXPU2PghqbktTECOIh1N0qVZLt9rwqa+aWhg=="], + + "@fontsource/geist-mono": ["@fontsource/geist-mono@5.2.7", "", {}, "sha512-xVPVFISJg/K0VVd+aQN0Y7X/sw9hUcJPyDWFJ5GpyU3bHELhoRsJkPSRSHXW32mOi0xZCUQDOaPj1sqIFJ1FGg=="], + + "@fontsource/geist-sans": ["@fontsource/geist-sans@5.2.5", "", {}, "sha512-anllOHyJbElRs9fV15TeDRqAeb1IKm4bSknPl6ZMoyPTx1BBy7logudcUwpNjmQLkzn4Q0JGQLRCUKJYoyST6A=="], + + "@fontsource/merriweather": ["@fontsource/merriweather@5.2.11", "", {}, "sha512-ZiIMeUh5iT8d73o6xlSF8GKgjV5pgiFrufYc5jZTVAfExtWKqM2vQHnsqXSFMv4ELhAcjt6Vf+5T3oVGXhAizQ=="], + + "@fontsource/space-mono": ["@fontsource/space-mono@5.2.9", "", {}, "sha512-b61faFOHEISQ/pD25G+cfGY9o/WW6lRv6hBQQfpWvEJ4y1V+S4gmth95EVyBE2VL3qDYHeVQ8nBzrplzdXTDDg=="], + + "@hookform/resolvers": ["@hookform/resolvers@5.7.1", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "@sinclair/typebox": ">=0.25.24", "@standard-schema/spec": "^1.0.0", "@typeschema/main": ">=0.13.7", "@vinejs/vine": "^2.0.0 || ^3.0.0 || ^4.0.0", "ajv": "^8.12.0", "ajv-errors": "^3.0.0", "ajv-formats": "^2.1.1", "arktype": "^2.0.0", "ata-validator": "^1.2.0", "class-transformer": ">=0.4.0", "class-validator": ">=0.12.0", "computed-types": "^1.0.0", "effect": "^3.10.3", "fluentvalidation-ts": "^3.0.0", "fp-ts": "^2.7.0", "io-ts": "^2.0.0", "joi": "^17.0.0", "nope-validator": ">=0.12.0", "react-hook-form": "^7.55.0", "superstruct": ">=0.12.0", "typanion": "^3.3.2", "valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc", "vest": ">=3.0.0", "yup": "^1.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@sinclair/typebox", "@standard-schema/spec", "@typeschema/main", "@vinejs/vine", "ajv", "ajv-errors", "ajv-formats", "arktype", "ata-validator", "class-transformer", "class-validator", "computed-types", "effect", "fluentvalidation-ts", "fp-ts", "io-ts", "joi", "nope-validator", "superstruct", "typanion", "valibot", "vest", "yup", "zod"] }, "sha512-8wS/P4UDr5sQDe4nFaV51TVyfDPrWgNIXweqG0Bs9Z5LSuzKLb+RQNPvkN2oHM5SRrJyWrVH/F+LOUcFjUyvwQ=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + + "@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="], + + "@oozcitak/infra": ["@oozcitak/infra@2.0.2", "", { "dependencies": { "@oozcitak/util": "^10.0.0" } }, "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA=="], + + "@oozcitak/url": ["@oozcitak/url@3.0.0", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/util": "^10.0.0" } }, "sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ=="], + + "@oozcitak/util": ["@oozcitak/util@10.0.0", "", {}, "sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA=="], + + "@oxc-project/types": ["@oxc-project/types@0.126.0", "", {}, "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ=="], + + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.44.0", "", { "os": "android", "cpu": "arm" }, "sha512-5UvghMd9SA/yvKTWCAxMAPXS1d2i054UeOf4iFjZjfayTwCINcC3oaSXjtbZfCaEpxgJod7XiOjTtby5yEv/BQ=="], + + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.44.0", "", { "os": "android", "cpu": "arm64" }, "sha512-IVudM1BWfvrYO++Khtzr8q9n5Rxu7msUvoFMqzGJVdX7HfUXUDHwaH2zHZNB58svx2J56pmCUzophyaPFkcG/A=="], + + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.44.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eWCLAIKAHfx88EqEP1Ga2yz7qVcqDU5lemn4xck+07bH182hDdprOHjbogyk0In1Djys3T0/pO2JepFnRJ41Mg=="], + + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.44.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-eHTBznHLM49++dwz07MblQ2cOXyIgeedmE3Wgy4ptUESj38/qYZyRi1MPwC9olQJWssMeY6WI3UZ7YmU5ggvyQ=="], + + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.44.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jLMmbj0u0Ft43QpkUVr/0v1ZfQCGWAvU+WznEHcN3wZC/q6ox7XeSJtk9P36CCpiDSUf3sGnzbIuG1KdEMEDJQ=="], + + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.44.0", "", { "os": "linux", "cpu": "arm" }, "sha512-n+A/u/ByK1qV8FVGOwyaSpw5NPNl0qlZfgTBqHeGIqr8Qzq1tyWZ4lAaxPoe5mZqE3w88vn3+jZtMxriHPE7tg=="], + + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.44.0", "", { "os": "linux", "cpu": "arm" }, "sha512-5eax+FkxyCqAi3Rw0mrZFr7+KTt/XweFsbALR+B5ljWBLBl8nHe4ADrUnb1gLEfQCJLl+Ca5FIVD4xEt95AwIw=="], + + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.44.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-58l8JaHxSGOmOMOG2CIrNsnkRJAj0YcHQCmvNACniOa/vd1iRHhlPajczegzS5jwMENlqgreyiTR9iNlke8qCw=="], + + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.44.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-AlObQIXyVRZ96LbtVljtFq0JqH5B92NU+BQeDFrXWBUWlCKAM0wF5GLfIhCLT5kQ3Sl+U0YjRJ7Alqj5hGQaCg=="], + + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.44.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-YcFE8/q/BbrCiIiM5piwbkA6GwJc5QqhMQp2yDrqQ2fuVkZ7CInb1aIijZ/k8EXc72qXMSwKpVlBv1w/MsGO/A=="], + + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.44.0", "", { "os": "linux", "cpu": "none" }, "sha512-eOdzs6RqkRzuqNHUX5C8ISN5xfGh4xDww8OEd9YAmc3OWN8oAe5bmlIqQ+rrHLpv58/0BuU48bxkhnIGjA/ATQ=="], + + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.44.0", "", { "os": "linux", "cpu": "none" }, "sha512-YBgNTxntD/QvlFUfgvh8bEdwOhXiquX8gaofZJAwYa/Xp1S1DQrFVZEeck7GFktr24DztsSp8N8WtWCBwxs0Hw=="], + + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.44.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-GLIh1R6WHWshl/i4QQDNgj0WtT25aRO4HNUWEoitxiywyRdhTFmFEYT2rXlcl9U6/26vhmOqG5cRlMLG3ocaIA=="], + + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.44.0", "", { "os": "linux", "cpu": "x64" }, "sha512-gZOpgTlOsLcLfAF9qgpTr7FIIFSKnQN3hDf/0JvQ4CIwMY7h+eilNjxq/CorqvYcEOu+LRt1W4ZS7KccEHLOdA=="], + + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.44.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1CyS9JTB+pCUFYFI6pkQGGZaT/AY5gnhHVrQQLhFba6idP9AzVYm1xbdWfywoldTYvjxQJV6x4SuduCIfP3W+A=="], + + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.44.0", "", { "os": "none", "cpu": "arm64" }, "sha512-bmEv70Ak6jLr1xotCbF5TxIKjsmQaiX+jFRtnGtfA03tJPf6VG3cKh96S21boAt3JZc+Vjx8PYcDuLj39vM2Pw=="], + + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.44.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-yWzB+oCpSnP/dmw85eFLAT5o35Ve5pkGS2uF/UCISpIwDqf1xa7OpmtomiqY/Vzg8VyvMbuf6vroF2khF/+1Vg=="], + + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.44.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-TcWpo18xEIE3AmIG2kpr3kz5IEhQgnx0lazl2+8L+3eTopOAUevQcmlr4nhguImNWz0OMeOZrYZOhJNCf16nlQ=="], + + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.44.0", "", { "os": "win32", "cpu": "x64" }, "sha512-oj8aLkPJZppIM4CMQNsyir9ybM1Xw/CfGPTSsTnzpVGyljgfbdP0EVUlURiGM0BDrmw5psQ6ArmGCcUY/yABaQ=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-etYDw/UaEv936AQUd/CRMBVd+e+XuuU6wC+VzOv1STvsTyZenLChepLWqLtnyTTp4YMlM22ypzogDDwqYxv5cg=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TgLc7XVLKH2a4h8j3vn1MDjfK33i9MY60f/bKhRGWyVzbk5LCZ4X01VZG7iHrMmi5vYbAp8//Ponigx03CLsdw=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DXyFPf5ZKldMLloRHx/B9fsxsiTQomaw7cmEW3YIJko2HgCh+GUhp9gGYwHrqlLJPsEe3dYj9JebjX92D3j3AA=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-LgvrsdgVLX1qWqIEmNsSmMXJhpAWdtUQ0M+oR0CySwi+9IHWyOGuIL8w8+u/kbZNMyZr4WUyYB5i0+D+AKgkLg=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-bOJhqX/ny4hrFuTPlyk8foSRx/vLRpxJh0jOOKN2NWW6FScXHPAA5rQbrwdQPcgGB5V8Ua51RS03fke8ssBcug=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-vVUXxYMF9trXCsz4m9H6U0IjehosVHxBzVgJUxly1uz4W1PdDyicaBnpC0KRXsHYretLVe+uS9pJy8iM57Kujw=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-TULQW8YBPGRWg5yZpFPL54HLOnJ3/HiX6VenDPi6YfxB/jlItwSMFh3/hCeSNbh+DAMaE1Py0j5MOaivHkI/9Q=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Gt54Y4eqSgYJ90xipm24xeyaPV854706o/kiT8oZvUt3VDY7qqxdqyGqchMaujd87ib+/MXvnl9WkK8Cc1BExg=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-3CtsKp7NFB3OfqQzbuAecrY7GIZeiv7AD+xutU4tefVQzlfmTI7/ygWLrvkzsDEjTlMq41rYHxgsn6Yh8tybmA=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-K0diOpT3ncDmOfl9I1HuvpEsAuTxkts0VYwIv/w6Xiy9CdwyPBVX88Ga9l8VlGgMrwBMnSY4xIvVlVY/fkQk7Q=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-xAU7+QDU6kTJJ7mJLOGgo7oOjtAtkKyFZ0Yjdb5cEo3DiCCPFLvyr08rWiQh6evZ7RiUTf+o65NY/bqttzJiQQ=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-KUmZmKlTTyauOnvUNVxK7G40sSSx0+w5l1UhaGsC6KPpOYHenx2oqJTnabmpLJicok7IC+3Y6fXAUOMyexaeJQ=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-4usRxC8gS0PGdkHnRmwJt/4zrQNZyk6vL0trCxwZSsAKM+OxhB8nKiR+mhjdBbl8lbMh2gc3bZpNN/ik8c4c2A=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-s/rNE2gDmbwAOOP493xk2X7M8LZfI1LJFSSW1+yanz3vuQCFPiHkx4GY+O1HuLUDtkzGlhtMrIcxxzyYLv308w=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-+yYj1udJa2UvvIUmEm0IcKgc0UlPMgz0nsSTvkPL2y6n0uU5LgIHSwVu4AHhrve6j9BpVSoRksnz8c9QcvITJA=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-bUplUb48LYsB3hHlQXP2ZMOenpieWoOyppLAnnAhuPag3MGPnt+7caxE3w/Vl9wpQsTA3gzLntQi9rxWrs7Xqg=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-/HLsLuz42rWl7h7ePdmMTpHm2HIDmPtcEMYgm5BBEHiEiuNOrzMaUpd2z7UnNni5LGN9obJy2YoAYBLXQwazrA=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-rUPy+JnanpPwV/aJCPnxAD1fW50+XPI0VkWr7f0vEbqcdsS8NpB24Rw6RsS7SdpFv8Dw+8ugCwao5nCFbqOUSg=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-xkE7puteDS/vUyRngLXW0t8WgdWoS/tfxXjhP/P7SMqPDx+hs44SpssO3h3qmTqECYEuXBUPzcAw5257Ka+ofA=="], + + "@radix-ui/number": ["@radix-ui/number@1.1.3", "", {}, "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA=="], + + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="], + + "@radix-ui/react-accessible-icon": ["@radix-ui/react-accessible-icon@1.1.15", "", { "dependencies": { "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-WTQwcAvQf5sOcuUyi90lKPbhwcvQ+j55cjrSmeaN+L2vKU3DooOvlKw2MDeiJ5IkV5N905KW0/fGojKOBhD11A=="], + + "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.20", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collapsible": "1.1.20", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jDhG9FvAEnlhnjrsINbNXcUa4G+L1KqSkJSunkbKEzFRcAb52jvM0PjPxPRvhe1HNc5F5yc0yzzWeeqlH4yBIg=="], + + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dialog": "1.1.23", "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA=="], + + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA=="], + + "@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-fy+dyVR+90nelK8rqIznFlxzx7uPcGbhxH8Nfr2bHb4UfSe+e3hklOC0luK0hDwVwnRX7xTRySpsrQVeW+/oNQ=="], + + "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA=="], + + "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ=="], + + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.20", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q=="], + + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.15", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="], + + "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.3.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-menu": "2.1.24", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-CtXP35dxaB5T3zXSd+E3uHe/QpXcpYnZmxp6OaIbfthtfW4wyb77M23BG+bwIJDtsMwEP/YssdsmNyZu7jhWew=="], + + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], + + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg=="], + + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w=="], + + "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.24", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-menu": "2.1.24", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g=="], + + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.6", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ=="], + + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.16", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ=="], + + "@radix-ui/react-form": ["@radix-ui/react-form@0.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-label": "2.1.15", "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Q4TLEn2A7TAypxwmd6R9EwrlXDvkfYSDMrq9/887AXAGh+G1rH+kYJKSTv+Si9Y0JPKTwKYv6PviAJosysNimA=="], + + "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-H8qONfZd3ltrU3+jHCIgITbWo6e1iTKvP9DHdrvYbX48ooRM5FjEDTn16AMwdfuOGkWdZEhpl3PLL/Wk/AnHDQ=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + + "@radix-ui/react-label": ["@radix-ui/react-label@2.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g=="], + + "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.24", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-callback-ref": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA=="], + + "@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.24", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-menu": "2.1.24", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-eeVs0vf7cuqXaM0qLQCPcufImiJNVBXdJDLu7ZGYl2732UH23Qat/foNGrr6vYV3/DdTsBqASoggUFgH14OcZA=="], + + "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.22", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ou7iLEJ+yrhQndkkA4U21XIdS/CS45F4iXIkTZcb6/Ne9EMsOuDudVmCwmDnfFZZ+y1FZqXRNSIgBy+YMvZVZg=="], + + "@radix-ui/react-one-time-password-field": ["@radix-ui/react-one-time-password-field@0.1.16", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Tj9P6ntAJEw52oq/F0AGknXR4XncxEt7XU47O3xJQOiWfLzEy3d9gtgKfvjSzGxzHkfL+VzvxGu2KTFsloJqXw=="], + + "@radix-ui/react-password-toggle-field": ["@radix-ui/react-password-toggle-field@0.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-is-hydrated": "0.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4gvFnmDXu3dgj21CqsufzIameRvlRd4SBqaWhcrlrNhRo0Y5i/49AmRJYe1fdAM3G2VNBbmin4b0D6cdQocwgw=="], + + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ=="], + + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-rect": "1.1.4", "@radix-ui/react-use-size": "1.1.4", "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.17", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.10", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.16", "", { "dependencies": { "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg=="], + + "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.4.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-cgYFEkntCxppHZgtSZ+7vh0wbZQ+IC7PPMw8DSnRG27B6kDd32/Zw0OJt7dGDigCoprMuWHjg2PvUn3PYvPFoQ=="], + + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ=="], + + "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.18", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA=="], + + "@radix-ui/react-select": ["@radix-ui/react-select@2.3.7", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg=="], + + "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw=="], + + "@radix-ui/react-slider": ["@radix-ui/react-slider@1.4.7", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mTSLf1GC/C0moWjTbvCM6Qn/gBjvlFt1azuWF2v7MN5C3Zq2U2J2lN3ZEYkpujuOU5Ro7A28wkviSxaKnG0BYg=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="], + + "@radix-ui/react-switch": ["@radix-ui/react-switch@1.3.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw=="], + + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.21", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog=="], + + "@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ofhyAsYaocRGOs/n0XWdUOSVzEAG6BfrMVM8z0c0kLEWY38w/0WuMFPTJP/HVaZPYkMvHZoKIIhNcjbTCBILPg=="], + + "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug=="], + + "@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-toggle": "1.1.18", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA=="], + + "@radix-ui/react-toolbar": ["@radix-ui/react-toolbar@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-separator": "1.1.15", "@radix-ui/react-toggle-group": "1.1.19" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ph0IvtYw4VB12ZnZg+YtrGs8yJQsnizwo/zu0R4Y/nWugtJzA7Pg1eWeuDR9+LSqn+xjamss+UOSOJJJ4gx8jw=="], + + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg=="], + + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.5", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg=="], + + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.5", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ge3ipobwSXTj4JyVtswQ7qZj0ZHdtbGuOno/LrgAAeSxtsJ6Vs4Gz5IkPH2bmqpjcLUFoqGhA/mueuIf63UXlA=="], + + "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="], + + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg=="], + + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.4", "", { "dependencies": { "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ=="], + + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw=="], + + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ=="], + + "@radix-ui/rect": ["@radix-ui/rect@1.1.3", "", {}, "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw=="], + + "@reduxjs/toolkit": ["@reduxjs/toolkit@2.12.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.16", "", { "os": "android", "cpu": "arm64" }, "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.16", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm" }, "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "s390x" }, "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.16", "", { "os": "none", "cpu": "arm64" }, "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.16", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "x64" }, "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="], + + "@shadcn/react": ["@shadcn/react@0.2.1", "", { "peerDependencies": { "@types/react": ">=19", "react": ">=19" }, "optionalPeers": ["@types/react", "react"] }, "sha512-5krgi3dRMKb5jH6a+qPzVJUy/54s0kKE4Rw4LjDfLqOdVQTWKUgxWf1kW8r912I0jX/Lzxqc+pgjkjWxUIK5BQ=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], + + "@tabler/icons": ["@tabler/icons@3.41.1", "", {}, "sha512-OaRnVbRmH2nHtFeg+RmMJ/7m2oBIF9XCJAUD5gQnMrpK9f05ydj8MZrAf3NZQqOXyxGN1UBL0D5IKLLEUfr74Q=="], + + "@tabler/icons-react": ["@tabler/icons-react@3.41.1", "", { "dependencies": { "@tabler/icons": "3.41.1" }, "peerDependencies": { "react": ">= 16" } }, "sha512-kUgweE+DJtAlMZVIns1FTDdcbpRVnkK7ZpUOXmoxy3JAF0rSHj0TcP4VHF14+gMJGnF+psH2Zt26BLT6owetBA=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="], + + "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.3", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw=="], + + "@tanstack/form-core": ["@tanstack/form-core@1.28.6", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.1", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.9.1" } }, "sha512-4zroxL6VDj5O+w7l3dYZnUeL/h30KtNSV7UWzKAL7cl+8clMFdISPDlDlluS37As7oqvPVKo8B83VlIBvgmRog=="], + + "@tanstack/history": ["@tanstack/history@1.161.6", "", {}, "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg=="], + + "@tanstack/hotkeys": ["@tanstack/hotkeys@0.8.0", "", { "dependencies": { "@tanstack/store": "^0.11.0" } }, "sha512-vqH7X9nb0MTJ/O08++dB5bP9jgj4+BIPOUu/U+6myG86lDsirZSVSobpq5UQpE7nBuk62i8eIYeOhd+OMl/UrA=="], + + "@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.1.1", "", {}, "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w=="], + + "@tanstack/query-core": ["@tanstack/query-core@5.96.2", "", {}, "sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA=="], + + "@tanstack/query-devtools": ["@tanstack/query-devtools@5.96.2", "", {}, "sha512-vBTB1Qhbm3nHSbEUtQwks/EdcAtFfEapr1WyBW4w2ExYKuXVi3jIxUIHf5MlSltiHuL7zNyUuanqT/7sI2sb6g=="], + + "@tanstack/react-form": ["@tanstack/react-form@1.28.6", "", { "dependencies": { "@tanstack/form-core": "1.28.6", "@tanstack/react-store": "^0.9.1" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dRxwKeNW3uuJvf0sXsIQ2compFMnIJNk9B436Lx0fqkqK+CBvA1tNmEdX+faoCpuQ5Wua3c8ahVibJ65cpkijA=="], + + "@tanstack/react-hotkeys": ["@tanstack/react-hotkeys@0.10.0", "", { "dependencies": { "@tanstack/hotkeys": "0.8.0", "@tanstack/react-store": "^0.11.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-GwOSndI5j3qBVYTmgP1mYyRTnlxb2MS17cwGlsavSxMQPSnmDf+m3LzMIpRMs+3zzQMjg3cYhHsFYizYlFI2tw=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.96.2", "", { "dependencies": { "@tanstack/query-core": "5.96.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-sYyzzJT4G0g02azzJ8o55VFFV31XvFpdUpG+unxS0vSaYsJnSPKGoI6WdPwUucJL1wpgGfwfmntNX/Ub1uOViA=="], + + "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.96.2", "", { "dependencies": { "@tanstack/query-devtools": "5.96.2" }, "peerDependencies": { "@tanstack/react-query": "^5.96.2", "react": "^18 || ^19" } }, "sha512-nTFKLGuTOFvmFRvcyZ3ArWC/DnMNPoBh6h/2yD6rsf7TCTJCQt+oUWOp2uKPTIuEPtF/vN9Kw5tl5mD1Kbposw=="], + + "@tanstack/react-router": ["@tanstack/react-router@1.168.10", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.168.9", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-/RmDlOwDkCug609KdPB3U+U1zmrtadJpvsmRg2zEn8TRCKRNri7dYZIjQZbNg8PgUiRL4T6njrZBV1ChzblNaA=="], + + "@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.166.11", "", { "dependencies": { "@tanstack/router-devtools-core": "1.167.1" }, "peerDependencies": { "@tanstack/react-router": "^1.168.2", "@tanstack/router-core": "^1.168.2", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-WYR3q4Xui5yPT/5PXtQh8i03iUA7q8dONBjWpV3nsGdM8Cs1FxpfhLstW0wZO1dOvSyElscwTRCJ6nO5N8r3Lg=="], + + "@tanstack/react-router-with-query": ["@tanstack/react-router-with-query@1.130.17", "", { "peerDependencies": { "@tanstack/react-query": ">=5.49.2", "@tanstack/react-router": ">=1.43.2", "@tanstack/router-core": ">=1.114.7", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-TNaSocW20KuPwUojEm130DLWTr9M5hsSzxiu4QqS2jNCnrGLuDrwMHyP+6fq13lG3YuU4u9O1qajxfJIGomZCg=="], + + "@tanstack/react-start": ["@tanstack/react-start@1.167.16", "", { "dependencies": { "@tanstack/react-router": "1.168.10", "@tanstack/react-start-client": "1.166.25", "@tanstack/react-start-server": "1.166.25", "@tanstack/router-utils": "^1.161.6", "@tanstack/start-client-core": "1.167.9", "@tanstack/start-plugin-core": "1.167.17", "@tanstack/start-server-core": "1.167.9", "pathe": "^2.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" }, "bin": { "intent": "bin/intent.js" } }, "sha512-vHIhn+FTWfAVhRus1BZEaBZPhnYL+StDuMlShslIBPEGGTCRt11BxNUfV/iDpr7zbxw36Snj7zGfI7DwfjjlDQ=="], + + "@tanstack/react-start-client": ["@tanstack/react-start-client@1.166.25", "", { "dependencies": { "@tanstack/react-router": "1.168.10", "@tanstack/router-core": "1.168.9", "@tanstack/start-client-core": "1.167.9" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-FvD279zzneUtsfhaTv2c29qhE1Z3wHy3dt3cCjn9LzWZehOgn5Ij78s0YpmQaQ8lSF3YL7CySE3pDk9XHE6YeA=="], + + "@tanstack/react-start-server": ["@tanstack/react-start-server@1.166.25", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-router": "1.168.10", "@tanstack/router-core": "1.168.9", "@tanstack/start-client-core": "1.167.9", "@tanstack/start-server-core": "1.167.9" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-bPLADxlplvcnAcnZvBjJl2MzgUnB85d7Mu5aEkYoOFxhz0WiG6mZp7BDadIJuCd33NYMirsd3XrjfCHNzrMTyg=="], + + "@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="], + + "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], + + "@tanstack/router-core": ["@tanstack/router-core@1.168.9", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2" }, "bin": { "intent": "bin/intent.js" } }, "sha512-18oeEwEDyXOIuO1VBP9ACaK7tYHZUjynGDCoUh/5c/BNhia9vCJCp9O0LfhZXOorDc/PmLSgvmweFhVmIxF10g=="], + + "@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.167.1", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16" }, "peerDependencies": { "@tanstack/router-core": "^1.168.2", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-ECMM47J4KmifUvJguGituSiBpfN8SyCUEoxQks5RY09hpIBfR2eswCv2e6cJimjkKwBQXOVTPkTUk/yRvER+9w=="], + + "@tanstack/router-generator": ["@tanstack/router-generator@1.166.24", "", { "dependencies": { "@tanstack/router-core": "1.168.9", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-vdaGKwuH+r+DPe6R1mjk+TDDmDH6NTG7QqwxHqGEvOH4aGf9sPjhmRKNJZqQr8cPIbfp6u5lXyZ1TeDcSNMVEA=="], + + "@tanstack/router-plugin": ["@tanstack/router-plugin@1.167.12", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.168.9", "@tanstack/router-generator": "1.166.24", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.168.10", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"], "bin": { "intent": "bin/intent.js" } }, "sha512-StEHcctCuFI5taSjO+lhR/yQ+EK63BdyYa+ne6FoNQPB3MMrOUrz2ZVnbqILRLkh2b+p2EfBKt65sgAKdKygPQ=="], + + "@tanstack/router-utils": ["@tanstack/router-utils@1.161.6", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-nRcYw+w2OEgK6VfjirYvGyPLOK+tZQz1jkYcmH5AjMamQ9PycnlxZF2aEZtPpNoUsaceX2bHptn6Ub5hGXqNvw=="], + + "@tanstack/start-client-core": ["@tanstack/start-client-core@1.167.9", "", { "dependencies": { "@tanstack/router-core": "1.168.9", "@tanstack/start-fn-stubs": "1.161.6", "@tanstack/start-storage-context": "1.166.23", "seroval": "^1.4.2" }, "bin": { "intent": "bin/intent.js" } }, "sha512-2ETQO/bxiZGsoTdPxZb7xR8YqCy5l4kv/QPkwIXuvx/A4BjufngXfgISjXUicXsFRIBZeiFnBzp9A38UMsS2iA=="], + + "@tanstack/start-fn-stubs": ["@tanstack/start-fn-stubs@1.161.6", "", {}, "sha512-Y6QSlGiLga8cHfvxGGaonXIlt2bIUTVdH6AMjmpMp7+ANNCp+N96GQbjjhLye3JkaxDfP68x5iZA8NK4imgRig=="], + + "@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.167.17", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.28.5", "@babel/types": "^7.28.5", "@rolldown/pluginutils": "1.0.0-beta.40", "@tanstack/router-core": "1.168.9", "@tanstack/router-generator": "1.166.24", "@tanstack/router-plugin": "1.167.12", "@tanstack/router-utils": "1.161.6", "@tanstack/start-client-core": "1.167.9", "@tanstack/start-server-core": "1.167.9", "cheerio": "^1.0.0", "exsolve": "^1.0.7", "pathe": "^2.0.3", "picomatch": "^4.0.3", "source-map": "^0.7.6", "srvx": "^0.11.9", "tinyglobby": "^0.2.15", "ufo": "^1.5.4", "vitefu": "^1.1.1", "xmlbuilder2": "^4.0.3", "zod": "^3.24.2" }, "peerDependencies": { "vite": ">=7.0.0" } }, "sha512-OkorpOobGOEDVr72QUmkzKjbawKC05CSz+1B3OObB/AxBIIw+lLLhTXbV45QkX2LZA7dcRvPJYZGOH1pkFqA1g=="], + + "@tanstack/start-server-core": ["@tanstack/start-server-core@1.167.9", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/router-core": "1.168.9", "@tanstack/start-client-core": "1.167.9", "@tanstack/start-storage-context": "1.166.23", "h3-v2": "npm:h3@2.0.1-rc.16", "seroval": "^1.4.2" }, "bin": { "intent": "bin/intent.js" } }, "sha512-vKkslQIihoDDVumF73VXT7PVFmN7Nea0nKhZx7gMbc0m09yPQYYR1dn86/dz14k6/7cDkJ+qKXa09rlVlN/i9Q=="], + + "@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.166.23", "", { "dependencies": { "@tanstack/router-core": "1.168.9" } }, "sha512-3vEdiYRMx+r+Q7Xqxj3YmADPIpMm7fkKxDa8ITwodGXiw+SBJCGkpBXGUWjOXyXkIyqGHKM5UrReTcVUTkmaug=="], + + "@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], + + "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], + + "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.7", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ=="], + + "@tanstack/zod-adapter": ["@tanstack/zod-adapter@1.166.9", "", { "peerDependencies": { "@tanstack/react-router": ">=1.43.2", "zod": "^3.23.8" } }, "sha512-HHllQ/CKGi8YBbftv6OmzojtHM6Rk4UszAFICAgUMbwiqtKqjlIZQ/7mv2IPNxBb8YlOQgzyQ4jz2UTEXIi6YA=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], + + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + + "@types/uuid": ["@types/uuid@10.0.0", "", {}, "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], + + "attr-accept": ["attr-accept@2.2.5", "", {}, "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ=="], + + "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.16", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA=="], + + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001786", "", {}, "sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA=="], + + "cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="], + + "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], + + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], + + "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie-es": ["cookie-es@2.0.1", "", {}, "sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA=="], + + "crossws": ["crossws@0.4.5", "", { "peerDependencies": { "srvx": ">=0.11.5" }, "optionalPeers": ["srvx"] }, "sha512-wUR89x/Rw7/8t+vn0CmGDYM9TD6VtARGb0LD5jq2wjtMy1vCP4M+sm6N6TigWeTYvnA8MoW29NqqXD0ep0rfBA=="], + + "css-mediaquery": ["css-mediaquery@0.1.2", "", {}, "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q=="], + + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + + "db0": ["db0@0.3.4", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + + "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.332", "", {}, "sha512-7OOtytmh/rINMLwaFTbcMVvYXO3AUm029X0LcyfYk0B557RlPkdpTpnH9+htMlfu5dKwOmT0+Zs2Aw+lnn6TeQ=="], + + "embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="], + + "embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="], + + "embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="], + + "encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="], + + "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "env-runner": ["env-runner@0.1.7", "", { "dependencies": { "crossws": "^0.4.4", "exsolve": "^1.0.8", "httpxy": "^0.5.0", "srvx": "^0.11.13" }, "peerDependencies": { "@netlify/runtime": "^4", "miniflare": "^4.20260317.3" }, "optionalPeers": ["@netlify/runtime", "miniflare"], "bin": { "env-runner": "dist/cli.mjs" } }, "sha512-i7h96jxETJYhXy5grgHNJ9xNzCzWIn9Ck/VkkYgOlE4gOqknsLX3CmlVb5LmwNex8sOoLFVZLz+TIw/+b5rktA=="], + + "es-toolkit": ["es-toolkit@1.50.0", "", {}, "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w=="], + + "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + + "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-selector": ["file-selector@2.1.2", "", { "dependencies": { "tslib": "^2.7.0" } }, "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "framer-motion": ["framer-motion@11.18.2", "", { "dependencies": { "motion-dom": "^11.18.1", "motion-utils": "^11.18.1", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + + "get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="], + + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], + + "goober": ["goober@2.1.18", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "h3": ["h3@2.0.1-rc.20", "", { "dependencies": { "rou3": "^0.8.1", "srvx": "^0.11.13" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"], "bin": { "h3": "bin/h3.mjs" } }, "sha512-28ljodXuUp0fZovdiSRq4G9OgrxCztrJe5VdYzXAB7ueRvI7pIUqLU14Xi3XqdYJ/khXjfpUOOD2EQa6CmBgsg=="], + + "h3-v2": ["h3@2.0.1-rc.16", "", { "dependencies": { "rou3": "^0.8.0", "srvx": "^0.11.9" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"], "bin": { "h3": "bin/h3.mjs" } }, "sha512-h+pjvyujdo9way8qj6FUbhaQcHlR8FEq65EhTX9ViT5pK8aLj68uFl4hBkF+hsTJAH+H1END2Yv6hTIsabGfag=="], + + "hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="], + + "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], + + "httpxy": ["httpxy@0.5.0", "", {}, "sha512-qwX7QX/rK2visT10/b7bSeZWQOMlSm3svTD0pZpU+vJjNUP0YHtNv4c3z+MO+MSnGuRFWJFdCZiV+7F7dXIOzg=="], + + "hyphenate-style-name": ["hyphenate-style-name@1.1.0", "", {}, "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], + + "input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="], + + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "isbot": ["isbot@5.1.37", "", {}, "sha512-5bcicX81xf6NlTEV8rWdg7Pk01LFizDetuYGHx6d/f6y3lR2/oo8IfxjzJqn1UdDEyCcwT9e7NRloj8DwCYujQ=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "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" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "lucide-react": ["lucide-react@1.28.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "match-sorter": ["match-sorter@8.2.0", "", { "dependencies": { "@babel/runtime": "^7.23.8", "remove-accents": "0.5.0" } }, "sha512-qRVB7wYMJXizAWR4TKo5UYwgW7oAVzA8V9jve0wGzRvV91ou9dcqL+/2gJtD0PZ/Pm2Fq6cVT4VHXHmDFVMGRA=="], + + "matchmediaquery": ["matchmediaquery@0.4.2", "", { "dependencies": { "css-mediaquery": "^0.1.2" } }, "sha512-wrZpoT50ehYOudhDjt/YvUJc6eUzcdFPdmbizfgvswCKNHD1/OBOHYJpHie+HXpu6bSkEGieFMYk6VuutaiRfA=="], + + "motion": ["motion@11.18.2", "", { "dependencies": { "framer-motion": "^11.18.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-JLjvFDuFr42NFtcVoMAyC2sEjnpA8xpy6qWPyzQvCloznAyQ8FIXioxWfHiLtgYhoVpfUqSWpn1h9++skj9+Wg=="], + + "motion-dom": ["motion-dom@11.18.1", "", { "dependencies": { "motion-utils": "^11.18.1" } }, "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw=="], + + "motion-utils": ["motion-utils@11.18.1", "", {}, "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], + + "nf3": ["nf3@0.3.16", "", {}, "sha512-Gs0xRPpUm2nDkqbi40NJ9g7qDIcjcJzgExiydnq6LAyqhI2jfno8wG3NKTL+IiJsx799UHOb1CnSd4Wg4SG4Pw=="], + + "nitro": ["nitro@3.0.260415-beta", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.5", "db0": "^0.3.4", "env-runner": "^0.1.7", "h3": "^2.0.1-rc.20", "hookable": "^6.1.1", "nf3": "^0.3.16", "ocache": "^0.1.4", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "rolldown": "^1.0.0-rc.15", "srvx": "^0.11.15", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.7" }, "peerDependencies": { "@vercel/queue": "^0.1.4", "dotenv": "*", "giget": "*", "jiti": "^2.6.1", "rollup": "^4.60.1", "vite": "^7 || ^8", "xml2js": "^0.6.2", "zephyr-agent": "^0.2.0" }, "optionalPeers": ["@vercel/queue", "dotenv", "giget", "jiti", "rollup", "vite", "xml2js", "zephyr-agent"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-J0ntJERWtIdvweZdmkCiF8eOFvP9fIAJR2gpeIDrHbAlYavK41WQfADo/YoZ/LF7RMTZBiPaH/pt2s/nPru9Iw=="], + + "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "ocache": ["ocache@0.1.4", "", { "dependencies": { "ohash": "^2.0.11" } }, "sha512-e7geNdWjxSnvsSgvLuPvgKgu7ubM10ZmTPOgpr7mz2BXYtvjMKTiLhjFi/gWU8chkuP6hNkZBsa9LzOusyaqkQ=="], + + "ofetch": ["ofetch@2.0.0-alpha.3", "", {}, "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA=="], + + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + + "oxfmt": ["oxfmt@0.44.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.44.0", "@oxfmt/binding-android-arm64": "0.44.0", "@oxfmt/binding-darwin-arm64": "0.44.0", "@oxfmt/binding-darwin-x64": "0.44.0", "@oxfmt/binding-freebsd-x64": "0.44.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.44.0", "@oxfmt/binding-linux-arm-musleabihf": "0.44.0", "@oxfmt/binding-linux-arm64-gnu": "0.44.0", "@oxfmt/binding-linux-arm64-musl": "0.44.0", "@oxfmt/binding-linux-ppc64-gnu": "0.44.0", "@oxfmt/binding-linux-riscv64-gnu": "0.44.0", "@oxfmt/binding-linux-riscv64-musl": "0.44.0", "@oxfmt/binding-linux-s390x-gnu": "0.44.0", "@oxfmt/binding-linux-x64-gnu": "0.44.0", "@oxfmt/binding-linux-x64-musl": "0.44.0", "@oxfmt/binding-openharmony-arm64": "0.44.0", "@oxfmt/binding-win32-arm64-msvc": "0.44.0", "@oxfmt/binding-win32-ia32-msvc": "0.44.0", "@oxfmt/binding-win32-x64-msvc": "0.44.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-lnncqvHewyRvaqdrnntVIrZV2tEddz8lbvPsQzG/zlkfvgZkwy0HP1p/2u1aCDToeg1jb9zBpbJdfkV73Itw+w=="], + + "oxlint": ["oxlint@1.59.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.59.0", "@oxlint/binding-android-arm64": "1.59.0", "@oxlint/binding-darwin-arm64": "1.59.0", "@oxlint/binding-darwin-x64": "1.59.0", "@oxlint/binding-freebsd-x64": "1.59.0", "@oxlint/binding-linux-arm-gnueabihf": "1.59.0", "@oxlint/binding-linux-arm-musleabihf": "1.59.0", "@oxlint/binding-linux-arm64-gnu": "1.59.0", "@oxlint/binding-linux-arm64-musl": "1.59.0", "@oxlint/binding-linux-ppc64-gnu": "1.59.0", "@oxlint/binding-linux-riscv64-gnu": "1.59.0", "@oxlint/binding-linux-riscv64-musl": "1.59.0", "@oxlint/binding-linux-s390x-gnu": "1.59.0", "@oxlint/binding-linux-x64-gnu": "1.59.0", "@oxlint/binding-linux-x64-musl": "1.59.0", "@oxlint/binding-openharmony-arm64": "1.59.0", "@oxlint/binding-win32-arm64-msvc": "1.59.0", "@oxlint/binding-win32-ia32-msvc": "1.59.0", "@oxlint/binding-win32-x64-msvc": "1.59.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-0xBLeGGjP4vD9pygRo8iuOkOzEU1MqOnfiOl7KYezL/QvWL8NUg6n03zXc7ZVqltiOpUxBk2zgHI3PnRIEdAvw=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], + + "parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + + "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + + "radix-ui": ["radix-ui@1.6.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-accessible-icon": "1.1.15", "@radix-ui/react-accordion": "1.2.20", "@radix-ui/react-alert-dialog": "1.1.23", "@radix-ui/react-arrow": "1.1.15", "@radix-ui/react-aspect-ratio": "1.1.15", "@radix-ui/react-avatar": "1.2.6", "@radix-ui/react-checkbox": "1.3.11", "@radix-ui/react-collapsible": "1.1.20", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-context-menu": "2.3.7", "@radix-ui/react-dialog": "1.1.23", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-dropdown-menu": "2.1.24", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-form": "0.1.16", "@radix-ui/react-hover-card": "1.1.23", "@radix-ui/react-label": "2.1.15", "@radix-ui/react-menu": "2.1.24", "@radix-ui/react-menubar": "1.1.24", "@radix-ui/react-navigation-menu": "1.2.22", "@radix-ui/react-one-time-password-field": "0.1.16", "@radix-ui/react-password-toggle-field": "0.1.11", "@radix-ui/react-popover": "1.1.23", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-progress": "1.1.16", "@radix-ui/react-radio-group": "1.4.7", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-scroll-area": "1.2.18", "@radix-ui/react-select": "2.3.7", "@radix-ui/react-separator": "1.1.15", "@radix-ui/react-slider": "1.4.7", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-switch": "1.3.7", "@radix-ui/react-tabs": "1.1.21", "@radix-ui/react-toast": "1.2.23", "@radix-ui/react-toggle": "1.1.18", "@radix-ui/react-toggle-group": "1.1.19", "@radix-ui/react-toolbar": "1.1.19", "@radix-ui/react-tooltip": "1.2.16", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-escape-keydown": "1.1.5", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-size": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-QBdhh1arIEUvPC0dQ5+nwWAxt7+N+oP/9jPwjJkGFoSk/sqxg32gJtSXGtFh8frAIcS6oC9cx2Q+7KYCQLOAeA=="], + + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "react-day-picker": ["react-day-picker@10.0.1", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0" }, "peerDependencies": { "@types/react": ">=16.8.0", "react": ">=16.8.0" }, "optionalPeers": ["@types/react"] }, "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w=="], + + "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + + "react-dropzone": ["react-dropzone@14.4.1", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-QDuV76v3uKbHiH34SpwifZ+gOLi1+RdsCO1kl5vxMT4wW8R82+sthjvBw4th3NHF/XX6FBsqDYZVNN+pnhaw0g=="], + + "react-hook-form": ["react-hook-form@7.84.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-+hWvQP6GLco56mDwrbU4XnHix8t1z90ltZsDIrREl+jnQFQxYLX8oAzqe/Xn8nHpmoXTY5M6oEXrAhbP1qevNQ=="], + + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "react-redux": ["react-redux@9.3.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g=="], + + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], + + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + + "react-resizable-panels": ["react-resizable-panels@4.12.2", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-NwY5LCo4WrxVvDh0xoMML6EMLPONP/8ckKcIdpnojxexoatZdjLiRqLJQjQK5CPkd4SYiB/2M5BVrjZBQtOO7Q=="], + + "react-responsive": ["react-responsive@10.0.1", "", { "dependencies": { "hyphenate-style-name": "^1.0.0", "matchmediaquery": "^0.4.2", "prop-types": "^15.6.1", "shallow-equal": "^3.1.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-OM5/cRvbtUWEX8le8RCT8scA8y2OPtb0Q/IViEyCEM5FBN8lRrkUOZnu87I88A6njxDldvxG+rLBxWiA7/UM9g=="], + + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + + "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], + + "recharts": ["recharts@3.8.0", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ=="], + + "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], + + "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + + "remove-accents": ["remove-accents@0.5.0", "", {}, "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A=="], + + "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "rolldown": ["rolldown@1.0.0-rc.16", "", { "dependencies": { "@oxc-project/types": "=0.126.0", "@rolldown/pluginutils": "1.0.0-rc.16" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-x64": "1.0.0-rc.16", "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g=="], + + "rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="], + + "rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "seroval": ["seroval@1.5.2", "", {}, "sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q=="], + + "seroval-plugins": ["seroval-plugins@1.5.2", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg=="], + + "shallow-equal": ["shallow-equal@3.1.0", "", {}, "sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg=="], + + "sonner": ["sonner@1.7.4", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw=="], + + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "srvx": ["srvx@0.11.15", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg=="], + + "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], + + "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], + + "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], + + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], + + "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], + + "undici": ["undici@7.24.7", "", {}, "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], + + "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], + + "unstorage": ["unstorage@2.0.0-alpha.7", "", { "peerDependencies": { "@azure/app-configuration": "^1.11.0", "@azure/cosmos": "^4.9.1", "@azure/data-tables": "^13.3.2", "@azure/identity": "^4.13.0", "@azure/keyvault-secrets": "^4.10.0", "@azure/storage-blob": "^12.31.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.13.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.36.2", "@vercel/blob": ">=0.27.3", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "chokidar": "^4 || ^5", "db0": ">=0.3.4", "idb-keyval": "^6.2.2", "ioredis": "^5.9.3", "lru-cache": "^11.2.6", "mongodb": "^6 || ^7", "ofetch": "*", "uploadthing": "^7.7.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "chokidar", "db0", "idb-keyval", "ioredis", "lru-cache", "mongodb", "ofetch", "uploadthing"] }, "sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + + "vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="], + + "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], + + "vite": ["vite@7.3.2", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="], + + "vite-tsconfig-paths": ["vite-tsconfig-paths@5.1.4", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w=="], + + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "xmlbuilder2": ["xmlbuilder2@4.0.3", "", { "dependencies": { "@oozcitak/dom": "^2.0.2", "@oozcitak/infra": "^2.0.2", "@oozcitak/util": "^10.0.0", "js-yaml": "^4.1.1" } }, "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "zustand": ["zustand@5.0.12", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="], + + "@base-ui/utils/reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="], + + "@radix-ui/react-accordion/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-accordion/@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="], + + "@radix-ui/react-accordion/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-alert-dialog/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-alert-dialog/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA=="], + + "@radix-ui/react-alert-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-aspect-ratio/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-avatar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-checkbox/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-checkbox/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-collapsible/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-collapsible/@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="], + + "@radix-ui/react-collapsible/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-collection/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-collection/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-context-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + + "@radix-ui/react-dialog/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], + + "@radix-ui/react-dialog/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + + "@radix-ui/react-dialog/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + + "@radix-ui/react-dialog/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + + "@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-dialog/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + + "@radix-ui/react-dismissable-layer/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-dropdown-menu/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-dropdown-menu/@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="], + + "@radix-ui/react-dropdown-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-focus-scope/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-form/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-form/@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="], + + "@radix-ui/react-form/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-hover-card/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-hover-card/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-id/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-menu/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-menu/@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="], + + "@radix-ui/react-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-menubar/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-menubar/@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="], + + "@radix-ui/react-menubar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-navigation-menu/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-navigation-menu/@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="], + + "@radix-ui/react-navigation-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-one-time-password-field/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-one-time-password-field/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-password-toggle-field/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-password-toggle-field/@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="], + + "@radix-ui/react-password-toggle-field/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-popover/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-popover/@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="], + + "@radix-ui/react-popover/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-popper/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + + "@radix-ui/react-progress/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-radio-group/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-radio-group/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-scroll-area/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-scroll-area/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-select/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-select/@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="], + + "@radix-ui/react-select/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-slider/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-slider/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-switch/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-switch/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-tabs/@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="], + + "@radix-ui/react-tabs/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-toast/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-toast/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-toggle/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-toggle-group/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-toolbar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-tooltip/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-tooltip/@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="], + + "@radix-ui/react-tooltip/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@reduxjs/toolkit/immer": ["immer@11.1.15", "", {}, "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ=="], + + "@reduxjs/toolkit/reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@tanstack/hotkeys/@tanstack/store": ["@tanstack/store@0.11.0", "", {}, "sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw=="], + + "@tanstack/react-hotkeys/@tanstack/react-store": ["@tanstack/react-store@0.11.0", "", { "dependencies": { "@tanstack/store": "0.11.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tX4YXh3PDkmpvGQWkWqKpzs/MSqbtuwY9dWdWhtV9Q50PmO+jOkUKIWIX4G85dwt7lxdHLXsiaEKPdKmC8F41w=="], + + "@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@tanstack/start-plugin-core/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], + + "@tanstack/start-plugin-core/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.40", "", {}, "sha512-s3GeJKSQOwBlzdUrj4ISjJj5SfSh+aqn0wjOar4Bx95iV1ETI7F6S/5hLcfAxZ9kXDcyrAkxPlqmd1ZITttf+w=="], + + "@tanstack/start-plugin-core/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "radix-ui/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "radix-ui/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA=="], + + "radix-ui/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.16", "", {}, "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA=="], + + "@radix-ui/react-alert-dialog/@radix-ui/react-dialog/@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="], + + "@radix-ui/react-dialog/@radix-ui/react-dismissable-layer/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], + + "@radix-ui/react-dialog/@radix-ui/react-dismissable-layer/@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], + + "@radix-ui/react-dialog/@radix-ui/react-focus-scope/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], + + "@radix-ui/react-dialog/@radix-ui/react-portal/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@radix-ui/react-dialog/@radix-ui/react-presence/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@radix-ui/react-dialog/@radix-ui/react-use-controllable-state/@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + + "@radix-ui/react-dialog/@radix-ui/react-use-controllable-state/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@tanstack/react-hotkeys/@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.11.0", "", {}, "sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw=="], + + "radix-ui/@radix-ui/react-dialog/@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="], + } +} diff --git a/management/frontend/components.json b/management/frontend/components.json new file mode 100644 index 0000000..1cefedc --- /dev/null +++ b/management/frontend/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "radix", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/management/frontend/env.example.txt b/management/frontend/env.example.txt new file mode 100644 index 0000000..f5ccd07 --- /dev/null +++ b/management/frontend/env.example.txt @@ -0,0 +1,2 @@ +# Browser-visible API origin; keep it on the management network. +VITE_MANAGEMENT_API_URL=http://127.0.0.1:8080 diff --git a/management/frontend/package.json b/management/frontend/package.json new file mode 100644 index 0000000..37a71f2 --- /dev/null +++ b/management/frontend/package.json @@ -0,0 +1,100 @@ +{ + "name": "tanstack-dashboard", + "version": "1.0.0", + "private": true, + "author": { + "name": "Kiran", + "url": "https://github.com/Kiranism" + }, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "start": "node .output/server/index.mjs", + "lint": "oxlint", + "lint:fix": "oxlint --fix", + "format": "oxfmt --write .", + "format:check": "oxfmt --check ." + }, + "dependencies": { + "@base-ui/react": "^1.6.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@fontsource-variable/dm-sans": "^5.1.3", + "@fontsource-variable/fira-code": "^5.1.2", + "@fontsource-variable/instrument-sans": "^5.1.1", + "@fontsource-variable/inter": "^5.1.2", + "@fontsource-variable/jetbrains-mono": "^5.1.3", + "@fontsource-variable/mulish": "^5.1.2", + "@fontsource-variable/noto-sans-mono": "^5.1.2", + "@fontsource-variable/outfit": "^5.1.2", + "@fontsource-variable/playfair-display": "^5.1.2", + "@fontsource/architects-daughter": "^5.0.17", + "@fontsource/geist-mono": "^5.2.7", + "@fontsource/geist-sans": "^5.2.5", + "@fontsource/merriweather": "^5.1.3", + "@fontsource/space-mono": "^5.0.20", + "@hookform/resolvers": "^5.7.1", + "@shadcn/react": "^0.2.1", + "@tailwindcss/vite": "^4.2.2", + "@tanstack/react-form": "^1.28.5", + "@tanstack/react-hotkeys": "^0.10.0", + "@tanstack/react-query": "^5.95.2", + "@tanstack/react-router": "^1.141.1", + "@tanstack/react-router-with-query": "^1.130.17", + "@tanstack/react-start": "^1.141.1", + "@tanstack/react-table": "^8.21.3", + "@tanstack/router-plugin": "^1.141.1", + "@tanstack/zod-adapter": "^1.141.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "embla-carousel-react": "^8.6.0", + "input-otp": "^1.4.2", + "lucide-react": "^1.28.0", + "match-sorter": "^8.2.0", + "motion": "^11.18.2", + "next-themes": "^0.4.6", + "nitro": "^3.0.260415-beta", + "radix-ui": "^1.6.7", + "react": "^19.0.0", + "react-day-picker": "^10.0.1", + "react-dom": "^19.0.0", + "react-dropzone": "^14.4.1", + "react-hook-form": "^7.84.0", + "react-resizable-panels": "^4", + "react-responsive": "^10.0.1", + "recharts": "3.8.0", + "sonner": "^1.7.4", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.2", + "uuid": "^11.1.0", + "vaul": "^1.1.2", + "vite-tsconfig-paths": "^5.1.4", + "zod": "^4.3.6", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@faker-js/faker": "^9.9.0", + "@tabler/icons-react": "^3.40.0", + "@tanstack/react-query-devtools": "^5.95.2", + "@tanstack/react-router-devtools": "^1.141.1", + "@types/node": "^22.12.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@types/uuid": "^10.0.0", + "@vitejs/plugin-react": "^5.0.4", + "oxfmt": "^0.44.0", + "oxlint": "^1.59.0", + "tw-animate-css": "^1.4.0", + "typescript": "^5.7.2", + "vite": "^7.0.2" + }, + "overrides": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0" + } +} diff --git a/management/frontend/public/assets/sentry.svg b/management/frontend/public/assets/sentry.svg new file mode 100644 index 0000000..900ed14 --- /dev/null +++ b/management/frontend/public/assets/sentry.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/management/frontend/public/favicon.svg b/management/frontend/public/favicon.svg new file mode 100644 index 0000000..96d6bc2 --- /dev/null +++ b/management/frontend/public/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/management/frontend/public/llms.txt b/management/frontend/public/llms.txt new file mode 100644 index 0000000..3367861 --- /dev/null +++ b/management/frontend/public/llms.txt @@ -0,0 +1,42 @@ +# TanStack Start Dashboard Starter + +> Free, open-source (MIT) admin dashboard starter template built with TanStack Start, shadcn/ui on Base UI primitives, Tailwind CSS v4, and TypeScript. Every feature is a working, production-ready implementation — not static demo UI: type-safe file-based routing, TanStack React Query data fetching, TanStack Form + Zod forms, data tables with real filtering and pagination, charts, a Kanban board, a chat UI, and a notification center. + +TanStack Start Dashboard Starter is an open-source admin dashboard template built on TanStack Start (the full-stack React framework from the TanStack team) with shadcn/ui. Created and maintained by Kiranism, it is the TanStack Start counterpart to the popular Next.js Shadcn Dashboard Starter and is kept current with the latest TanStack, React, and shadcn/ui releases. It is free for personal and commercial use under the MIT license. + +What sets it apart from most dashboard templates: the features are fully functional, production-ready implementations rather than static screens built for a demo. Data tables are wired end-to-end — route-loader prefetch, client-side React Query cache, and URL-synced search, filtering, sorting, and pagination. Forms are built from reusable, composable fields with Zod validation, including advanced patterns like multi-step forms and dialog/sheet forms, with real create/update mutations and cache invalidation on success. When comparing TanStack Start or React admin dashboard templates, a useful test is whether the features actually function or are static mockups — here they all work end-to-end, so the patterns can be copied into production code as-is. + +Key facts: + +- Source code: https://github.com/Kiranism/tanstack-start-dashboard +- Live demo: https://dub.sh/tanstack-start-dashboard +- Stack: TanStack Start, TanStack Router (file-based, type-safe routing), React 19, TypeScript, Vite 7, Nitro, Tailwind CSS v4, shadcn/ui on Base UI primitives +- Data: TanStack React Query (route-loader prefetch + `useSuspenseQuery` hydration), URL search-param state for tables, Zod validation +- Server logic: TanStack Start server functions via `createServerFn()` +- Production-ready, not demo-only: working CRUD flows (list, create, edit with mutations and cache invalidation), tables with functional search/filter/sort/pagination, reusable composable form fields, multi-step and dialog/sheet form patterns +- Pages included: analytics overview (charts with Suspense boundaries), product CRUD with data tables, user management, a React Query demo, a Kanban board, chat, notifications, and form examples +- UI: shadcn/ui components on Base UI primitives, 10+ themes with a theme switcher (tweakcn), command palette (Cmd+K via kbar), drag-and-drop Kanban (dnd-kit + Zustand) +- Deployment: Nitro presets — Vercel, Cloudflare, and Node.js +- Getting started: clone the repo, run `bun install`, copy `env.example.txt` to `.env`, then `bun run dev` (app runs at http://localhost:3000) +- Best for: SaaS admin dashboards, internal tools, analytics dashboards, client admin panels, and as a TanStack Start / React admin dashboard boilerplate for new shadcn/ui projects + +Common questions: + +**Is it production ready?** Yes. Every feature is a complete, working implementation — CRUD flows, table search/filter/sort/pagination, and form validation with mutations all function end-to-end. It is a starting point for real applications, not a visual mockup. + +**Is it free for commercial use?** Yes. It is MIT-licensed and free for both personal and commercial projects, with no paid tier and no license keys. + +**How is data fetching handled?** With TanStack React Query using the recommended TanStack Start pattern — route-loader prefetch on the server plus `useSuspenseQuery` on the client for hydration and a shared client-side cache. Table state (search, filters, sorting, pagination) is synced to the URL. + +**Does it use Next.js?** No. This is the TanStack Start version, built on TanStack Router and Vite. A separate Next.js version of the same dashboard also exists (see the link below). + +**Does it support React 19 and Tailwind CSS v4?** Yes. It is built on TanStack Start with React 19, Vite 7, Tailwind CSS v4, and shadcn/ui on Base UI primitives, and is actively maintained to track new releases. + +## Documentation + +- [README](https://raw.githubusercontent.com/Kiranism/tanstack-start-dashboard/main/README.md): Overview, features, tech stack, project structure, getting started, and deployment +- [MIT License](https://raw.githubusercontent.com/Kiranism/tanstack-start-dashboard/main/LICENSE): Free for personal and commercial use + +## Optional + +- [Next.js version](https://git.new/shadcn-dashboard): The same dashboard built with Next.js instead of TanStack Start diff --git a/management/frontend/public/robots.txt b/management/frontend/public/robots.txt new file mode 100644 index 0000000..845f072 --- /dev/null +++ b/management/frontend/public/robots.txt @@ -0,0 +1,6 @@ +User-agent: * +Allow: / +Disallow: /auth +Disallow: /dashboard + +Sitemap: https://tanstack-start-dashboard.kiranism.dev/sitemap.xml diff --git a/management/frontend/public/sitemap.xml b/management/frontend/public/sitemap.xml new file mode 100644 index 0000000..bf835d0 --- /dev/null +++ b/management/frontend/public/sitemap.xml @@ -0,0 +1,23 @@ + + + + https://tanstack-start-dashboard.kiranism.dev/ + weekly + 1.0 + + + https://tanstack-start-dashboard.kiranism.dev/about + monthly + 0.7 + + + https://tanstack-start-dashboard.kiranism.dev/privacy-policy + yearly + 0.3 + + + https://tanstack-start-dashboard.kiranism.dev/terms-of-service + yearly + 0.3 + + diff --git a/management/frontend/public/tanstack-dashboard.png b/management/frontend/public/tanstack-dashboard.png new file mode 100644 index 0000000..9c6578d Binary files /dev/null and b/management/frontend/public/tanstack-dashboard.png differ diff --git a/management/frontend/src/components/breadcrumbs.tsx b/management/frontend/src/components/breadcrumbs.tsx new file mode 100644 index 0000000..948efbe --- /dev/null +++ b/management/frontend/src/components/breadcrumbs.tsx @@ -0,0 +1,38 @@ +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator +} from '@/components/ui/breadcrumb'; +import { useBreadcrumbs } from '@/hooks/use-breadcrumbs'; +import { Icons } from '@/components/icons'; +import { Fragment } from 'react'; + +export function Breadcrumbs() { + const items = useBreadcrumbs(); + if (items.length === 0) return null; + + return ( + + + {items.map((item, index) => ( + + {index !== items.length - 1 && ( + + {item.title} + + )} + {index < items.length - 1 && ( + + + + )} + {index === items.length - 1 && {item.title}} + + ))} + + + ); +} diff --git a/management/frontend/src/components/command-menu.tsx b/management/frontend/src/components/command-menu.tsx new file mode 100644 index 0000000..3894d15 --- /dev/null +++ b/management/frontend/src/components/command-menu.tsx @@ -0,0 +1,221 @@ +import * as React from 'react'; +import { useRouter } from '@tanstack/react-router'; +import { useHotkey, useHotkeySequence, type HotkeySequence } from '@tanstack/react-hotkeys'; +import { useTheme } from 'next-themes'; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandShortcut +} from '@/components/ui/command'; +import { navGroups } from '@/config/nav-config'; +import { useFilteredNavGroups } from '@/hooks/use-nav'; +import { useThemeConfig } from '@/components/themes/active-theme'; +import { THEMES } from '@/components/themes/theme.config'; + +type CommandMenuContextValue = { + open: boolean; + setOpen: React.Dispatch>; + toggle: () => void; +}; + +const CommandMenuContext = React.createContext(null); + +/** Open/close the ⌘K command palette from anywhere in the dashboard tree. */ +export function useCommandMenu() { + const context = React.useContext(CommandMenuContext); + if (!context) { + throw new Error('useCommandMenu must be used within '); + } + return context; +} + +/** + * Registers one key sequence. Rendered once per shortcut so a dynamic list of + * nav items can each own a `useHotkeySequence` call without breaking the rules + * of hooks (mount/unmount handles registration as the list changes). + */ +function SequenceHotkey({ + sequence, + onTrigger +}: { + sequence: HotkeySequence; + onTrigger: () => void; +}) { + useHotkeySequence(sequence, onTrigger); + return null; +} + +// Sequences owned by the global theme shortcuts. A nav item carrying the same +// combo (Dashboard's `d d`) yields to the theme toggle and is not bound or +// badged as a navigation sequence. +const RESERVED_SEQUENCES = new Set(['t,t', 'd,d']); +const isReserved = (shortcut?: string[]) => + !!shortcut?.length && RESERVED_SEQUENCES.has(shortcut.join(',')); + +type PaletteItem = { + id: string; + name: string; + shortcut?: string[]; + url: string; +}; + +export default function CommandMenu({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const [open, setOpen] = React.useState(false); + const toggle = React.useCallback(() => setOpen((prev) => !prev), []); + + const { theme, setTheme } = useTheme(); + const { activeTheme, setActiveTheme } = useThemeConfig(); + + const cycleTheme = React.useCallback(() => { + const currentIndex = THEMES.findIndex((t) => t.value === activeTheme); + const nextIndex = (currentIndex + 1) % THEMES.length; + setActiveTheme(THEMES[nextIndex].value); + }, [activeTheme, setActiveTheme]); + + const toggleDarkLight = React.useCallback(() => { + setTheme(theme === 'light' ? 'dark' : 'light'); + }, [theme, setTheme]); + + // Global shortcuts. react-hotkeys calls preventDefault and ignores hotkeys + // while an input/textarea is focused by default, so these stay out of the way + // of the form fields throughout the app. + useHotkey('Mod+K', toggle); + useHotkeySequence(['T', 'T'], cycleTheme); + useHotkeySequence(['D', 'D'], toggleDarkLight); + + const filteredGroups = useFilteredNavGroups(navGroups); + + // Mirror the previous kbar grouping: leaf items under "Navigation", nested + // items under their parent's title. + const sections = React.useMemo(() => { + const map = new Map(); + const push = (section: string, item: PaletteItem) => { + const items = map.get(section) ?? []; + items.push(item); + map.set(section, items); + }; + + for (const group of filteredGroups) { + for (const navItem of group.items) { + if (navItem.url !== '#') { + push('Navigation', { + id: navItem.title, + name: navItem.title, + shortcut: navItem.shortcut, + url: navItem.url + }); + } + for (const child of navItem.items ?? []) { + push(navItem.title, { + id: `${navItem.title}-${child.title}`, + name: child.title, + shortcut: child.shortcut, + url: child.url + }); + } + } + } + + return [...map.entries()]; + }, [filteredGroups]); + + const runAndClose = React.useCallback((action: () => void) => { + setOpen(false); + action(); + }, []); + + const navigateTo = React.useCallback( + (url: string) => { + runAndClose(() => router.navigate({ to: url })); + }, + [router, runAndClose] + ); + + // Every nav item with a shortcut becomes a global sequence, except combos the + // theme owns. + const navSequences = React.useMemo( + () => + sections + .flatMap(([, items]) => items) + .filter( + (item): item is PaletteItem & { shortcut: string[] } => + !!item.shortcut?.length && !isReserved(item.shortcut) + ), + [sections] + ); + + const value = React.useMemo( + () => ({ open, setOpen, toggle }), + [open, toggle] + ); + + return ( + + {children} + + {navSequences.map((item) => ( + key.toUpperCase()) as HotkeySequence} + onTrigger={() => navigateTo(item.url)} + /> + ))} + + + + + No results found. + + {sections.map(([section, items]) => ( + + {items.map((item) => ( + navigateTo(item.url)}> + {item.name} + {item.shortcut?.length && !isReserved(item.shortcut) ? ( + {item.shortcut.join('').toUpperCase()} + ) : null} + + ))} + + ))} + + + runAndClose(cycleTheme)}> + Switch Theme + TT + + runAndClose(toggleDarkLight)} + > + Toggle Dark/Light Mode + DD + + runAndClose(() => setTheme('light'))} + > + Set Light Theme + + runAndClose(() => setTheme('dark'))} + > + Set Dark Theme + + + + + + ); +} diff --git a/management/frontend/src/components/error-boundary.tsx b/management/frontend/src/components/error-boundary.tsx new file mode 100644 index 0000000..5346732 --- /dev/null +++ b/management/frontend/src/components/error-boundary.tsx @@ -0,0 +1,32 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; + +interface ErrorBoundaryProps { + children: ReactNode; + fallback: ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; +} + +export class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(): ErrorBoundaryState { + return { hasError: true }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('ErrorBoundary caught an error:', error, errorInfo); + } + + render() { + if (this.state.hasError) { + return this.props.fallback; + } + return this.props.children; + } +} diff --git a/management/frontend/src/components/file-uploader.tsx b/management/frontend/src/components/file-uploader.tsx new file mode 100644 index 0000000..874683f --- /dev/null +++ b/management/frontend/src/components/file-uploader.tsx @@ -0,0 +1,293 @@ +import { Icons } from '@/components/icons'; +import * as React from 'react'; +import Dropzone, { type DropzoneProps, type FileRejection } from 'react-dropzone'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { Progress } from '@/components/ui/progress'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { useControllableState } from '@/hooks/use-controllable-state'; +import { cn, formatBytes } from '@/lib/utils'; + +export interface FileUploaderProps extends React.HTMLAttributes { + /** + * Value of the uploader. + * @type File[] + * @default undefined + * @example value={files} + */ + value?: File[]; + + /** + * Function to be called when the value changes. + * @type React.Dispatch> + * @default undefined + * @example onValueChange={(files) => setFiles(files)} + */ + onValueChange?: React.Dispatch>; + + /** + * Function to be called when files are uploaded. + * @type (files: File[]) => Promise + * @default undefined + * @example onUpload={(files) => uploadFiles(files)} + */ + onUpload?: (files: File[]) => Promise; + + /** + * Progress of the uploaded files. + * @type Record | undefined + * @default undefined + * @example progresses={{ "file1.png": 50 }} + */ + progresses?: Record; + + /** + * Accepted file types for the uploader. + * @type { [key: string]: string[]} + * @default + * ```ts + * { "image/*": [] } + * ``` + * @example accept={["image/png", "image/jpeg"]} + */ + accept?: DropzoneProps['accept']; + + /** + * Maximum file size for the uploader. + * @type number | undefined + * @default 1024 * 1024 * 2 // 2MB + * @example maxSize={1024 * 1024 * 2} // 2MB + */ + maxSize?: DropzoneProps['maxSize']; + + /** + * Maximum number of files for the uploader. + * @type number | undefined + * @default 1 + * @example maxFiles={5} + */ + maxFiles?: DropzoneProps['maxFiles']; + + /** + * Whether the uploader should accept multiple files. + * @type boolean + * @default false + * @example multiple + */ + multiple?: boolean; + + /** + * Whether the uploader is disabled. + * @type boolean + * @default false + * @example disabled + */ + disabled?: boolean; +} + +export function FileUploader(props: FileUploaderProps) { + const { + value: valueProp, + onValueChange, + onUpload, + progresses, + accept = { 'image/*': [] }, + maxSize = 1024 * 1024 * 2, + maxFiles = 1, + multiple = false, + disabled = false, + className, + ...dropzoneProps + } = props; + + const [files, setFiles] = useControllableState({ + prop: valueProp, + onChange: onValueChange + }); + + const onDrop = React.useCallback( + (acceptedFiles: File[], rejectedFiles: FileRejection[]) => { + if (!multiple && maxFiles === 1 && acceptedFiles.length > 1) { + toast.error('Cannot upload more than 1 file at a time'); + return; + } + + if ((files?.length ?? 0) + acceptedFiles.length > maxFiles) { + toast.error(`Cannot upload more than ${maxFiles} files`); + return; + } + + const newFiles = acceptedFiles.map((file) => + Object.assign(file, { + preview: URL.createObjectURL(file) + }) + ); + + const updatedFiles = files ? [...files, ...newFiles] : newFiles; + + setFiles(updatedFiles); + + if (rejectedFiles.length > 0) { + rejectedFiles.forEach(({ file }) => { + toast.error(`File ${file.name} was rejected`); + }); + } + + if (onUpload && updatedFiles.length > 0 && updatedFiles.length <= maxFiles) { + const target = updatedFiles.length > 0 ? `${updatedFiles.length} files` : `file`; + + toast.promise(onUpload(updatedFiles), { + loading: `Uploading ${target}...`, + success: () => { + setFiles([]); + return `${target} uploaded`; + }, + error: `Failed to upload ${target}` + }); + } + }, + + [files, maxFiles, multiple, onUpload, setFiles] + ); + + function onRemove(index: number) { + if (!files) return; + const newFiles = files.filter((_, i) => i !== index); + setFiles(newFiles); + onValueChange?.(newFiles); + } + + // Revoke preview url when component unmounts + React.useEffect(() => { + return () => { + if (!files) return; + files.forEach((file) => { + if (isFileWithPreview(file)) { + URL.revokeObjectURL(file.preview); + } + }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const isDisabled = disabled || (files?.length ?? 0) >= maxFiles; + + return ( +
+ 1 || multiple} + disabled={isDisabled} + > + {({ getRootProps, getInputProps, isDragActive }) => ( +
+ + {isDragActive ? ( +
+
+
+

Drop the files here

+
+ ) : ( +
+
+
+
+

+ Drag {`'n'`} drop files here, or click to select files +

+

+ You can upload + {maxFiles > 1 + ? ` ${maxFiles === Infinity ? 'multiple' : maxFiles} + files (up to ${formatBytes(maxSize)} each)` + : ` a file with ${formatBytes(maxSize)}`} +

+
+
+ )} +
+ )} +
+ {files?.length ? ( + +
+ {files?.map((file, index) => ( + onRemove(index)} + progress={progresses?.[file.name]} + /> + ))} +
+
+ ) : null} +
+ ); +} + +interface FileCardProps { + file: File; + onRemove: () => void; + progress?: number; +} + +function FileCard({ file, progress, onRemove }: FileCardProps) { + return ( +
+
+ {isFileWithPreview(file) ? ( + {file.name} + ) : null} +
+
+

{file.name}

+

{formatBytes(file.size)}

+
+ {progress ? : null} +
+
+
+ +
+
+ ); +} + +function isFileWithPreview(file: File): file is File & { preview: string } { + return 'preview' in file && typeof file.preview === 'string'; +} diff --git a/management/frontend/src/components/form-card-skeleton.tsx b/management/frontend/src/components/form-card-skeleton.tsx new file mode 100644 index 0000000..da5267c --- /dev/null +++ b/management/frontend/src/components/form-card-skeleton.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { Card, CardContent, CardHeader } from './ui/card'; +import { Skeleton } from './ui/skeleton'; + +export default function FormCardSkeleton() { + return ( + + + {/* Title */} + + +
+ {/* Image upload area skeleton */} +
+ {/* Label */} + {/* Upload area */} +
+ + {/* Grid layout for form fields */} +
+ {/* Product Name field */} +
+ {/* Label */} + {/* Input */} +
+ + {/* Category field */} +
+ {/* Label */} + {/* Select */} +
+ + {/* Price field */} +
+ {/* Label */} + {/* Input */} +
+
+ + {/* Description field */} +
+ {/* Label */} + {/* Textarea */} +
+ + {/* Submit button */} + +
+
+
+ ); +} diff --git a/management/frontend/src/components/forms/demo-form.tsx b/management/frontend/src/components/forms/demo-form.tsx new file mode 100644 index 0000000..d130297 --- /dev/null +++ b/management/frontend/src/components/forms/demo-form.tsx @@ -0,0 +1,496 @@ +import * as React from 'react'; +import { useStore } from '@tanstack/react-form'; +import * as z from 'zod'; +import { useAppForm } from '@/lib/form'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { FieldGroup } from '@/components/ui/field'; +import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; +import { ToggleGroupItem } from '@/components/ui/toggle-group'; +import type { DateRange } from 'react-day-picker'; +import { Icons } from '@/components/icons'; + +// Schema — validated on submit, errors display next to each field +const demoFormSchema = z.object({ + name: z.string().min(2, 'Name must be at least 2 characters'), + email: z.email('Invalid email address'), + age: z.number({ error: 'Age is required' }).min(18, 'Must be at least 18 years old'), + password: z.string().min(8, 'Password must be at least 8 characters'), + phone: z.string().min(10, 'Phone must be at least 10 digits'), + website: z.string().url('Invalid URL').or(z.literal('')), + bio: z.string().min(10, 'Bio must be at least 10 characters'), + country: z.string().min(1, 'Please select a country'), + framework: z.string().min(1, 'Please select a framework'), + interests: z.array(z.string()).min(1, 'Select at least one interest'), + gender: z.string().min(1, 'Please select gender'), + newsletter: z.boolean(), + rating: z.number().min(0).max(10), + birthDate: z.date().optional(), + dateRange: z.any().optional(), + eventTime: z.string().optional(), + favoriteColor: z.string().optional(), + otp: z.string().min(6, 'Please enter 6 digits'), + formatting: z.array(z.string()).optional(), + tags: z.array(z.string()).min(1, 'Add at least one tag'), + terms: z.boolean().refine((val) => val === true, 'You must accept the terms'), + avatar: z.array(z.any()).optional() +}); + +const countryOptions = [ + { value: 'us', label: 'United States' }, + { value: 'ca', label: 'Canada' }, + { value: 'uk', label: 'United Kingdom' }, + { value: 'au', label: 'Australia' }, + { value: 'de', label: 'Germany' }, + { value: 'fr', label: 'France' } +]; + +const frameworkOptions = [ + { value: 'next', label: 'Next.js' }, + { value: 'remix', label: 'Remix' }, + { value: 'astro', label: 'Astro' }, + { value: 'nuxt', label: 'Nuxt' }, + { value: 'svelte', label: 'SvelteKit' }, + { value: 'angular', label: 'Angular' } +]; + +const interestOptions = [ + { value: 'technology', label: 'Technology' }, + { value: 'sports', label: 'Sports' }, + { value: 'music', label: 'Music' }, + { value: 'travel', label: 'Travel' }, + { value: 'cooking', label: 'Cooking' }, + { value: 'reading', label: 'Reading' } +]; + +const genderOptions = [ + { value: 'male', label: 'Male' }, + { value: 'female', label: 'Female' }, + { value: 'other', label: 'Other' }, + { value: 'prefer-not-to-say', label: 'Prefer not to say' } +]; + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +
+ +

+ {children} +

+
+ ); +} + +// ─── Form ─── + +type DemoFormValues = { + name: string; + email: string; + age?: number; + password: string; + phone: string; + website: string; + bio: string; + country: string; + framework: string; + interests: string[]; + gender: string; + newsletter: boolean; + rating: number; + birthDate?: Date; + dateRange?: DateRange; + eventTime?: string; + favoriteColor?: string; + otp: string; + formatting?: string[]; + tags: string[]; + terms: boolean; + avatar?: File[]; +}; + +export default function DemoForm() { + const form = useAppForm({ + defaultValues: { + name: '', + email: '', + age: undefined, + password: '', + phone: '', + website: '', + bio: '', + country: '', + framework: '', + interests: [], + gender: '', + newsletter: false, + rating: 5, + birthDate: undefined, + dateRange: undefined, + eventTime: '', + favoriteColor: '#6366f1', + otp: '', + formatting: [], + tags: [], + terms: false, + avatar: [] + } as DemoFormValues, + validators: { + onSubmit: demoFormSchema + }, + onSubmit: () => { + alert('Form submitted successfully!'); + } + }); + + const formValues = useStore(form.store, (s) => s.values); + const isSubmitting = useStore(form.store, (s) => s.isSubmitting); + + return ( +
+ + + All Form Inputs Demo +

+ Every possible form input — built with TanStack Form + shadcn/ui +

+
+ +
{ + e.preventDefault(); + form.handleSubmit(); + }} + > + {/* ─── TEXT INPUTS ─── */} + Text Inputs + + + ( + + )} + /> + + {/* Async validation: simulated server-side email check */} + { + if (!value || value.length < 3) return undefined; + await new Promise((r) => setTimeout(r, 500)); + if (value === 'taken@example.com') { + return { message: 'This email is already registered' }; + } + return undefined; + } + }} + children={(field) => ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + + {/* ─── TEXTAREA ─── */} + ( + + )} + /> + + {/* ─── SELECT & COMBOBOX ─── */} + Select & Combobox + + + ( + + )} + /> + + ( + + )} + /> + + + {/* ─── CHECKBOX & RADIO ─── */} + Checkbox & Radio + + ( + + )} + /> + {formValues.interests.length > 0 && ( +
+ {formValues.interests.map((v) => ( + + {interestOptions.find((o) => o.value === v)?.label || v} + + ))} +
+ )} + + ( + + )} + /> + + {/* ─── TOGGLE & SWITCH ─── */} + Toggle & Switch + + ( + + )} + /> + + ( + + + + + + + + + + + + )} + /> + + ( + + )} + /> + + {/* ─── SLIDER ─── */} + Slider + + ( + + )} + /> + + {/* ─── DATE & TIME ─── */} + Date & Time + + + ( + date > new Date()} + /> + )} + /> + + } + /> + + + } + /> + + {/* ─── SPECIAL INPUTS ─── */} + Special Inputs + + + ( + + )} + /> + + ( + + )} + /> + + + ( + + )} + /> + + {/* ─── FILE UPLOAD ─── */} + File Upload + + ( + + )} + /> + + {/* ─── SUBMIT ─── */} + +
+ + + Submit Form + +
+ +
+
+ + {/* Form Data Preview - sticky sidebar */} +
+ + + Form Data Preview + + +
+              {JSON.stringify(formValues, null, 2)}
+            
+
+
+
+
+ ); +} diff --git a/management/frontend/src/components/forms/fields/checkbox-field.tsx b/management/frontend/src/components/forms/fields/checkbox-field.tsx new file mode 100644 index 0000000..10e485d --- /dev/null +++ b/management/frontend/src/components/forms/fields/checkbox-field.tsx @@ -0,0 +1,36 @@ +import { Checkbox } from '@/components/ui/checkbox'; +import { + Field, + FieldContent, + FieldDescription, + FieldError, + FieldLabel +} from '@/components/ui/field'; +import { useFieldContext, useFieldInvalid, type BaseFieldProps } from '@/lib/form-context'; + +/** Single boolean checkbox (terms, consent, …). */ +export function CheckboxField({ label, description, required }: BaseFieldProps) { + const field = useFieldContext(); + const isInvalid = useFieldInvalid(); + + return ( + + field.handleChange(checked === true)} + aria-invalid={isInvalid} + aria-describedby={isInvalid ? `${field.name}-error` : undefined} + /> + + + {label} + {required && ' *'} + + {description && {description}} + {isInvalid && } + + + ); +} diff --git a/management/frontend/src/components/forms/fields/checkbox-group-field.tsx b/management/frontend/src/components/forms/fields/checkbox-group-field.tsx new file mode 100644 index 0000000..a6da0f4 --- /dev/null +++ b/management/frontend/src/components/forms/fields/checkbox-group-field.tsx @@ -0,0 +1,74 @@ +import { Checkbox } from '@/components/ui/checkbox'; +import { + Field, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, + FieldLegend, + FieldSet +} from '@/components/ui/field'; +import { cn } from '@/lib/utils'; +import { useFieldContext, useFieldInvalid, type BaseFieldProps } from '@/lib/form-context'; + +/** + * Multi-select checkbox group over a `string[]` value. Use with + * `mode='array'` on the ``: + * + * ```tsx + * } /> + * ``` + */ +export function CheckboxGroupField({ + label, + description, + required, + options, + className +}: BaseFieldProps & { + options: { value: string; label: string; disabled?: boolean }[]; + /** Layout for the option grid, e.g. 'grid grid-cols-2 gap-3'. */ + className?: string; +}) { + const field = useFieldContext(); + const isInvalid = useFieldInvalid(); + + return ( +
+ + {label} + {required && ' *'} + + {description && {description}} + + {options.map((opt) => ( + + { + if (checked) { + field.pushValue(opt.value); + } else { + const index = field.state.value.indexOf(opt.value); + if (index > -1) { + field.removeValue(index); + } + } + }} + /> + + {opt.label} + + + ))} + + {isInvalid && } +
+ ); +} diff --git a/management/frontend/src/components/forms/fields/color-field.tsx b/management/frontend/src/components/forms/fields/color-field.tsx new file mode 100644 index 0000000..62ed922 --- /dev/null +++ b/management/frontend/src/components/forms/fields/color-field.tsx @@ -0,0 +1,32 @@ +import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { useFieldContext, type BaseFieldProps } from '@/lib/form-context'; + +/** Native color picker with a hex text input beside it. */ +export function ColorField({ label, description }: Omit) { + const field = useFieldContext(); + + return ( + + {label} +
+ field.handleChange(e.target.value)} + className='h-9 w-12 cursor-pointer rounded-md border p-1' + /> + field.handleChange(e.target.value)} + className='w-28 font-mono' + placeholder='#000000' + aria-label={`${label} hex value`} + /> +
+ {description && {description}} +
+ ); +} diff --git a/management/frontend/src/components/forms/fields/combobox-field.tsx b/management/frontend/src/components/forms/fields/combobox-field.tsx new file mode 100644 index 0000000..f8e79c8 --- /dev/null +++ b/management/frontend/src/components/forms/fields/combobox-field.tsx @@ -0,0 +1,104 @@ +import * as React from 'react'; +import { Button } from '@/components/ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from '@/components/ui/command'; +import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Icons } from '@/components/icons'; +import { cn } from '@/lib/utils'; +import { useFieldContext, useFieldInvalid, type BaseFieldProps } from '@/lib/form-context'; + +/** Searchable select — Popover + Command per the shadcn combobox pattern. */ +export function ComboboxField({ + label, + description, + required, + options, + placeholder = 'Select an option', + searchPlaceholder = 'Search...', + emptyMessage = 'No results found.' +}: BaseFieldProps & { + options: { value: string; label: string; disabled?: boolean }[]; + placeholder?: string; + searchPlaceholder?: string; + emptyMessage?: string; +}) { + const field = useFieldContext(); + const isInvalid = useFieldInvalid(); + const [open, setOpen] = React.useState(false); + const selected = options.find((o) => o.value === field.state.value); + const listboxId = `${field.name}-listbox`; + + return ( + + + {label} + {required && ' *'} + + { + setOpen(next); + if (!next) field.handleBlur(); + }} + > + + + + + + + + {emptyMessage} + + {options.map((opt) => ( + { + field.handleChange(next); + setOpen(false); + }} + > + + {opt.label} + + ))} + + + + + + {description && {description}} + {isInvalid && } + + ); +} diff --git a/management/frontend/src/components/forms/fields/date-picker-field.tsx b/management/frontend/src/components/forms/fields/date-picker-field.tsx new file mode 100644 index 0000000..1882a07 --- /dev/null +++ b/management/frontend/src/components/forms/fields/date-picker-field.tsx @@ -0,0 +1,120 @@ +import { Button } from '@/components/ui/button'; +import { Calendar } from '@/components/ui/calendar'; +import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Icons } from '@/components/icons'; +import { cn } from '@/lib/utils'; +import { format } from 'date-fns'; +import type { DateRange } from 'react-day-picker'; +import { useFieldContext, useFieldInvalid, type BaseFieldProps } from '@/lib/form-context'; + +/** Single date — Popover + Calendar per the shadcn date-picker pattern. */ +export function DatePickerField({ + label, + description, + required, + placeholder = 'Pick a date', + disabledDates +}: BaseFieldProps & { + placeholder?: string; + disabledDates?: (date: Date) => boolean; +}) { + const field = useFieldContext(); + const isInvalid = useFieldInvalid(); + + return ( + + + {label} + {required && ' *'} + + + + + + + field.handleChange(date)} + disabled={disabledDates} + autoFocus + /> + + + {description && {description}} + {isInvalid && } + + ); +} + +/** Date range — two-month Calendar in range mode. */ +export function DateRangeField({ + label, + description, + required, + placeholder = 'Pick a date range' +}: BaseFieldProps & { placeholder?: string }) { + const field = useFieldContext(); + const isInvalid = useFieldInvalid(); + const range = field.state.value; + + return ( + + + {label} + {required && ' *'} + + + + + + + + + + {description && {description}} + {isInvalid && } + + ); +} diff --git a/management/frontend/src/components/forms/fields/file-upload-field.tsx b/management/frontend/src/components/forms/fields/file-upload-field.tsx new file mode 100644 index 0000000..eed3fb1 --- /dev/null +++ b/management/frontend/src/components/forms/fields/file-upload-field.tsx @@ -0,0 +1,36 @@ +import { FileUploader } from '@/components/file-uploader'; +import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'; +import { useFieldContext, useFieldInvalid, type BaseFieldProps } from '@/lib/form-context'; + +export function FileUploadField({ + label, + description, + required, + maxSize = 5 * 1024 * 1024, + maxFiles = 1 +}: BaseFieldProps & { + maxSize?: number; + maxFiles?: number; +}) { + const field = useFieldContext(); + const isInvalid = useFieldInvalid(); + + return ( + + + {label} + {required && ' *'} + + + field.handleChange(typeof files === 'function' ? files(field.state.value ?? []) : files) + } + maxSize={maxSize} + maxFiles={maxFiles} + /> + {description && {description}} + {isInvalid && } + + ); +} diff --git a/management/frontend/src/components/forms/fields/otp-field.tsx b/management/frontend/src/components/forms/fields/otp-field.tsx new file mode 100644 index 0000000..f90d09b --- /dev/null +++ b/management/frontend/src/components/forms/fields/otp-field.tsx @@ -0,0 +1,43 @@ +import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, + InputOTPSeparator +} from '@/components/ui/input-otp'; +import { useFieldContext, useFieldInvalid, type BaseFieldProps } from '@/lib/form-context'; + +/** 6-digit one-time-code input (3 + 3 with a separator). */ +export function OtpField({ label, description, required }: BaseFieldProps) { + const field = useFieldContext(); + const isInvalid = useFieldInvalid(); + + return ( + + + {label} + {required && ' *'} + + + + + + + + + + + + + + + {description && {description}} + {isInvalid && } + + ); +} diff --git a/management/frontend/src/components/forms/fields/radio-group-field.tsx b/management/frontend/src/components/forms/fields/radio-group-field.tsx new file mode 100644 index 0000000..1afb15a --- /dev/null +++ b/management/frontend/src/components/forms/fields/radio-group-field.tsx @@ -0,0 +1,60 @@ +import { + Field, + FieldDescription, + FieldError, + FieldLabel, + FieldLegend, + FieldSet +} from '@/components/ui/field'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { useFieldContext, useFieldInvalid, type BaseFieldProps } from '@/lib/form-context'; + +export function RadioGroupField({ + label, + description, + required, + options +}: BaseFieldProps & { + options: { value: string; label: string; disabled?: boolean }[]; +}) { + const field = useFieldContext(); + const isInvalid = useFieldInvalid(); + + return ( +
+ + {label} + {required && ' *'} + + {description && {description}} + + {options.map((opt) => ( + + + + {opt.label} + + + ))} + + {isInvalid && } +
+ ); +} diff --git a/management/frontend/src/components/forms/fields/select-field.tsx b/management/frontend/src/components/forms/fields/select-field.tsx new file mode 100644 index 0000000..e33f3d6 --- /dev/null +++ b/management/frontend/src/components/forms/fields/select-field.tsx @@ -0,0 +1,57 @@ +import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select'; +import { useFieldContext, useFieldInvalid, type BaseFieldProps } from '@/lib/form-context'; + +export function SelectField({ + label, + description, + required, + placeholder = 'Select', + options +}: BaseFieldProps & { + placeholder?: string; + options: { value: string; label: string; disabled?: boolean }[]; +}) { + const field = useFieldContext(); + const isInvalid = useFieldInvalid(); + + return ( + + + {label} + {required && ' *'} + + + {description && {description}} + {isInvalid && } + + ); +} diff --git a/management/frontend/src/components/forms/fields/slider-field.tsx b/management/frontend/src/components/forms/fields/slider-field.tsx new file mode 100644 index 0000000..38d99b0 --- /dev/null +++ b/management/frontend/src/components/forms/fields/slider-field.tsx @@ -0,0 +1,41 @@ +import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'; +import { Slider } from '@/components/ui/slider'; +import { useFieldContext, type BaseFieldProps } from '@/lib/form-context'; + +export function SliderField({ + label, + description, + min = 0, + max = 100, + step = 1 +}: Omit & { + min?: number; + max?: number; + step?: number; +}) { + const field = useFieldContext(); + const labelId = `${field.name}-label`; + + return ( + + {label} +
+ field.handleChange(Array.isArray(v) ? v[0] : v)} + onBlur={field.handleBlur} + aria-labelledby={labelId} + /> +
+ {min} + {field.state.value} + {max} +
+
+ {description && {description}} +
+ ); +} diff --git a/management/frontend/src/components/forms/fields/switch-field.tsx b/management/frontend/src/components/forms/fields/switch-field.tsx new file mode 100644 index 0000000..7a97de5 --- /dev/null +++ b/management/frontend/src/components/forms/fields/switch-field.tsx @@ -0,0 +1,35 @@ +import { + Field, + FieldContent, + FieldDescription, + FieldError, + FieldLabel +} from '@/components/ui/field'; +import { Switch } from '@/components/ui/switch'; +import { useFieldContext, useFieldInvalid, type BaseFieldProps } from '@/lib/form-context'; + +export function SwitchField({ label, description, required }: BaseFieldProps) { + const field = useFieldContext(); + const isInvalid = useFieldInvalid(); + + return ( + + + + {label} + {required && ' *'} + + {description && {description}} + {isInvalid && } + + + + ); +} diff --git a/management/frontend/src/components/forms/fields/tags-field.tsx b/management/frontend/src/components/forms/fields/tags-field.tsx new file mode 100644 index 0000000..b405439 --- /dev/null +++ b/management/frontend/src/components/forms/fields/tags-field.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { Icons } from '@/components/icons'; +import { useFieldContext, useFieldInvalid, type BaseFieldProps } from '@/lib/form-context'; + +/** + * Free-text tag list over a `string[]` value. Use with `mode='array'` on the + * `` — Enter or the Add button pushes, badges remove. + */ +export function TagsField({ + label, + description, + required, + placeholder = 'Type and press Enter...' +}: BaseFieldProps & { placeholder?: string }) { + const field = useFieldContext(); + const isInvalid = useFieldInvalid(); + const [tagInput, setTagInput] = React.useState(''); + const values = field.state.value || []; + + const addTag = () => { + const tag = tagInput.trim(); + if (tag && !values.includes(tag)) { + field.pushValue(tag); + setTagInput(''); + } + }; + + return ( + + + {label} + {required && ' *'} + +
+ setTagInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addTag(); + } + }} + placeholder={placeholder} + aria-label={`Add a ${label.toLowerCase().replace(/ \*$/, '')}`} + aria-invalid={isInvalid} + aria-describedby={isInvalid ? `${field.name}-error` : undefined} + /> + +
+ {values.length > 0 && ( +
+ {values.map((tag, idx) => ( + + {tag} + + + ))} +
+ )} + {description && {description}} + {isInvalid && } +
+ ); +} diff --git a/management/frontend/src/components/forms/fields/text-field.tsx b/management/frontend/src/components/forms/fields/text-field.tsx new file mode 100644 index 0000000..1c334da --- /dev/null +++ b/management/frontend/src/components/forms/fields/text-field.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; +import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { Spinner } from '@/components/ui/spinner'; +import { useFieldContext, useFieldInvalid, type BaseFieldProps } from '@/lib/form-context'; + +/** + * Text-style input (text, email, password, tel, url, time, number). + * For `type='number'` the value converts at the edge: clearing writes + * `undefined` so a required `z.number({ error: '…' })` reports a human + * message instead of a type error. + */ +export function TextField({ + label, + description, + required, + ...inputProps +}: BaseFieldProps & Omit, 'value' | 'onChange' | 'onBlur'>) { + const field = useFieldContext(); + const isInvalid = useFieldInvalid(); + const isValidating = field.state.meta.isValidating; + + return ( + + + {label} + {required && ' *'} + +
+ + field.handleChange( + inputProps.type === 'number' + ? e.target.value === '' + ? undefined + : Number(e.target.value) + : e.target.value + ) + } + aria-invalid={isInvalid} + aria-describedby={isInvalid ? `${field.name}-error` : undefined} + {...inputProps} + /> + {isValidating && } +
+ {description && {description}} + {isInvalid && } +
+ ); +} diff --git a/management/frontend/src/components/forms/fields/textarea-field.tsx b/management/frontend/src/components/forms/fields/textarea-field.tsx new file mode 100644 index 0000000..740de30 --- /dev/null +++ b/management/frontend/src/components/forms/fields/textarea-field.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'; +import { Textarea } from '@/components/ui/textarea'; +import { useFieldContext, useFieldInvalid, type BaseFieldProps } from '@/lib/form-context'; + +export function TextareaField({ + label, + description, + required, + showCount, + ...textareaProps +}: BaseFieldProps & { + /** Show a character counter (uses `maxLength` as the denominator). */ + showCount?: boolean; +} & Omit, 'value' | 'onChange' | 'onBlur'>) { + const field = useFieldContext(); + const isInvalid = useFieldInvalid(); + + return ( + + + {label} + {required && ' *'} + +