From 368f4f926b2cf01825aedb98db0ae9b661e4b7f6 Mon Sep 17 00:00:00 2001 From: jihyunzep Date: Fri, 10 Apr 2026 16:52:47 +0900 Subject: [PATCH 1/9] Migrate dashboard runtime from Supabase to SQLite --- ...026-04-10-sqlite-runtime-migration-plan.md | 585 ++++++++++++ zeude/Dockerfile | 12 +- zeude/README.md | 36 +- zeude/dashboard/Dockerfile | 14 +- zeude/dashboard/README.md | 48 +- zeude/dashboard/docker-compose.yaml | 17 +- zeude/dashboard/next.config.ts | 1 + zeude/dashboard/package-lock.json | 445 ++++++++- zeude/dashboard/package.json | 4 + .../scripts/migrate-from-supabase.cjs | 244 +++++ zeude/dashboard/scripts/migrate-sqlite.cjs | 174 ++++ .../src/app/api/admin/agents/[id]/route.ts | 36 +- .../src/app/api/admin/agents/route.ts | 45 +- .../api/admin/analytics/efficiency/route.ts | 4 +- .../app/api/admin/analytics/skills/route.ts | 11 +- .../app/api/admin/analytics/usage/route.ts | 4 +- .../src/app/api/admin/cohorts/route.ts | 56 +- .../src/app/api/admin/hooks/[id]/route.ts | 27 +- .../src/app/api/admin/hooks/route.ts | 17 +- .../src/app/api/admin/invites/route.ts | 37 +- .../src/app/api/admin/mcp/[id]/route.ts | 28 +- .../dashboard/src/app/api/admin/mcp/route.ts | 15 +- .../src/app/api/admin/skills/[id]/route.ts | 42 +- .../src/app/api/admin/skills/route.ts | 23 +- .../src/app/api/admin/users/[id]/key/route.ts | 38 +- .../src/app/api/admin/users/[id]/route.ts | 52 +- .../src/app/api/admin/users/route.ts | 52 +- .../src/app/api/auth/callback/route.ts | 41 +- .../src/app/api/auth/logout/route.ts | 6 +- zeude/dashboard/src/app/api/auth/ott/route.ts | 33 +- .../app/api/config/[agentKey]/route.test.ts | 530 ++--------- .../src/app/api/config/[agentKey]/route.ts | 102 +- .../src/app/api/invite/[token]/route.ts | 67 +- .../src/app/api/leaderboard/route.ts | 47 +- .../src/app/api/prompts/[id]/route.ts | 14 +- zeude/dashboard/src/app/api/prompts/route.ts | 12 +- .../src/app/api/skill-rules/route.ts | 33 +- .../src/app/api/skill-suggest/route.ts | 14 +- .../src/app/api/skill-suggestions/route.ts | 12 +- .../src/app/api/status/[agentKey]/route.ts | 46 +- .../src/app/api/user/skills/route.ts | 62 +- zeude/dashboard/src/lib/data/admin-hooks.ts | 22 +- zeude/dashboard/src/lib/data/admin-mcp.ts | 22 +- zeude/dashboard/src/lib/data/admin-skills.ts | 28 +- zeude/dashboard/src/lib/data/admin-team.ts | 31 +- zeude/dashboard/src/lib/db/index.ts | 14 + .../dashboard/src/lib/db/sqlite/connection.ts | 37 + zeude/dashboard/src/lib/db/sqlite/migrator.ts | 179 ++++ .../src/lib/db/sqlite/repositories.ts | 884 ++++++++++++++++++ .../dashboard/src/lib/db/supabase-adapter.ts | 302 ++++++ zeude/dashboard/src/lib/db/types.ts | 148 +++ zeude/dashboard/src/lib/env.ts | 12 +- zeude/dashboard/src/lib/name-resolution.ts | 40 +- zeude/dashboard/src/lib/session.ts | 32 +- 54 files changed, 3495 insertions(+), 1342 deletions(-) create mode 100644 docs/plans/2026-04-10-sqlite-runtime-migration-plan.md create mode 100644 zeude/dashboard/scripts/migrate-from-supabase.cjs create mode 100644 zeude/dashboard/scripts/migrate-sqlite.cjs create mode 100644 zeude/dashboard/src/lib/db/index.ts create mode 100644 zeude/dashboard/src/lib/db/sqlite/connection.ts create mode 100644 zeude/dashboard/src/lib/db/sqlite/migrator.ts create mode 100644 zeude/dashboard/src/lib/db/sqlite/repositories.ts create mode 100644 zeude/dashboard/src/lib/db/supabase-adapter.ts create mode 100644 zeude/dashboard/src/lib/db/types.ts diff --git a/docs/plans/2026-04-10-sqlite-runtime-migration-plan.md b/docs/plans/2026-04-10-sqlite-runtime-migration-plan.md new file mode 100644 index 0000000..b3a9f4d --- /dev/null +++ b/docs/plans/2026-04-10-sqlite-runtime-migration-plan.md @@ -0,0 +1,585 @@ +# SQLite Runtime + Supabase Migration Implementation Plan + +> Status: revised for execution readiness on 2026-04-10 + +**Goal:** Replace Supabase as the runtime operational datastore with SQLite, keep ClickHouse for analytics, preserve the existing client `releases/install.sh` UX, and add a supported migration path from existing Supabase deployments to SQLite. + +**Architecture:** The dashboard runtime becomes `SQLite + ClickHouse`. All operational reads and writes move behind a repository layer so API routes, auth/session code, and mixed analytics helpers stop calling Supabase directly. Supabase remains only as a migration source for existing installations. Release packaging is extended so the dashboard continues to serve CLI artifacts and additionally serves a server bundle plus `install-server.sh` for single-host deployment. + +**Tech Stack:** Next.js App Router, TypeScript, Vitest, Go CLI binaries, SQLite runtime, ClickHouse analytics, shell install scripts, Docker multi-stage release build. + +--- + +## Scope + +In scope: +- Replace runtime operational storage with SQLite. +- Keep ClickHouse analytics code paths and envs. +- Add repository layer for users, sessions, OTT, invites, skills, hooks, MCP servers, agents, cohorts, install status, and related admin metadata. +- Add SQLite schema, migration runner, and bootstrap path. +- Add Supabase-to-SQLite migration tool with dry-run and verification. +- Add server install packaging and `install-server.sh`. +- Keep `/releases/install.sh` and CLI binaries working. + +Out of scope: +- Supporting runtime `postgres` or runtime `supabase` provider selection. +- Replacing ClickHouse analytics with SQLite. +- Re-architecting the CLI sync protocol. +- Multi-node deployment support. + +## Success Criteria + +- Fresh install can run on a single host using SQLite for operational data. +- Existing client install command still works against the new dashboard. +- All runtime operational APIs and helpers no longer depend on `@supabase/supabase-js`. +- Existing analytics pages and routes continue using ClickHouse. +- Existing Supabase deployments can migrate operational data into SQLite with a verifiable tool. +- Route tests, migration tests, and packaging smoke checks cover the high-risk flows. + +## Current State Summary + +Runtime operational logic currently depends directly on Supabase in these areas: +- Auth and session handling: `zeude/dashboard/src/lib/session.ts` +- Supabase client creation: `zeude/dashboard/src/lib/supabase.ts` +- Config sync API: `zeude/dashboard/src/app/api/config/[agentKey]/route.ts` +- Auth callback, OTT issuance, and logout: `zeude/dashboard/src/app/api/auth/**` +- Public invite validation and claim flow: `zeude/dashboard/src/app/api/invite/[token]/route.ts` +- User skill preference APIs: `zeude/dashboard/src/app/api/user/skills/route.ts` +- Status and install-reporting APIs: `zeude/dashboard/src/app/api/status/[agentKey]/route.ts` +- Admin CRUD and helper modules under `zeude/dashboard/src/app/api/admin/**` and `zeude/dashboard/src/lib/data/**` +- Mixed analytics-plus-operational routes: `leaderboard`, `prompts`, `skill-suggestions`, `admin/analytics/**` +- User name resolution helper: `zeude/dashboard/src/lib/name-resolution.ts` + +Analytics already has a partial optional boundary: +- ClickHouse client and analytics helpers: `zeude/dashboard/src/lib/clickhouse.ts` + +Release packaging already serves CLI assets from the dashboard: +- `zeude/Dockerfile` +- `zeude/scripts/build-release.sh` +- `zeude/releases/install.sh` + +## Execution Preconditions + +Lock these before implementation starts: +- Choose the SQLite driver and confirm it works with `node:20-alpine`, Next standalone output, and CI. +- Decide whether install status tables remain in SQLite v1 or are intentionally dropped with route changes. +- Decide whether admin bootstrap is installer-driven, web-first, or both. +- Decide release artifact shape: tarball only, Docker image only, or both. + +If these decisions are not locked first, the plan will churn mid-implementation. + +## SQLite Driver Decision + +Required properties: +- Must work in the Next.js server runtime used by the dashboard. +- Must work inside the current Docker build and runtime model. +- Must support transactions, prepared statements, and predictable file locking behavior. +- Must have a testable story for local dev and CI. + +Recommended default: +- Prefer `better-sqlite3` if it builds cleanly in the current Alpine-based image and standalone bundling includes it correctly. +- If Alpine/native build friction is too high, switch the runtime image from Alpine to Debian slim before implementation rather than forcing a weaker driver choice late. + +Do not start repository implementation until this is decided and proven with a throwaway build spike. + +## Data Model To Preserve In SQLite + +Operational tables that must exist in SQLite unless explicitly removed by product decision: +- `zeude_users` +- `zeude_sessions` +- `zeude_one_time_tokens` +- `zeude_invites` +- `zeude_skills` +- `zeude_hooks` +- `zeude_mcp_servers` +- `zeude_agents` +- `zeude_cohort_members` +- `zeude_mcp_install_status` +- `zeude_hook_install_status` +- any supporting lookup/admin tables referenced by existing APIs + +Field handling rules: +- Keep IDs stable during migration. +- Store array/object fields as JSON text in SQLite. +- Normalize timestamps as ISO 8601 UTC strings. +- Preserve `status`, `role`, `team`, `agent_key`, `disabled_skills`, `files`, `env`, `teams`, `contributors`, and similar fields with the same semantics. +- Add unique indexes for runtime invariants such as `email`, `agent_key`, `token`, and route-level conflict keys. + +## Supabase Semantics Inventory To Reproduce + +This migration is not just table copying. The runtime currently depends on Supabase/Postgres semantics that must be reimplemented explicitly: + +- Atomic skill disable toggle via Postgres RPC `toggle_disabled_skill` +- `upsert` with `onConflict` and duplicate-ignore behavior for cohort registration and status reporting +- Joined selects used by session/auth callbacks +- Count-only reads such as `select(..., { count: 'exact', head: true })` +- Array containment filters such as `teams.cs` +- Claim-first invite flow with rollback on downstream failure +- Bulk ID and email lookups used by analytics name resolution +- Stable ordering relied on by config sync hashing or response determinism + +Every one of these must map to either: +- a repository method plus transaction boundary +- a unique index plus conflict handling policy +- or a deliberate behavior change documented in advance + +## Environment Model + +New runtime env: +- `DATABASE_PROVIDER=sqlite` +- `DATABASE_PATH=/var/lib/zeude/zeude.db` +- `SESSION_SECRET=` +- `NEXT_PUBLIC_APP_URL=` +- `CLICKHOUSE_URL`, `CLICKHOUSE_USER`, `CLICKHOUSE_PASSWORD`, `CLICKHOUSE_DATABASE` + +Migration-only env or flags: +- `SUPABASE_URL` +- `SUPABASE_SERVICE_ROLE_KEY` + +Runtime rules: +- Supabase env validation must be removed from startup for SQLite mode. +- ClickHouse remains optional for runtime startup but required for analytics features. +- SQLite path creation, permissions, and backup location must be part of installer behavior. + +## Test Strategy + +Meaningful coverage is possible and required. + +Add or update tests in these layers: +- Repository unit tests for SQLite CRUD, filtering, and transaction behavior. +- Route tests for auth, config sync, admin CRUD, invite claim, skill toggle, and status reporting. +- Migration tests that seed Supabase-shaped records, import them into SQLite, and verify row counts and transformed fields. +- Packaging smoke tests for release artifacts and installer assumptions. +- Build verification for the chosen SQLite driver inside the target runtime image. + +Minimum required automated verification: +- `vitest` on all touched route tests. +- New SQLite repository tests. +- New migration command tests. +- Existing ClickHouse schema/config tests must still pass. +- `npm run build` for dashboard after runtime env cleanup. +- `go test ./...` for CLI/install code touched by release changes. + +## Phase 0: Spike and Semantic Inventory + +This is a required phase before the current Task 1. + +**Files:** +- Create: `docs/plans/2026-04-10-sqlite-runtime-semantic-inventory.md` +- Optionally create: temporary driver spike files under `zeude/dashboard/scripts/` or `zeude/dashboard/src/lib/db/sqlite/` + +**Steps:** +1. Confirm the exact set of Supabase-backed routes and helpers still in runtime use. +2. Inventory every Postgres/Supabase behavior that needs explicit SQLite replacement. +3. Run a small driver compatibility spike against the current Docker/runtime model. +4. Lock the SQLite driver decision before schema and repository work starts. +5. Record any deliberate v1 exclusions, if there are any. + +**Verification:** +- Produce a short semantic inventory doc and a passing driver smoke result. +- Do not start schema work until this phase is complete. + +## Task 1: Create the planning and scaffolding directories + +**Files:** +- Keep: `docs/plans/2026-04-10-sqlite-runtime-migration-plan.md` +- Create: `zeude/dashboard/src/lib/db/` +- Create: `zeude/dashboard/src/lib/db/sqlite/` +- Create: `zeude/dashboard/src/lib/db/repositories/` +- Create: `zeude/dashboard/src/lib/db/migration/` +- Create: `zeude/dashboard/scripts/` +- Create: `zeude/server-install/` + +**Steps:** +1. Create the DB-layer, migration, and packaging directories. +2. Add placeholder modules so import paths are stable before larger refactors. +3. Commit only directory and scaffold additions. + +**Verification:** +- Run: `find zeude/dashboard/src/lib/db -maxdepth 3 -type d | sort` +- Expected: `repositories`, `sqlite`, and `migration` directories exist. + +## Task 2: Introduce the operational DB boundary + +**Files:** +- Create: `zeude/dashboard/src/lib/db/types.ts` +- Create: `zeude/dashboard/src/lib/db/index.ts` +- Create repository interfaces for: + - `users` + - `sessions` + - `tokens` + - `invites` + - `skills` + - `hooks` + - `mcp` + - `agents` + - `cohorts` + - `install-status` +- Modify: `zeude/dashboard/src/lib/session.ts` +- Modify: `zeude/dashboard/src/app/api/config/[agentKey]/route.ts` +- Modify: `zeude/dashboard/src/lib/name-resolution.ts` + +**Steps:** +1. Define repository interfaces that cover all current operational queries and writes, not just simple CRUD. +2. Add a runtime `getOperationalDb()` factory that returns SQLite-backed repositories. +3. Refactor one narrow path first, preferably config sync, to prove the boundary. +4. Refactor session/auth helpers and name-resolution to depend on repositories, not Supabase. +5. Commit the boundary before converting every route. + +**Tests:** +- Create: `zeude/dashboard/src/lib/db/repositories/repositories.test.ts` +- Modify: `zeude/dashboard/src/app/api/config/[agentKey]/route.test.ts` + +**Verification:** +- Run: `npm test -- --runInBand src/app/api/config/[agentKey]/route.test.ts` +- Expected: config route still passes after removing direct Supabase dependency from the route. + +## Task 3: Add SQLite connection and migration runner + +**Files:** +- Create: `zeude/dashboard/src/lib/db/sqlite/connection.ts` +- Create: `zeude/dashboard/src/lib/db/sqlite/migrator.ts` +- Create: `zeude/dashboard/src/lib/db/sqlite/migrations/0001_initial_operational_schema.sql` +- Create: `zeude/dashboard/src/lib/db/sqlite/migrations/0002_indexes_and_conflicts.sql` +- Create: `zeude/dashboard/src/lib/db/sqlite/migrations/0003_seed_defaults.sql` +- Create: `zeude/dashboard/scripts/migrate-sqlite.ts` +- Modify: `zeude/dashboard/package.json` + +**Steps:** +1. Implement one connection helper with configurable DB path. +2. Add migrations for all runtime tables, indexes, and conflict constraints. +3. Encode route-critical invariants in schema, not just in TypeScript. +4. Build a migration runner script and package command. +5. Ensure migrations are idempotent enough for installer use. + +**Tests:** +- Create: `zeude/dashboard/src/lib/db/sqlite/migrator.test.ts` + +**Verification:** +- Run: `npm run migrate:sqlite` +- Expected: SQLite file is created and schema migrations apply successfully. + +## Task 4: Implement SQLite repositories + +**Files:** +- Create SQLite-backed repositories for: + - `users` + - `sessions` + - `tokens` + - `invites` + - `skills` + - `hooks` + - `mcp` + - `agents` + - `cohorts` + - `install-status` +- Create: `zeude/dashboard/src/lib/db/sqlite/admin-support.ts` + +**Steps:** +1. Implement repository methods for each operational entity. +2. Mirror current filtering semantics for `is_global`, `teams`, `status`, contributors, and disabled skills. +3. Preserve deterministic sorting where config sync depends on stable hashes. +4. Add helper functions for JSON column serialization and deserialization. +5. Add transaction wrappers for invite claim, skill toggle, status upsert, and cohort registration. +6. Commit by logical slice if needed: auth/session first, then config data, then admin/public CRUD. + +**Tests:** +- Create: `zeude/dashboard/src/lib/db/sqlite/users-repository.test.ts` +- Create: `zeude/dashboard/src/lib/db/sqlite/sessions-repository.test.ts` +- Create: `zeude/dashboard/src/lib/db/sqlite/invites-repository.test.ts` +- Create: `zeude/dashboard/src/lib/db/sqlite/skills-repository.test.ts` +- Create: `zeude/dashboard/src/lib/db/sqlite/config-repositories.test.ts` +- Create: `zeude/dashboard/src/lib/db/sqlite/install-status-repository.test.ts` + +**Verification:** +- Run: `npm test -- --runInBand src/lib/db/sqlite` +- Expected: repository tests pass for inserts, lookups, updates, filters, deletes, and concurrent conflict paths. + +## Task 5: Refactor auth, session, and public invite flows to SQLite + +**Files:** +- Modify: `zeude/dashboard/src/lib/session.ts` +- Modify: `zeude/dashboard/src/app/api/auth/ott/route.ts` +- Modify: `zeude/dashboard/src/app/api/auth/callback/route.ts` +- Modify: `zeude/dashboard/src/app/api/auth/logout/route.ts` +- Modify: `zeude/dashboard/src/app/api/invite/[token]/route.ts` +- Modify: `zeude/dashboard/src/app/(auth)/auth/page.tsx` if needed for error messages + +**Steps:** +1. Replace Supabase lookups in OTT issuance with repository calls. +2. Replace session creation, validation, and logout flows with SQLite repository calls. +3. Move invite validation and claim flow to a transaction-backed SQLite implementation. +4. Keep cookie behavior identical to avoid client-facing regressions. +5. Remove Supabase-specific connection error handling from runtime auth paths. +6. Commit auth/session/public invite changes separately from admin CRUD. + +**Tests:** +- Create: `zeude/dashboard/src/app/api/auth/ott/route.test.ts` +- Create: `zeude/dashboard/src/app/api/auth/callback/route.test.ts` +- Create: `zeude/dashboard/src/app/api/invite/[token]/route.test.ts` +- Modify: existing auth/session tests if added during refactor + +**Verification:** +- Run: `npm test -- --runInBand src/app/api/auth src/app/api/invite` +- Expected: OTT creation, callback, invalid token, logout, invite validation, invite double-claim, and invite rollback flows pass. + +## Task 6: Refactor config sync, status, and admin/public operational APIs to SQLite + +**Files:** +- Modify: `zeude/dashboard/src/app/api/config/[agentKey]/route.ts` +- Modify: `zeude/dashboard/src/app/api/status/[agentKey]/route.ts` +- Modify: `zeude/dashboard/src/app/api/user/skills/route.ts` +- Modify: `zeude/dashboard/src/app/api/skill-rules/route.ts` +- Modify: `zeude/dashboard/src/app/api/skill-suggest/route.ts` +- Modify: `zeude/dashboard/src/app/api/skill-suggestions/route.ts` +- Modify: `zeude/dashboard/src/app/api/admin/agents/route.ts` +- Modify: `zeude/dashboard/src/app/api/admin/agents/[id]/route.ts` +- Modify: `zeude/dashboard/src/app/api/admin/hooks/route.ts` +- Modify: `zeude/dashboard/src/app/api/admin/hooks/[id]/route.ts` +- Modify: `zeude/dashboard/src/app/api/admin/skills/route.ts` +- Modify: `zeude/dashboard/src/app/api/admin/skills/[id]/route.ts` +- Modify: `zeude/dashboard/src/app/api/admin/mcp/route.ts` +- Modify: `zeude/dashboard/src/app/api/admin/mcp/[id]/route.ts` +- Modify: `zeude/dashboard/src/app/api/admin/users/route.ts` +- Modify: `zeude/dashboard/src/app/api/admin/users/[id]/route.ts` +- Modify: `zeude/dashboard/src/app/api/admin/users/[id]/key/route.ts` +- Modify: `zeude/dashboard/src/app/api/admin/invites/route.ts` +- Modify: `zeude/dashboard/src/app/api/admin/cohorts/route.ts` +- Modify: `zeude/dashboard/src/lib/data/*.ts` as needed + +**Steps:** +1. Convert config sync route fully to repository-backed reads. +2. Convert status reporting and user skill APIs. +3. Replace skill toggle RPC semantics with a repository transaction or conflict-safe update. +4. Convert admin CRUD routes one resource at a time. +5. Keep response shapes stable to avoid frontend churn. +6. Remove unused `supabase.ts` imports from refactored routes and helpers. + +**Tests:** +- Modify existing route tests: + - `zeude/dashboard/src/app/api/admin/agents/[id]/route.test.ts` + - `zeude/dashboard/src/app/api/admin/agents/route.test.ts` + - `zeude/dashboard/src/app/api/admin/hooks/[id]/route.test.ts` + - `zeude/dashboard/src/app/api/admin/invites/route.test.ts` + - `zeude/dashboard/src/app/api/admin/skills/[id]/route.test.ts` + - `zeude/dashboard/src/app/api/admin/skills/route.test.ts` + - `zeude/dashboard/src/app/api/admin/users/[id]/route.test.ts` + - `zeude/dashboard/src/app/api/config/[agentKey]/route.test.ts` +- Create new tests where coverage is missing: + - `status/[agentKey]` + - `user/skills` + - `admin/cohorts` + - `admin/users/[id]/key` + - `skill-rules` + - `skill-suggest` + +**Verification:** +- Run: `npm test -- --runInBand src/app/api/admin src/app/api/config src/app/api/status src/app/api/user src/app/api/skill-rules src/app/api/skill-suggest` +- Expected: all operational route tests pass using SQLite-backed setup. + +## Task 7: Remove Supabase runtime validation and runtime-only dependencies + +**Files:** +- Modify: `zeude/dashboard/src/lib/env.ts` +- Modify or delete: `zeude/dashboard/src/lib/supabase.ts` +- Modify: `zeude/dashboard/package.json` +- Modify: lockfile used by the dashboard + +**Steps:** +1. Change env validation so runtime no longer requires Supabase keys. +2. Demote `supabase.ts` to migration-only utility or delete it if fully replaced. +3. Remove runtime imports of `@supabase/supabase-js` from dashboard code. +4. Keep the library only if the migration tool still uses it. +5. Commit dependency and env cleanup separately. + +**Tests:** +- Create: `zeude/dashboard/src/lib/env.test.ts` + +**Verification:** +- Run: `npm run build` +- Expected: production build succeeds without Supabase runtime envs. + +## Task 8: Implement Supabase-to-SQLite migration tool + +**Files:** +- Create: `zeude/dashboard/src/lib/db/migration/supabase-client.ts` +- Create: `zeude/dashboard/src/lib/db/migration/export.ts` +- Create: `zeude/dashboard/src/lib/db/migration/transform.ts` +- Create: `zeude/dashboard/src/lib/db/migration/import.ts` +- Create: `zeude/dashboard/src/lib/db/migration/verify.ts` +- Create: `zeude/dashboard/scripts/migrate-supabase-to-sqlite.ts` +- Modify: `zeude/dashboard/package.json` + +**Steps:** +1. Add a migration-only Supabase client wrapper. +2. Export operational tables in a deterministic order respecting foreign-key and conflict dependencies. +3. Transform Supabase rows to SQLite row shapes, including JSON field normalization. +4. Import into a fresh SQLite DB inside a transaction per table or logical batch. +5. Add row-count verification and a human-readable summary report. +6. Support `--dry-run`, `--force`, and `--report `. +7. Document cutover sequence: freeze writes, export, import, verify, switch runtime, smoke test, rollback trigger. + +**Tests:** +- Create: `zeude/dashboard/src/lib/db/migration/transform.test.ts` +- Create: `zeude/dashboard/src/lib/db/migration/verify.test.ts` +- Create: `zeude/dashboard/scripts/migrate-supabase-to-sqlite.test.ts` + +**Verification:** +- Run: `npm test -- --runInBand src/lib/db/migration` +- Run: `node ./scripts/migrate-supabase-to-sqlite.ts --dry-run --supabase-url=... --supabase-service-role-key=... --sqlite-path=/tmp/zeude.db` +- Expected: dry-run prints counts and planned table imports without mutating SQLite. + +## Task 9: Keep ClickHouse intact and explicitly separate analytics from operational DB + +**Files:** +- Modify: `zeude/dashboard/src/lib/clickhouse.ts` +- Modify: analytics routes under `zeude/dashboard/src/app/api/admin/analytics/**` +- Modify: `zeude/dashboard/src/app/api/leaderboard/route.ts` +- Modify: `zeude/dashboard/src/app/api/prompts/route.ts` +- Modify: `zeude/dashboard/src/app/api/prompts/[id]/route.ts` +- Modify: `zeude/dashboard/src/lib/name-resolution.ts` + +**Steps:** +1. Audit analytics routes that currently use both ClickHouse and Supabase. +2. Replace operational user/name lookups with SQLite repositories. +3. Leave ClickHouse queries unchanged unless absolutely required. +4. Ensure analytics pages still degrade cleanly when ClickHouse is absent. +5. Preserve email fallback behavior for legacy analytics rows. + +**Tests:** +- Create or update route tests for mixed analytics-plus-operational lookups. + +**Verification:** +- Run: `npm test -- --runInBand src/app/api/admin/analytics src/app/api/leaderboard src/app/api/prompts` +- Expected: analytics routes still resolve user metadata through SQLite and metrics through ClickHouse mocks. + +## Task 10: Add server install packaging + +**Files:** +- Create: `zeude/server-install/install-server.sh` +- Create: `zeude/server-install/zeude.service` +- Create: `zeude/server-install/bootstrap-admin.sh` or `.ts` +- Create: `zeude/server-install/README.md` +- Modify: `zeude/Dockerfile` +- Modify: `zeude/scripts/build-release.sh` + +**Steps:** +1. Package a server bundle containing the built dashboard standalone output and support scripts. +2. Add `install-server.sh` that downloads the bundle, writes env/config, creates the SQLite path, runs migrations, and registers `systemd`. +3. Add admin bootstrap support so a fresh server can be initialized deterministically. +4. Ensure the dashboard still serves CLI binaries under `/releases`. +5. Verify the chosen SQLite driver and its native assets are included in the bundle and runtime image. +6. Commit release packaging separately from application refactor. + +**Tests:** +- Create: `zeude/server-install/install-server-smoke.sh` + +**Verification:** +- Run: `bash zeude/scripts/build-release.sh` +- Expected: release directory contains existing CLI assets plus server bundle and installer. + +## Task 11: Preserve client install UX + +**Files:** +- Modify: `zeude/releases/install.sh` +- Modify: `zeude/scripts/install.sh` +- Modify: `zeude/internal/autoupdate/autoupdate.go` +- Modify: docs where install command is shown + +**Steps:** +1. Keep the client installer contract stable. +2. Update defaults or messaging only where required by the new SQLite server runtime. +3. Ensure agent key flow still works with SQLite-backed OTT issuance. +4. Verify no regression in URL layout or release paths. + +**Tests:** +- Add shell smoke checks for `releases/install.sh` path assumptions. +- Re-run relevant Go tests under `zeude/internal`. + +**Verification:** +- Run: `go test ./...` +- Expected: Go CLI/install-related tests still pass. + +## Task 12: Update docs for fresh install and migration operators + +**Files:** +- Modify: `README.md` +- Modify: `zeude/README.md` +- Create: `zeude/docs/sqlite-self-host.md` +- Create: `zeude/docs/supabase-to-sqlite-migration.md` +- Create: `zeude/docs/release-artifacts.md` + +**Steps:** +1. Rewrite Quick Start around SQLite single-host runtime. +2. Document `install-server.sh`. +3. Document the preserved client installer flow. +4. Document migration prerequisites, dry-run, cutover, rollback, and verification. +5. Document ClickHouse as a continuing analytics dependency. + +**Verification:** +- Manual review that all install commands reference existing artifacts and env names. + +## Suggested Commit Sequence + +1. `docs: lock sqlite runtime migration preconditions` +2. `chore: scaffold sqlite runtime db layer` +3. `refactor: add operational repository interfaces` +4. `feat: add sqlite schema and migration runner` +5. `feat: implement sqlite repositories for runtime data` +6. `refactor: move auth session and invite flows to sqlite` +7. `refactor: migrate config status and admin apis off supabase` +8. `feat: add supabase to sqlite migration tool` +9. `build: add server installer and release bundle` +10. `docs: rewrite install and migration guides` + +## Risks To Review Before Execution + +- SQLite library choice may affect deployment size, native build complexity, and standalone bundling. +- Invite claim, cohort registration, skill toggle, and install-status writes all depend on conflict-safe behavior that must be reimplemented carefully. +- Some analytics routes depend on both ClickHouse and operational lookups, so partial migration can leave runtime still coupled to Supabase. +- The migration tool must define exactly which operational tables are source-of-truth and which ClickHouse data remains untouched. +- Installer behavior will vary across Linux distributions; `systemd` should be the primary supported target. +- SQLite is acceptable for the declared single-host scope, but backup, file permissions, and corruption recovery need explicit operational docs. + +## Open Decisions To Lock Before Coding + +- Exact SQLite driver package and base image implications. +- Whether `zeude_mcp_install_status` and `zeude_hook_install_status` are retained in v1. +- Whether admin bootstrap is web-first or installer-driven. +- Whether the server bundle is a tarball only or also shipped as a Docker image. +- Whether invite and cohort data are fully migrated or intentionally re-seeded. + +## Cutover and Rollback Requirements + +Cutover must be documented and tested: +- Freeze operational writes on the old runtime. +- Run Supabase export and SQLite import. +- Verify row counts and spot-check high-risk entities. +- Switch runtime env to SQLite. +- Run smoke tests for auth, config sync, invite claim, admin CRUD, and analytics pages. + +Rollback must be documented and tested: +- Define the exact point at which rollback is still safe. +- Preserve the pre-cutover Supabase environment and dashboard artifact version. +- Keep the generated SQLite file and verification report for incident analysis. + +## Recommended Test Matrix + +- Fresh SQLite install on macOS or Linux dev machine. +- Fresh SQLite server bundle install on Linux with `systemd`. +- Existing client `install.sh` against new dashboard. +- Supabase dry-run migration. +- Supabase real migration into empty SQLite DB. +- Cutover smoke test: auth, config sync, invite claim, user skill toggle, admin CRUD, analytics pages. +- Conflict-path tests: duplicate cohort registration, invite double-claim, repeated status upsert, concurrent skill toggle. + +## Execution Recommendation + +Use this plan only after Phase 0 is complete. The critical path is: +1. Lock driver and runtime packaging constraints. +2. Build the DB boundary and schema. +3. Move auth/config/invite/status flows first. +4. Remove residual Supabase runtime imports. +5. Add migration tooling and packaging last. + +Do not start with admin CRUD breadth before auth, invite, and config-sync semantics are proven on SQLite. diff --git a/zeude/Dockerfile b/zeude/Dockerfile index 0051548..de50400 100644 --- a/zeude/Dockerfile +++ b/zeude/Dockerfile @@ -36,7 +36,7 @@ COPY scripts/install.sh /releases/install.sh COPY scripts/uninstall.sh /releases/uninstall.sh # Node build stage -FROM node:20-alpine AS builder +FROM node:20-bookworm-slim AS builder WORKDIR /app @@ -53,15 +53,17 @@ COPY --from=go-builder /releases ./public/releases RUN npm run build # Production stage -FROM node:20-alpine AS runner +FROM node:20-bookworm-slim AS runner WORKDIR /app ENV NODE_ENV=production # Create non-root user -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 nextjs +RUN groupadd --system --gid 1001 nodejs && \ + useradd --system --uid 1001 --gid 1001 nextjs && \ + mkdir -p /var/lib/zeude && \ + chown -R nextjs:nodejs /var/lib/zeude # Copy built assets COPY --from=builder /app/public ./public @@ -74,5 +76,7 @@ EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" +ENV DATABASE_PROVIDER=sqlite +ENV DATABASE_PATH=/var/lib/zeude/zeude.db CMD ["node", "server.js"] diff --git a/zeude/README.md b/zeude/README.md index f632b2f..99cfd9b 100644 --- a/zeude/README.md +++ b/zeude/README.md @@ -9,7 +9,7 @@ Zeude gives engineering teams visibility into how Claude Code and OpenAI Codex a - **Understand usage** — See who's using what, how much it costs, and which prompts work best - **Distribute knowledge** — Push skills, MCP servers, hooks, and agent profiles to your whole team - **Support multiple tools** — Monitor both Claude Code and OpenAI Codex from one place -- **Own your data** — Self-hosted with Supabase + ClickHouse, no data leaves your infrastructure +- **Own your data** — Self-hosted with SQLite + ClickHouse, no data leaves your infrastructure ## Features @@ -32,7 +32,7 @@ Developer Machine Self-Hosted Infrastructure ┌──────────────────────┐ ┌────────────────────────────┐ │ │ │ │ │ claude/codex (shim) │──── on startup ──▶│ Zeude Dashboard (Next.js) │ -│ ~/.zeude/bin/ │ sync config │ ├── Supabase (users, config) +│ ~/.zeude/bin/ │ sync config │ ├── SQLite (users, config) │ │ │ │ └── ClickHouse (telemetry)│ │ ▼ │ │ │ │ real claude/codex │ │ OTel Collector │ @@ -59,11 +59,20 @@ When you run `claude` (or `codex`), the Zeude shim: ```bash cd dashboard -cp .env.example .env.local # configure Supabase + ClickHouse URLs +cp .env.example .env.local npm install +npm run migrate:sqlite npm run dev ``` +For a single-host production deploy: + +```bash +cd dashboard +cp .env.example .env +docker compose up -d --build +``` + ### 2. Install the CLI shim ```bash @@ -95,7 +104,7 @@ zeude/ ├── dashboard/ │ ├── src/app/ # Next.js App Router pages & API routes │ ├── clickhouse/ # ClickHouse schema, migrations, and tests -│ └── supabase/ # Supabase migrations +│ └── supabase/ # Legacy Supabase schema used for one-time migration ├── scripts/ # Build and install scripts ├── deployments/ # OTel Collector config └── Dockerfile # Multi-platform binary builder @@ -129,6 +138,8 @@ dashboard_url=https://your-dashboard-url ```bash cd dashboard npm install +cp .env.example .env.local +npm run migrate:sqlite npm run dev # http://localhost:3000 # Run tests @@ -138,6 +149,23 @@ npm test # 202 vitest tests SKIP_AUTH=true MOCK_API=true npm run dev ``` +### Existing Supabase -> SQLite migration + +```bash +cd dashboard +SUPABASE_URL=... \ +SUPABASE_SERVICE_ROLE_KEY=... \ +DATABASE_PATH=.data/zeude.db \ +npm run migrate:supabase-to-sqlite -- --dry-run + +SUPABASE_URL=... \ +SUPABASE_SERVICE_ROLE_KEY=... \ +DATABASE_PATH=.data/zeude.db \ +npm run migrate:supabase-to-sqlite +``` + +Use `--force` only when you intentionally want to import into a non-empty SQLite DB. + ### Go binaries ```bash diff --git a/zeude/dashboard/Dockerfile b/zeude/dashboard/Dockerfile index cca2c99..1c20789 100644 --- a/zeude/dashboard/Dockerfile +++ b/zeude/dashboard/Dockerfile @@ -2,7 +2,7 @@ # This file is for local dashboard-only development # Build stage -FROM node:20-alpine AS builder +FROM node:20-bookworm-slim AS builder WORKDIR /app @@ -17,15 +17,17 @@ COPY . . RUN npm run build # Production stage -FROM node:20-alpine AS runner +FROM node:20-bookworm-slim AS runner WORKDIR /app ENV NODE_ENV=production -# Create non-root user -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 nextjs +# Create non-root user and SQLite data dir +RUN groupadd --system --gid 1001 nodejs && \ + useradd --system --uid 1001 --gid 1001 nextjs && \ + mkdir -p /var/lib/zeude && \ + chown -R nextjs:nodejs /var/lib/zeude # Copy built assets COPY --from=builder /app/public ./public @@ -38,5 +40,7 @@ EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" +ENV DATABASE_PROVIDER=sqlite +ENV DATABASE_PATH=/var/lib/zeude/zeude.db CMD ["node", "server.js"] diff --git a/zeude/dashboard/README.md b/zeude/dashboard/README.md index e215bc4..14246d0 100644 --- a/zeude/dashboard/README.md +++ b/zeude/dashboard/README.md @@ -1,36 +1,40 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Zeude Dashboard -## Getting Started +Operational state lives in SQLite. Analytics stay in ClickHouse. -First, run the development server: +## Local development ```bash +cp .env.example .env.local +npm install +npm run migrate:sqlite npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +Default local SQLite path is `.data/zeude.db`. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +## One-time migration from Supabase -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. - -## Learn More - -To learn more about Next.js, take a look at the following resources: +```bash +SUPABASE_URL=... \ +SUPABASE_SERVICE_ROLE_KEY=... \ +DATABASE_PATH=.data/zeude.db \ +npm run migrate:supabase-to-sqlite -- --dry-run + +SUPABASE_URL=... \ +SUPABASE_SERVICE_ROLE_KEY=... \ +DATABASE_PATH=.data/zeude.db \ +npm run migrate:supabase-to-sqlite +``` -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +Use `--force` only if the target SQLite DB already has data and you intend to replace conflicting rows. -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +## Production -## Deploy on Vercel +`docker-compose.yaml` now assumes: -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +- `DATABASE_PROVIDER=sqlite` +- `DATABASE_PATH=/var/lib/zeude/zeude.db` +- a persistent volume mounted at `/var/lib/zeude` -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +ClickHouse remains external and must still be configured via env vars. diff --git a/zeude/dashboard/docker-compose.yaml b/zeude/dashboard/docker-compose.yaml index 473d386..25febaf 100644 --- a/zeude/dashboard/docker-compose.yaml +++ b/zeude/dashboard/docker-compose.yaml @@ -1,5 +1,3 @@ -version: '3.8' - services: dashboard: build: @@ -9,18 +7,25 @@ services: - "3000:3000" environment: - NODE_ENV=production - - SUPABASE_URL=${SUPABASE_URL} - - SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY} - - SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY} + - DATABASE_PROVIDER=sqlite + - DATABASE_PATH=/var/lib/zeude/zeude.db + - SESSION_SECRET=${SESSION_SECRET} - CLICKHOUSE_URL=${CLICKHOUSE_URL} - CLICKHOUSE_USER=${CLICKHOUSE_USER} - CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD} - CLICKHOUSE_DATABASE=${CLICKHOUSE_DATABASE} - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} + - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-} + - OPENROUTER_MODEL=${OPENROUTER_MODEL:-anthropic/claude-3.5-sonnet} + volumes: + - zeude_data:/var/lib/zeude restart: unless-stopped healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"] + test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] interval: 30s timeout: 10s retries: 3 start_period: 40s + +volumes: + zeude_data: diff --git a/zeude/dashboard/next.config.ts b/zeude/dashboard/next.config.ts index 89fbaa1..819631d 100644 --- a/zeude/dashboard/next.config.ts +++ b/zeude/dashboard/next.config.ts @@ -7,6 +7,7 @@ const withBundleAnalyzer = bundleAnalyzer({ const nextConfig: NextConfig = { output: 'standalone', + serverExternalPackages: ['better-sqlite3'], experimental: { optimizePackageImports: ['recharts', 'lucide-react'], }, diff --git a/zeude/dashboard/package-lock.json b/zeude/dashboard/package-lock.json index 6083238..597b031 100644 --- a/zeude/dashboard/package-lock.json +++ b/zeude/dashboard/package-lock.json @@ -19,6 +19,7 @@ "@supabase/supabase-js": "^2.89.0", "@tanstack/react-query": "^5.95.0", "@tanstack/react-query-devtools": "^5.95.0", + "better-sqlite3": "^11.10.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.562.0", @@ -32,6 +33,7 @@ "devDependencies": { "@next/bundle-analyzer": "^16.2.1", "@tailwindcss/postcss": "^4", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -3486,6 +3488,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -4667,6 +4679,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.9.10", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.10.tgz", @@ -4676,6 +4708,37 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -4734,6 +4797,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -4841,6 +4928,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -5147,6 +5240,30 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -5194,7 +5311,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -5255,6 +5371,15 @@ "dev": true, "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.4", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", @@ -5926,6 +6051,15 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -6010,6 +6144,12 @@ "node": ">=16.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -6077,6 +6217,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -6232,6 +6378,12 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -6438,6 +6590,26 @@ "node": ">=20.0.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6485,6 +6657,18 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -7543,6 +7727,18 @@ "node": ">=8.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -7560,12 +7756,17 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -7601,6 +7802,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -7705,6 +7912,30 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -7846,6 +8077,15 @@ ], "license": "MIT" }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/opener": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", @@ -8029,6 +8269,33 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8051,6 +8318,16 @@ "react-is": "^16.13.1" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8082,6 +8359,30 @@ ], "license": "MIT" }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", @@ -8201,6 +8502,20 @@ } } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/recharts": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz", @@ -8426,6 +8741,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -8690,6 +9025,51 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sirv": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", @@ -8749,6 +9129,15 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -8965,6 +9354,34 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -9114,6 +9531,18 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", @@ -9406,6 +9835,12 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", @@ -10057,6 +10492,12 @@ "node": ">=0.10.0" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", diff --git a/zeude/dashboard/package.json b/zeude/dashboard/package.json index 55a9d41..4358d3c 100644 --- a/zeude/dashboard/package.json +++ b/zeude/dashboard/package.json @@ -7,6 +7,8 @@ "build": "next build", "start": "next start", "lint": "eslint", + "migrate:sqlite": "node ./scripts/migrate-sqlite.cjs", + "migrate:supabase-to-sqlite": "node ./scripts/migrate-from-supabase.cjs", "test": "vitest run", "test:watch": "vitest" }, @@ -22,6 +24,7 @@ "@supabase/supabase-js": "^2.89.0", "@tanstack/react-query": "^5.95.0", "@tanstack/react-query-devtools": "^5.95.0", + "better-sqlite3": "^11.10.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.562.0", @@ -35,6 +38,7 @@ "devDependencies": { "@next/bundle-analyzer": "^16.2.1", "@tailwindcss/postcss": "^4", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/zeude/dashboard/scripts/migrate-from-supabase.cjs b/zeude/dashboard/scripts/migrate-from-supabase.cjs new file mode 100644 index 0000000..7aa437c --- /dev/null +++ b/zeude/dashboard/scripts/migrate-from-supabase.cjs @@ -0,0 +1,244 @@ +const { createClient } = require('@supabase/supabase-js') +const { ensureSqliteReady, resolveDatabasePath } = require('./migrate-sqlite.cjs') + +const PAGE_SIZE = 500 +const args = new Set(process.argv.slice(2)) +const dryRun = args.has('--dry-run') +const force = args.has('--force') + +const SUPABASE_URL = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL +const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY + +const TABLES = [ + { + source: 'zeude_users', + target: 'zeude_users', + orderBy: 'created_at', + columns: ['id', 'email', 'name', 'agent_key', 'team', 'role', 'status', 'disabled_skills', 'invited_by', 'created_at', 'updated_at'], + serialize: row => ({ + ...row, + disabled_skills: JSON.stringify(row.disabled_skills || []), + }), + }, + { + source: 'zeude_sessions', + target: 'zeude_sessions', + orderBy: 'created_at', + columns: ['id', 'token', 'user_id', 'expires_at', 'created_at'], + }, + { + source: 'zeude_one_time_tokens', + target: 'zeude_one_time_tokens', + orderBy: 'created_at', + columns: ['id', 'token', 'user_id', 'expires_at', 'created_at'], + }, + { + source: 'zeude_invites', + target: 'zeude_invites', + orderBy: 'created_at', + columns: ['id', 'token', 'team', 'role', 'created_by', 'expires_at', 'used_at', 'used_by', 'created_at'], + }, + { + source: 'zeude_skills', + target: 'zeude_skills', + orderBy: 'created_at', + columns: [ + 'id', 'name', 'slug', 'description', 'content', 'files', 'teams', 'is_global', 'status', + 'created_by', 'created_at', 'updated_at', 'keywords', 'primary_keywords', 'secondary_keywords', + 'hint', 'is_general', 'is_command', 'contributors', + ], + serialize: row => ({ + ...row, + files: JSON.stringify(row.files || {}), + teams: JSON.stringify(row.teams || []), + is_global: row.is_global ? 1 : 0, + keywords: JSON.stringify(row.keywords || []), + primary_keywords: JSON.stringify(row.primary_keywords || []), + secondary_keywords: JSON.stringify(row.secondary_keywords || []), + is_general: row.is_general ? 1 : 0, + is_command: row.is_command ? 1 : 0, + contributors: JSON.stringify(row.contributors || []), + }), + }, + { + source: 'zeude_hooks', + target: 'zeude_hooks', + orderBy: 'created_at', + columns: ['id', 'name', 'event', 'description', 'script_content', 'script_type', 'env', 'teams', 'is_global', 'status', 'created_by', 'created_at', 'updated_at'], + serialize: row => ({ + ...row, + env: JSON.stringify(row.env || {}), + teams: JSON.stringify(row.teams || []), + is_global: row.is_global ? 1 : 0, + }), + }, + { + source: 'zeude_mcp_servers', + target: 'zeude_mcp_servers', + orderBy: 'created_at', + columns: ['id', 'name', 'url', 'command', 'args', 'env', 'teams', 'is_global', 'status', 'created_by', 'created_at', 'updated_at'], + serialize: row => ({ + ...row, + args: JSON.stringify(row.args || []), + env: JSON.stringify(row.env || {}), + teams: JSON.stringify(row.teams || []), + is_global: row.is_global ? 1 : 0, + }), + }, + { + source: 'zeude_agents', + target: 'zeude_agents', + orderBy: 'created_at', + columns: ['id', 'name', 'description', 'files', 'teams', 'is_global', 'status', 'created_by', 'created_at', 'updated_at'], + serialize: row => ({ + ...row, + files: JSON.stringify(row.files || {}), + teams: JSON.stringify(row.teams || []), + is_global: row.is_global ? 1 : 0, + }), + }, + { + source: 'zeude_cohort_members', + target: 'zeude_cohort_members', + orderBy: 'created_at', + columns: ['id', 'cohort_key', 'user_id', 'created_by', 'created_at'], + }, + { + source: 'zeude_mcp_install_status', + target: 'zeude_mcp_install_status', + orderBy: 'last_checked_at', + columns: ['id', 'user_id', 'mcp_server_id', 'installed', 'version', 'last_checked_at'], + serialize: row => ({ + ...row, + installed: row.installed ? 1 : 0, + }), + }, + { + source: 'zeude_hook_install_status', + target: 'zeude_hook_install_status', + orderBy: 'last_checked_at', + columns: ['id', 'user_id', 'hook_id', 'installed', 'version', 'last_checked_at'], + serialize: row => ({ + ...row, + installed: row.installed ? 1 : 0, + }), + }, +] + +function usage() { + console.log(` +Usage: + npm run migrate:supabase-to-sqlite -- [--dry-run] [--force] + +Required env: + SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY + +Optional env: + DATABASE_PATH + +Flags: + --dry-run Print row counts without writing to SQLite + --force Allow importing into a non-empty SQLite database + `) +} + +function ensureEnv() { + if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) { + usage() + throw new Error('Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY') + } +} + +async function fetchAllRows(supabase, table) { + const rows = [] + let start = 0 + + while (true) { + const end = start + PAGE_SIZE - 1 + const query = supabase + .from(table.source) + .select(table.columns.join(',')) + .order(table.orderBy, { ascending: true }) + .range(start, end) + + const { data, error } = await query + if (error) { + throw new Error(`Failed to fetch ${table.source}: ${error.message}`) + } + + if (!data || data.length === 0) { + break + } + + rows.push(...data) + if (data.length < PAGE_SIZE) { + break + } + start += PAGE_SIZE + } + + return rows +} + +function ensureTargetIsEmpty(db) { + const occupied = [] + for (const table of TABLES) { + const row = db.prepare(`SELECT COUNT(*) as count FROM ${table.target}`).get() + if (row.count > 0) { + occupied.push(`${table.target}:${row.count}`) + } + } + if (occupied.length > 0 && !force) { + throw new Error(`Target SQLite DB is not empty (${occupied.join(', ')}). Re-run with --force to append/replace.`) + } +} + +function buildInsert(table) { + const placeholders = table.columns.map(() => '?').join(', ') + return `INSERT OR REPLACE INTO ${table.target}(${table.columns.join(', ')}) VALUES (${placeholders})` +} + +async function main() { + ensureEnv() + + const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false, autoRefreshToken: false }, + }) + + const { db, databasePath } = ensureSqliteReady(resolveDatabasePath()) + ensureTargetIsEmpty(db) + + const summary = [] + + try { + for (const table of TABLES) { + const rows = await fetchAllRows(supabase, table) + summary.push({ table: table.target, rows: rows.length }) + + if (dryRun || rows.length === 0) { + continue + } + + const insert = db.prepare(buildInsert(table)) + const write = db.transaction((items) => { + for (const row of items) { + const normalized = table.serialize ? table.serialize(row) : row + insert.run(...table.columns.map(column => normalized[column] ?? null)) + } + }) + write(rows) + } + } finally { + db.close() + } + + console.log(`Supabase -> SQLite ${dryRun ? 'dry run' : 'migration'} complete`) + console.table(summary) + console.log(`SQLite path: ${databasePath}`) +} + +main().catch((error) => { + console.error(error.message) + process.exit(1) +}) diff --git a/zeude/dashboard/scripts/migrate-sqlite.cjs b/zeude/dashboard/scripts/migrate-sqlite.cjs new file mode 100644 index 0000000..6c86f3f --- /dev/null +++ b/zeude/dashboard/scripts/migrate-sqlite.cjs @@ -0,0 +1,174 @@ +const fs = require('fs') +const path = require('path') +const Database = require('better-sqlite3') + +function resolveDatabasePath() { + const isProduction = process.env.NODE_ENV === 'production' + return process.env.DATABASE_PATH || (isProduction ? '/var/lib/zeude/zeude.db' : path.join(process.cwd(), '.data', 'zeude.db')) +} + +const statements = [ + ` + CREATE TABLE IF NOT EXISTS _sqlite_migrations ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + applied_at TEXT NOT NULL + ); + `, + ` + CREATE TABLE IF NOT EXISTS zeude_users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + name TEXT, + agent_key TEXT NOT NULL UNIQUE, + team TEXT NOT NULL DEFAULT 'default', + role TEXT NOT NULL DEFAULT 'member', + status TEXT NOT NULL DEFAULT 'active', + disabled_skills TEXT NOT NULL DEFAULT '[]', + invited_by TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS zeude_sessions ( + id TEXT PRIMARY KEY, + token TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS zeude_one_time_tokens ( + id TEXT PRIMARY KEY, + token TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS zeude_invites ( + id TEXT PRIMARY KEY, + token TEXT NOT NULL UNIQUE, + team TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + created_by TEXT, + expires_at TEXT NOT NULL, + used_at TEXT, + used_by TEXT, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS zeude_skills ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + description TEXT, + content TEXT, + files TEXT NOT NULL DEFAULT '{}', + teams TEXT NOT NULL DEFAULT '[]', + is_global INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + created_by TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + keywords TEXT NOT NULL DEFAULT '[]', + primary_keywords TEXT NOT NULL DEFAULT '[]', + secondary_keywords TEXT NOT NULL DEFAULT '[]', + hint TEXT, + is_general INTEGER NOT NULL DEFAULT 0, + is_command INTEGER NOT NULL DEFAULT 0, + contributors TEXT NOT NULL DEFAULT '[]' + ); + CREATE TABLE IF NOT EXISTS zeude_hooks ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + event TEXT NOT NULL, + description TEXT, + script_content TEXT NOT NULL, + script_type TEXT NOT NULL DEFAULT 'bash', + env TEXT NOT NULL DEFAULT '{}', + teams TEXT NOT NULL DEFAULT '[]', + is_global INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + created_by TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS zeude_mcp_servers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + url TEXT, + command TEXT NOT NULL DEFAULT '', + args TEXT NOT NULL DEFAULT '[]', + env TEXT NOT NULL DEFAULT '{}', + teams TEXT NOT NULL DEFAULT '[]', + is_global INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + created_by TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS zeude_agents ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT, + files TEXT NOT NULL DEFAULT '{}', + teams TEXT NOT NULL DEFAULT '[]', + is_global INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + created_by TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS zeude_cohort_members ( + id TEXT PRIMARY KEY, + cohort_key TEXT NOT NULL, + user_id TEXT NOT NULL, + created_by TEXT, + created_at TEXT NOT NULL, + UNIQUE (cohort_key, user_id) + ); + CREATE TABLE IF NOT EXISTS zeude_mcp_install_status ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + mcp_server_id TEXT NOT NULL, + installed INTEGER NOT NULL, + version TEXT, + last_checked_at TEXT NOT NULL, + UNIQUE (user_id, mcp_server_id) + ); + CREATE TABLE IF NOT EXISTS zeude_hook_install_status ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + hook_id TEXT NOT NULL, + installed INTEGER NOT NULL, + version TEXT, + last_checked_at TEXT NOT NULL, + UNIQUE (user_id, hook_id) + ); + `, +] + +function ensureSqliteReady(databasePath = resolveDatabasePath()) { + fs.mkdirSync(path.dirname(databasePath), { recursive: true }) + + const db = new Database(databasePath) + db.pragma('journal_mode = WAL') + db.pragma('foreign_keys = ON') + db.pragma('busy_timeout = 5000') + + statements.forEach((sql, index) => { + db.exec(sql) + const insertMigration = db.prepare('INSERT OR IGNORE INTO _sqlite_migrations(name, applied_at) VALUES (?, ?)') + insertMigration.run(`script_migration_${index + 1}`, new Date().toISOString()) + }) + + return { db, databasePath } +} + +module.exports = { + ensureSqliteReady, + resolveDatabasePath, +} + +if (require.main === module) { + const { db, databasePath } = ensureSqliteReady() + db.close() + console.log(`SQLite schema is ready at ${databasePath}`) +} diff --git a/zeude/dashboard/src/app/api/admin/agents/[id]/route.ts b/zeude/dashboard/src/app/api/admin/agents/[id]/route.ts index 5991947..0b0aa3a 100644 --- a/zeude/dashboard/src/app/api/admin/agents/[id]/route.ts +++ b/zeude/dashboard/src/app/api/admin/agents/[id]/route.ts @@ -1,6 +1,6 @@ -import { createServerClient } from '@/lib/supabase' import { getSession } from '@/lib/session' import { validateFiles } from '@/lib/file-validation' +import { getOperationalDb } from '@/lib/db' // Agent name validation: lowercase letters and hyphens only (kebab-case, no digits) const AGENT_NAME_PATTERN = /^[a-z]+(-[a-z]+)*$/ @@ -57,7 +57,7 @@ export async function PATCH( } } - const supabase = createServerClient() + const db = getOperationalDb() const updateData: Record = {} if (name !== undefined) updateData.name = name @@ -74,23 +74,23 @@ export async function PATCH( if (teams !== undefined && !isGlobal) updateData.teams = teams if (status !== undefined) updateData.status = status - const { data: agent, error } = await supabase - .from('zeude_agents') - .update(updateData) - .eq('id', id) - .select() - .single() - - if (error) { - if (error.code === '23505') { + let agent + try { + agent = await db.agents.updateById(id, updateData) + } catch (error) { + const code = (error as { code?: string }).code + if (code === '23505' || code === 'SQLITE_CONSTRAINT_UNIQUE') { return Response.json({ error: 'An agent with this name already exists' }, { status: 400 }) } - if (error.code === '23514') { + if (code === '23514' || code === 'SQLITE_CONSTRAINT_CHECK') { return Response.json({ error: 'Invalid agent data: check name format and files size' }, { status: 400 }) } console.error('Failed to update agent:', error) return Response.json({ error: 'Failed to update agent' }, { status: 500 }) } + if (!agent) { + return Response.json({ error: 'Failed to update agent' }, { status: 500 }) + } return Response.json({ agent }) } catch (err) { @@ -116,15 +116,9 @@ export async function DELETE( } const { id } = await params - const supabase = createServerClient() - - const { error } = await supabase - .from('zeude_agents') - .delete() - .eq('id', id) - - if (error) { - console.error('Failed to delete agent:', error) + const db = getOperationalDb() + const deleted = await db.agents.deleteById(id) + if (!deleted) { return Response.json({ error: 'Failed to delete agent' }, { status: 500 }) } diff --git a/zeude/dashboard/src/app/api/admin/agents/route.ts b/zeude/dashboard/src/app/api/admin/agents/route.ts index 1d27111..2623e51 100644 --- a/zeude/dashboard/src/app/api/admin/agents/route.ts +++ b/zeude/dashboard/src/app/api/admin/agents/route.ts @@ -1,6 +1,6 @@ -import { createServerClient } from '@/lib/supabase' import { getSession } from '@/lib/session' import { validateFiles } from '@/lib/file-validation' +import { getOperationalDb } from '@/lib/db' // Agent name validation: lowercase letters and hyphens only (kebab-case, no digits) const AGENT_NAME_PATTERN = /^[a-z]+(-[a-z]+)*$/ @@ -19,25 +19,9 @@ export async function GET() { return Response.json({ error: 'Admin access required' }, { status: 403 }) } - const supabase = createServerClient() - - const { data: agents, error } = await supabase - .from('zeude_agents') - .select('*') - .order('created_at', { ascending: false }) - - if (error) { - console.error('Failed to fetch agents:', error) - return Response.json({ error: 'Failed to fetch agents' }, { status: 500 }) - } - - // Get unique teams for filter dropdown - const { data: usersData } = await supabase - .from('zeude_users') - .select('team') - .order('team') - - const teams = [...new Set(usersData?.map(u => u.team) || [])] + const db = getOperationalDb() + const agents = await db.agents.listAll() + const teams = await db.users.listTeams() return Response.json({ agents, teams }) } catch (err) { @@ -92,11 +76,10 @@ export async function POST(req: Request) { return Response.json({ error: validation.error }, { status: 400 }) } - const supabase = createServerClient() + const db = getOperationalDb() - const { data: agent, error } = await supabase - .from('zeude_agents') - .insert({ + try { + const agent = await db.agents.create({ name, description: description || null, files, @@ -105,22 +88,18 @@ export async function POST(req: Request) { status: 'active', created_by: session.user.id, }) - .select() - .single() - - if (error) { - if (error.code === '23505') { + return Response.json({ agent }) + } catch (error) { + const code = (error as { code?: string }).code + if (code === '23505' || code === 'SQLITE_CONSTRAINT_UNIQUE') { return Response.json({ error: 'An agent with this name already exists' }, { status: 400 }) } - if (error.code === '23514') { - // CHECK constraint violation (name format or files size) + if (code === '23514' || code === 'SQLITE_CONSTRAINT_CHECK') { return Response.json({ error: 'Invalid agent data: check name format and files size' }, { status: 400 }) } console.error('Failed to create agent:', error) return Response.json({ error: 'Failed to create agent' }, { status: 500 }) } - - return Response.json({ agent }) } catch (err) { console.error('Agent create error:', err) return Response.json({ error: 'Internal server error' }, { status: 500 }) diff --git a/zeude/dashboard/src/app/api/admin/analytics/efficiency/route.ts b/zeude/dashboard/src/app/api/admin/analytics/efficiency/route.ts index 528dcec..1839c07 100644 --- a/zeude/dashboard/src/app/api/admin/analytics/efficiency/route.ts +++ b/zeude/dashboard/src/app/api/admin/analytics/efficiency/route.ts @@ -1,6 +1,5 @@ import { getSession } from '@/lib/session' import { getClickHouseClient, buildMVSourceCondition, parseSourceParam } from '@/lib/clickhouse' -import { createServerClient } from '@/lib/supabase' import { calculateEfficiencyScore } from '@/lib/efficiency' import { resolveUserNames } from '@/lib/name-resolution' @@ -123,8 +122,7 @@ export async function GET(req: Request) { // === Name Resolution (Zeude Identity SSOT) === // Shared utility handles Supabase lookup + email fallback + error handling - const supabase = createServerClient() - const { getDisplayName } = await resolveUserNames(supabase, userData) + const { getDisplayName } = await resolveUserNames(userData) const byUser: UserEfficiency[] = userData.map(row => { const inputTokens = parseInt(row.input_tokens) || 0 diff --git a/zeude/dashboard/src/app/api/admin/analytics/skills/route.ts b/zeude/dashboard/src/app/api/admin/analytics/skills/route.ts index 7c45735..6bf35cb 100644 --- a/zeude/dashboard/src/app/api/admin/analytics/skills/route.ts +++ b/zeude/dashboard/src/app/api/admin/analytics/skills/route.ts @@ -1,6 +1,5 @@ import { parseSourceParam } from '@/lib/clickhouse' import { getSession } from '@/lib/session' -import { createServerClient } from '@/lib/supabase' import { getTeamPromptTypeStats, getTeamTopSkills, @@ -17,15 +16,7 @@ export async function GET(req: Request) { return Response.json({ error: 'Not authenticated' }, { status: 401 }) } - // Check if user is admin - const supabase = createServerClient() - const { data: user } = await supabase - .from('zeude_users') - .select('role, team') - .eq('id', session.user_id) - .single() - - if (!user || user.role !== 'admin') { + if (session.user.role !== 'admin') { return Response.json({ error: 'Admin access required' }, { status: 403 }) } diff --git a/zeude/dashboard/src/app/api/admin/analytics/usage/route.ts b/zeude/dashboard/src/app/api/admin/analytics/usage/route.ts index 4b7816a..7e55f79 100644 --- a/zeude/dashboard/src/app/api/admin/analytics/usage/route.ts +++ b/zeude/dashboard/src/app/api/admin/analytics/usage/route.ts @@ -1,6 +1,5 @@ import { getSession } from '@/lib/session' import { getClickHouseClient, buildMVSourceCondition, parseSourceParam } from '@/lib/clickhouse' -import { createServerClient } from '@/lib/supabase' import { resolveUserNames } from '@/lib/name-resolution' import type { SourceBreakdown, SourceTrendPoint, UserSourceUsage } from '@/lib/source-types' @@ -289,8 +288,7 @@ export async function GET(req: Request) { // === Name Resolution (Zeude Identity SSOT) === // Shared utility handles Supabase lookup + email fallback + error handling - const supabase = createServerClient() - const { getDisplayName } = await resolveUserNames(supabase, userData) + const { getDisplayName } = await resolveUserNames(userData) // Process users - cost comes directly from query const byUser: UserUsage[] = userData.map(row => { diff --git a/zeude/dashboard/src/app/api/admin/cohorts/route.ts b/zeude/dashboard/src/app/api/admin/cohorts/route.ts index 35f5b4f..6a5dfcb 100644 --- a/zeude/dashboard/src/app/api/admin/cohorts/route.ts +++ b/zeude/dashboard/src/app/api/admin/cohorts/route.ts @@ -1,5 +1,5 @@ import { getSession } from '@/lib/session' -import { createServerClient } from '@/lib/supabase' +import { getOperationalDb } from '@/lib/db' interface RegisterCohortBody { cohortKey?: string @@ -30,24 +30,19 @@ export async function POST(req: Request) { return Response.json({ error: 'Invalid cohortKey (min 3 chars, URL-safe only)' }, { status: 400 }) } - const supabase = createServerClient() + const db = getOperationalDb() const requestedUserIds = Array.isArray(body.userIds) ? body.userIds.filter(Boolean) : [] - let usersQuery = supabase.from('zeude_users').select('id') - if (requestedUserIds.length > 0) { - usersQuery = usersQuery.in('id', requestedUserIds) - } else { - usersQuery = usersQuery.eq('status', 'active') - } - - const { data: users, error: usersError } = await usersQuery - if (usersError) { - console.error('Failed to fetch users for cohort register:', usersError) - return Response.json({ error: 'Failed to fetch users' }, { status: 500 }) - } + const users = requestedUserIds.length > 0 + ? await db.users.listByIds(requestedUserIds) + : (await db.users.listAll()).filter(user => user.status === 'active').map(user => ({ + id: user.id, + email: user.email, + name: user.name, + })) if (!users || users.length === 0) { return Response.json({ @@ -59,36 +54,21 @@ export async function POST(req: Request) { }) } - const rows = users.map(user => ({ - cohort_key: cohortKey, - user_id: user.id, - created_by: session.user.id, - })) - - const { count: insertedMembers, error: insertError } = await supabase - .from('zeude_cohort_members') - .upsert(rows, { onConflict: 'cohort_key,user_id', ignoreDuplicates: true, count: 'exact' }) - - if (insertError) { - console.error('Failed to upsert cohort members:', insertError) + let insertedMembers = 0 + let totalMembers = 0 + try { + insertedMembers = await db.cohorts.addMembers(cohortKey, users.map(user => user.id), session.user.id) + totalMembers = await db.cohorts.countMembers(cohortKey) + } catch (error) { + console.error('Failed to register cohort members:', error) return Response.json({ error: 'Failed to register cohort members' }, { status: 500 }) } - const { count: totalMembers, error: totalError } = await supabase - .from('zeude_cohort_members') - .select('id', { count: 'exact', head: true }) - .eq('cohort_key', cohortKey) - - if (totalError) { - console.error('Failed to count cohort members:', totalError) - return Response.json({ error: 'Failed to read cohort members' }, { status: 500 }) - } - return Response.json({ cohortKey, processedUsers: users.length, - insertedMembers: insertedMembers || 0, - totalMembers: totalMembers || 0, + insertedMembers, + totalMembers, leaderboardUrl: `/leaderboard?cohort=${encodeURIComponent(cohortKey)}`, }) } catch (err) { diff --git a/zeude/dashboard/src/app/api/admin/hooks/[id]/route.ts b/zeude/dashboard/src/app/api/admin/hooks/[id]/route.ts index 95b82e3..b8b402a 100644 --- a/zeude/dashboard/src/app/api/admin/hooks/[id]/route.ts +++ b/zeude/dashboard/src/app/api/admin/hooks/[id]/route.ts @@ -1,5 +1,5 @@ -import { createServerClient } from '@/lib/supabase' import { getSession } from '@/lib/session' +import { getOperationalDb } from '@/lib/db' // Maximum script content size: 100KB const MAX_SCRIPT_SIZE = 100 * 1024 @@ -39,7 +39,7 @@ export async function PATCH( }, { status: 400 }) } - const supabase = createServerClient() + const db = getOperationalDb() const updateData: Record = {} if (name !== undefined) updateData.name = name @@ -55,15 +55,8 @@ export async function PATCH( if (teams !== undefined && !isGlobal) updateData.teams = teams if (status !== undefined) updateData.status = status - const { data: hook, error } = await supabase - .from('zeude_hooks') - .update(updateData) - .eq('id', id) - .select() - .single() - - if (error) { - console.error('Failed to update hook:', error) + const hook = await db.hooks.updateById(id, updateData) + if (!hook) { return Response.json({ error: 'Failed to update hook' }, { status: 500 }) } @@ -91,15 +84,9 @@ export async function DELETE( } const { id } = await params - const supabase = createServerClient() - - const { error } = await supabase - .from('zeude_hooks') - .delete() - .eq('id', id) - - if (error) { - console.error('Failed to delete hook:', error) + const db = getOperationalDb() + const deleted = await db.hooks.deleteById(id) + if (!deleted) { return Response.json({ error: 'Failed to delete hook' }, { status: 500 }) } diff --git a/zeude/dashboard/src/app/api/admin/hooks/route.ts b/zeude/dashboard/src/app/api/admin/hooks/route.ts index 7f8d0a7..5b87c88 100644 --- a/zeude/dashboard/src/app/api/admin/hooks/route.ts +++ b/zeude/dashboard/src/app/api/admin/hooks/route.ts @@ -1,6 +1,6 @@ -import { createServerClient } from '@/lib/supabase' import { getSession } from '@/lib/session' import { fetchHooksData } from '@/lib/data/admin-hooks' +import { getOperationalDb } from '@/lib/db' // Maximum script content size: 100KB const MAX_SCRIPT_SIZE = 100 * 1024 @@ -60,13 +60,11 @@ export async function POST(req: Request) { }, { status: 400 }) } - const supabase = createServerClient() + const db = getOperationalDb() - const { data: hook, error } = await supabase - .from('zeude_hooks') - .insert({ + const hook = await db.hooks.create({ name, - event, + event: event as never, description: description || null, script_content: scriptContent, script_type: scriptType, @@ -76,13 +74,6 @@ export async function POST(req: Request) { status: 'active', created_by: session.user.id, }) - .select() - .single() - - if (error) { - console.error('Failed to create hook:', error) - return Response.json({ error: 'Failed to create hook' }, { status: 500 }) - } return Response.json({ hook }) } catch (err) { diff --git a/zeude/dashboard/src/app/api/admin/invites/route.ts b/zeude/dashboard/src/app/api/admin/invites/route.ts index e349698..8dea6c2 100644 --- a/zeude/dashboard/src/app/api/admin/invites/route.ts +++ b/zeude/dashboard/src/app/api/admin/invites/route.ts @@ -1,4 +1,4 @@ -import { createServerClient } from '@/lib/supabase' +import { getOperationalDb } from '@/lib/db' import { getSession } from '@/lib/session' import { randomBytes } from 'crypto' @@ -30,28 +30,25 @@ export async function POST(req: Request) { return Response.json({ error: 'Role must be admin or member' }, { status: 400 }) } - const supabase = createServerClient() - // Generate secure token (32 bytes = 64 hex chars) const token = randomBytes(32).toString('hex') const expiresAt = new Date(Date.now() + 60 * 60 * 1000) // 1 hour + const db = getOperationalDb() - const { data: invite, error } = await supabase - .from('zeude_invites') - .insert({ + let invite + try { + invite = await db.invites.create({ token, team, role, created_by: session.user.id, expires_at: expiresAt.toISOString(), + used_at: null, + used_by: null, }) - .select() - .single() - - if (error) { - console.error('Failed to create invite:', JSON.stringify(error, null, 2)) - console.error('Error code:', error.code, 'Message:', error.message) - return Response.json({ error: 'Failed to create invite', details: error.message }, { status: 500 }) + } catch (error) { + console.error('Failed to create invite:', error) + return Response.json({ error: 'Failed to create invite' }, { status: 500 }) } const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://zeude.zep.work' @@ -81,18 +78,8 @@ export async function GET() { return Response.json({ error: 'Admin access required' }, { status: 403 }) } - const supabase = createServerClient() - - const { data: invites, error } = await supabase - .from('zeude_invites') - .select('id, token, team, role, created_by, expires_at, used_at, used_by, created_at') - .order('created_at', { ascending: false }) - .limit(50) - - if (error) { - console.error('Failed to fetch invites:', error) - return Response.json({ error: 'Failed to fetch invites' }, { status: 500 }) - } + const db = getOperationalDb() + const invites = await db.invites.listRecent(50) return Response.json({ invites }) } catch (err) { diff --git a/zeude/dashboard/src/app/api/admin/mcp/[id]/route.ts b/zeude/dashboard/src/app/api/admin/mcp/[id]/route.ts index a28436c..2603bde 100644 --- a/zeude/dashboard/src/app/api/admin/mcp/[id]/route.ts +++ b/zeude/dashboard/src/app/api/admin/mcp/[id]/route.ts @@ -1,5 +1,5 @@ -import { createServerClient } from '@/lib/supabase' import { getSession } from '@/lib/session' +import { getOperationalDb } from '@/lib/db' // PATCH: Update MCP server (authenticated) export async function PATCH( @@ -37,17 +37,9 @@ export async function PATCH( updates.updated_at = new Date().toISOString() - const supabase = createServerClient() - - const { data: server, error } = await supabase - .from('zeude_mcp_servers') - .update(updates) - .eq('id', id) - .select() - .single() - - if (error) { - console.error('Failed to update MCP server:', error) + const db = getOperationalDb() + const server = await db.mcp.updateById(id, updates) + if (!server) { return Response.json({ error: 'Failed to update server' }, { status: 500 }) } @@ -71,15 +63,9 @@ export async function DELETE( } const { id } = await params - const supabase = createServerClient() - - const { error } = await supabase - .from('zeude_mcp_servers') - .delete() - .eq('id', id) - - if (error) { - console.error('Failed to delete MCP server:', error) + const db = getOperationalDb() + const deleted = await db.mcp.deleteById(id) + if (!deleted) { return Response.json({ error: 'Failed to delete server' }, { status: 500 }) } diff --git a/zeude/dashboard/src/app/api/admin/mcp/route.ts b/zeude/dashboard/src/app/api/admin/mcp/route.ts index 526e8aa..edc886b 100644 --- a/zeude/dashboard/src/app/api/admin/mcp/route.ts +++ b/zeude/dashboard/src/app/api/admin/mcp/route.ts @@ -1,6 +1,6 @@ -import { createServerClient } from '@/lib/supabase' import { getSession } from '@/lib/session' import { fetchMCPData } from '@/lib/data/admin-mcp' +import { getOperationalDb } from '@/lib/db' // GET: List all MCP servers (authenticated) export async function GET() { @@ -43,11 +43,9 @@ export async function POST(req: Request) { return Response.json({ error: 'Either URL or Command is required' }, { status: 400 }) } - const supabase = createServerClient() + const db = getOperationalDb() - const { data: server, error } = await supabase - .from('zeude_mcp_servers') - .insert({ + const server = await db.mcp.create({ name, url: hasUrl ? url.trim() : null, command: hasCommand ? command.trim() : '', @@ -58,13 +56,6 @@ export async function POST(req: Request) { status: 'active', created_by: session.user.id, }) - .select() - .single() - - if (error) { - console.error('Failed to create MCP server:', error) - return Response.json({ error: 'Failed to create server' }, { status: 500 }) - } return Response.json({ server }) } catch (err) { diff --git a/zeude/dashboard/src/app/api/admin/skills/[id]/route.ts b/zeude/dashboard/src/app/api/admin/skills/[id]/route.ts index 7287b46..ac00bd9 100644 --- a/zeude/dashboard/src/app/api/admin/skills/[id]/route.ts +++ b/zeude/dashboard/src/app/api/admin/skills/[id]/route.ts @@ -1,6 +1,6 @@ -import { createServerClient } from '@/lib/supabase' import { getSession } from '@/lib/session' import { MAX_CONTENT_SIZE, validateFiles } from '@/lib/file-validation' +import { getOperationalDb } from '@/lib/db' // PATCH: Update skill (authenticated) export async function PATCH( @@ -47,7 +47,7 @@ export async function PATCH( }, { status: 400 }) } - const supabase = createServerClient() + const db = getOperationalDb() // Validate keyword arrays if provided if (primaryKeywords !== undefined) { @@ -116,11 +116,7 @@ export async function PATCH( // Auto-add current user as contributor if not explicitly managing contributors // Only when: not the creator AND not already a contributor if (contributors === undefined) { - const { data: existing } = await supabase - .from('zeude_skills') - .select('created_by, contributors') - .eq('id', id) - .single() + const existing = await db.skills.findById(id) if (existing) { const userId = session.user.id @@ -131,23 +127,23 @@ export async function PATCH( } } - const { data: skill, error } = await supabase - .from('zeude_skills') - .update(updateData) - .eq('id', id) - .select() - .single() - - if (error) { - if (error.code === '23505') { + let skill + try { + skill = await db.skills.updateById(id, updateData) + } catch (error) { + const code = (error as { code?: string }).code + if (code === '23505' || code === 'SQLITE_CONSTRAINT_UNIQUE') { return Response.json({ error: 'A skill with this slug already exists' }, { status: 400 }) } - if (error.code === '23514') { + if (code === '23514' || code === 'SQLITE_CONSTRAINT_CHECK') { return Response.json({ error: 'Invalid skill data: check files size (max 512KB total)' }, { status: 400 }) } console.error('Failed to update skill:', error) return Response.json({ error: 'Failed to update skill' }, { status: 500 }) } + if (!skill) { + return Response.json({ error: 'Failed to update skill' }, { status: 500 }) + } return Response.json({ skill }) } catch (err) { @@ -173,15 +169,9 @@ export async function DELETE( } const { id } = await params - const supabase = createServerClient() - - const { error } = await supabase - .from('zeude_skills') - .delete() - .eq('id', id) - - if (error) { - console.error('Failed to delete skill:', error) + const db = getOperationalDb() + const deleted = await db.skills.deleteById(id) + if (!deleted) { return Response.json({ error: 'Failed to delete skill' }, { status: 500 }) } diff --git a/zeude/dashboard/src/app/api/admin/skills/route.ts b/zeude/dashboard/src/app/api/admin/skills/route.ts index dc2a0aa..8dc583f 100644 --- a/zeude/dashboard/src/app/api/admin/skills/route.ts +++ b/zeude/dashboard/src/app/api/admin/skills/route.ts @@ -1,8 +1,8 @@ -import { createServerClient } from '@/lib/supabase' import { getSession } from '@/lib/session' import { hasAllowedTools } from '@/lib/skill-utils' import { validateFiles } from '@/lib/file-validation' import { fetchSkillsData } from '@/lib/data/admin-skills' +import { getOperationalDb } from '@/lib/db' // GET: List all Skills (authenticated) export async function GET() { @@ -71,15 +71,14 @@ export async function POST(req: Request) { return Response.json({ error: validation.error }, { status: 400 }) } - const supabase = createServerClient() + const db = getOperationalDb() // Check if this is a command (has allowed-tools) - skip LLM generation const isCommand = hasAllowedTools(files['SKILL.md']) // New skills: content = NULL (old shim gracefully skips, new shim uses files) - const { data: skill, error } = await supabase - .from('zeude_skills') - .insert({ + try { + const skill = await db.skills.create({ name, slug, description: description || null, @@ -89,6 +88,7 @@ export async function POST(req: Request) { is_global: isGlobal, is_general: isGeneral, is_command: isCommand, + keywords: [], primary_keywords: primaryKeywords || [], secondary_keywords: secondaryKeywords || [], hint: hint || '', @@ -96,21 +96,18 @@ export async function POST(req: Request) { status: 'active', created_by: session.user.id, }) - .select() - .single() - - if (error) { - if (error.code === '23505') { + return Response.json({ skill }) + } catch (error) { + const code = (error as { code?: string }).code + if (code === '23505' || code === 'SQLITE_CONSTRAINT_UNIQUE') { return Response.json({ error: 'A skill with this slug already exists' }, { status: 400 }) } - if (error.code === '23514') { + if (code === '23514' || code === 'SQLITE_CONSTRAINT_CHECK') { return Response.json({ error: 'Invalid skill data: check files size (max 5MB total)' }, { status: 400 }) } console.error('Failed to create skill:', error) return Response.json({ error: 'Failed to create skill' }, { status: 500 }) } - - return Response.json({ skill }) } catch (err) { console.error('Skill create error:', err) return Response.json({ error: 'Internal server error' }, { status: 500 }) diff --git a/zeude/dashboard/src/app/api/admin/users/[id]/key/route.ts b/zeude/dashboard/src/app/api/admin/users/[id]/key/route.ts index 65021f2..cb8d860 100644 --- a/zeude/dashboard/src/app/api/admin/users/[id]/key/route.ts +++ b/zeude/dashboard/src/app/api/admin/users/[id]/key/route.ts @@ -1,6 +1,6 @@ -import { createServerClient } from '@/lib/supabase' import { getSession } from '@/lib/session' import { randomBytes } from 'crypto' +import { getOperationalDb } from '@/lib/db' // POST: Generate new agent key for user export async function POST( @@ -19,29 +19,19 @@ export async function POST( } const { id } = await params - const supabase = createServerClient() + const db = getOperationalDb() // Generate new agent key const agentKey = 'zd_' + randomBytes(32).toString('hex') - const { data: user, error } = await supabase - .from('zeude_users') - .update({ - agent_key: agentKey, - updated_at: new Date().toISOString(), - }) - .eq('id', id) - .select('id, email, name') - .single() - - if (error) { - console.error('Failed to regenerate key:', error) + const updated = await db.users.updateAgentKey(id, agentKey) + if (!updated) { return Response.json({ error: 'Failed to regenerate key' }, { status: 500 }) } return Response.json({ agentKey, - user, + user: { id: updated.id, email: updated.email, name: updated.name }, message: 'New key generated. Share securely with the user.', }) } catch (err) { @@ -73,22 +63,16 @@ export async function DELETE( return Response.json({ error: 'Cannot revoke your own key' }, { status: 400 }) } - const supabase = createServerClient() + const db = getOperationalDb() // Generate a revoked key that won't match any valid format const revokedKey = 'revoked_' + randomBytes(16).toString('hex') - const { error } = await supabase - .from('zeude_users') - .update({ - agent_key: revokedKey, - status: 'inactive', - updated_at: new Date().toISOString(), - }) - .eq('id', id) - - if (error) { - console.error('Failed to revoke key:', error) + const updated = await db.users.updateById(id, { + agent_key: revokedKey, + status: 'inactive', + }) + if (!updated) { return Response.json({ error: 'Failed to revoke key' }, { status: 500 }) } diff --git a/zeude/dashboard/src/app/api/admin/users/[id]/route.ts b/zeude/dashboard/src/app/api/admin/users/[id]/route.ts index a6c75d1..7c02ad2 100644 --- a/zeude/dashboard/src/app/api/admin/users/[id]/route.ts +++ b/zeude/dashboard/src/app/api/admin/users/[id]/route.ts @@ -1,5 +1,5 @@ -import { createServerClient } from '@/lib/supabase' import { getSession } from '@/lib/session' +import { getOperationalDb } from '@/lib/db' // UUID v4 validation regex const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i @@ -30,15 +30,10 @@ export async function GET( return Response.json({ error: 'Invalid user ID format' }, { status: 400 }) } - const supabase = createServerClient() + const db = getOperationalDb() + const user = await db.users.findById(id) - const { data: user, error } = await supabase - .from('zeude_users') - .select('id, email, name, team, role, status, invited_by, created_at, updated_at') - .eq('id', id) - .single() - - if (error || !user) { + if (!user) { return Response.json({ error: 'User not found' }, { status: 404 }) } @@ -104,12 +99,8 @@ export async function PATCH( } // Prevent restoring a deleted user via PATCH - const supabaseCheck = createServerClient() - const { data: targetUser } = await supabaseCheck - .from('zeude_users') - .select('status') - .eq('id', id) - .single() + const db = getOperationalDb() + const targetUser = await db.users.findById(id) if (targetUser?.status === 'deleted') { return Response.json({ error: 'Cannot modify a deleted user' }, { status: 400 }) @@ -125,17 +116,8 @@ export async function PATCH( return Response.json({ error: 'Cannot deactivate yourself' }, { status: 400 }) } - const supabase = createServerClient() - - const { data: user, error } = await supabase - .from('zeude_users') - .update({ ...updates, updated_at: new Date().toISOString() }) - .eq('id', id) - .select('id, email, name, team, role, status') - .single() - - if (error) { - console.error('Failed to update user:', error) + const user = await db.users.updateById(id, updates) + if (!user) { return Response.json({ error: 'Failed to update user' }, { status: 500 }) } @@ -173,18 +155,14 @@ export async function DELETE( return Response.json({ error: 'Cannot delete yourself' }, { status: 400 }) } - const supabase = createServerClient() - - const { data: user, error } = await supabase - .from('zeude_users') - .update({ status: 'deleted', updated_at: new Date().toISOString() }) - .eq('id', id) - .neq('status', 'deleted') - .select('id, email, name, status') - .single() + const db = getOperationalDb() + const existing = await db.users.findById(id) + if (!existing || existing.status === 'deleted') { + return Response.json({ error: 'User not found or already deleted' }, { status: 404 }) + } - if (error || !user) { - console.error('Failed to delete user:', error) + const user = await db.users.updateById(id, { status: 'deleted' }) + if (!user) { return Response.json({ error: 'User not found or already deleted' }, { status: 404 }) } diff --git a/zeude/dashboard/src/app/api/admin/users/route.ts b/zeude/dashboard/src/app/api/admin/users/route.ts index a6f1cfc..dc70f48 100644 --- a/zeude/dashboard/src/app/api/admin/users/route.ts +++ b/zeude/dashboard/src/app/api/admin/users/route.ts @@ -1,5 +1,5 @@ -import { createServerClient } from '@/lib/supabase' import { getSession } from '@/lib/session' +import { getOperationalDb } from '@/lib/db' // Sanitize search input to prevent PostgREST filter injection function sanitizeSearch(input: string): string { @@ -28,44 +28,18 @@ export async function GET(req: Request) { const limit = Math.min(parseInt(url.searchParams.get('limit') || '50', 10), 100) const offset = (page - 1) * limit - const supabase = createServerClient() - - // Single query with count to avoid N+1 - let query = supabase - .from('zeude_users') - .select('id, email, name, team, role, status, created_at, updated_at', { count: 'exact' }) - .order('created_at', { ascending: false }) - .range(offset, offset + limit - 1) - - if (team) { - query = query.eq('team', team) - } - - if (status) { - query = query.eq('status', status) - } - - if (search) { - const safeSearch = sanitizeSearch(search) - if (safeSearch.length > 0) { - query = query.or(`name.ilike.%${safeSearch}%,email.ilike.%${safeSearch}%`) - } - } - - const { data: users, error, count } = await query - - if (error) { - console.error('Failed to fetch users:', error) - return Response.json({ error: 'Failed to fetch users' }, { status: 500 }) - } - - // Get unique teams using DISTINCT via RPC or separate optimized query - const { data: teamsData } = await supabase - .from('zeude_users') - .select('team') - .not('team', 'is', null) - - const teams = [...new Set(teamsData?.map(t => t.team) || [])].sort() + const db = getOperationalDb() + const safeSearch = search ? sanitizeSearch(search) : '' + const result = await db.users.listUsers({ + team, + status, + search: safeSearch || undefined, + page, + limit, + }) + const users = result.users + const count = result.total + const teams = (await db.users.listTeams()).sort() return Response.json({ users, diff --git a/zeude/dashboard/src/app/api/auth/callback/route.ts b/zeude/dashboard/src/app/api/auth/callback/route.ts index e5726bd..69f448b 100644 --- a/zeude/dashboard/src/app/api/auth/callback/route.ts +++ b/zeude/dashboard/src/app/api/auth/callback/route.ts @@ -1,18 +1,6 @@ -import { createServerClient } from '@/lib/supabase' import { NextRequest, NextResponse } from 'next/server' import { randomBytes } from 'crypto' - -interface OttRecord { - id: string - token: string - user_id: string - expires_at: string - user: { - id: string - email: string - name: string | null - } | null -} +import { getOperationalDb } from '@/lib/db' // Get the proper base URL from request headers (handles reverse proxy) function getBaseUrl(request: NextRequest): string { @@ -29,17 +17,10 @@ export async function GET(request: NextRequest) { return NextResponse.redirect(new URL('/auth?error=missing_token', baseUrl)) } - const supabase = createServerClient() + const db = getOperationalDb() // Validate OTT - const { data } = await supabase - .from('zeude_one_time_tokens') - .select('*, user:zeude_users(*)') - .eq('token', ott) - .gt('expires_at', new Date().toISOString()) - .single() - - const ottRecord = data as OttRecord | null + const ottRecord = db.tokens.findValidByTokenWithUser(ott, new Date().toISOString()) if (!ottRecord) { return NextResponse.redirect(new URL('/auth?error=invalid_token', baseUrl)) @@ -49,13 +30,13 @@ export async function GET(request: NextRequest) { const sessionToken = randomBytes(32).toString('hex') const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) - const { error: sessionError } = await supabase.from('zeude_sessions').insert({ - token: sessionToken, - user_id: ottRecord.user_id, - expires_at: expiresAt.toISOString(), - }) - - if (sessionError) { + try { + db.sessions.create({ + token: sessionToken, + user_id: ottRecord.user_id, + expires_at: expiresAt.toISOString(), + }) + } catch (sessionError) { console.error('Failed to create session:', sessionError) return NextResponse.redirect(new URL('/auth?error=session_error', baseUrl)) } @@ -67,7 +48,7 @@ export async function GET(request: NextRequest) { }) // Delete used OTT - await supabase.from('zeude_one_time_tokens').delete().eq('id', ottRecord.id) + db.tokens.deleteById(ottRecord.id) // Set cookie on the redirect response const response = NextResponse.redirect(new URL('/', baseUrl)) diff --git a/zeude/dashboard/src/app/api/auth/logout/route.ts b/zeude/dashboard/src/app/api/auth/logout/route.ts index 4fc741d..3e872fa 100644 --- a/zeude/dashboard/src/app/api/auth/logout/route.ts +++ b/zeude/dashboard/src/app/api/auth/logout/route.ts @@ -1,14 +1,14 @@ import { cookies } from 'next/headers' -import { createServerClient } from '@/lib/supabase' import { redirect } from 'next/navigation' +import { getOperationalDb } from '@/lib/db' export async function POST() { const cookieStore = await cookies() const sessionToken = cookieStore.get('session')?.value if (sessionToken) { - const supabase = createServerClient() - await supabase.from('zeude_sessions').delete().eq('token', sessionToken) + const db = getOperationalDb() + db.sessions.deleteByToken(sessionToken) cookieStore.delete('session') } diff --git a/zeude/dashboard/src/app/api/auth/ott/route.ts b/zeude/dashboard/src/app/api/auth/ott/route.ts index 66e18fd..49ab402 100644 --- a/zeude/dashboard/src/app/api/auth/ott/route.ts +++ b/zeude/dashboard/src/app/api/auth/ott/route.ts @@ -1,6 +1,6 @@ -import { createServerClient, isDBConnectionError } from '@/lib/supabase' import { randomBytes } from 'crypto' import { rateLimit, getClientIP } from '@/lib/rate-limit' +import { getOperationalDb } from '@/lib/db' const AGENT_KEY_PATTERN = /^zd_[a-f0-9]{64}$/ @@ -34,23 +34,14 @@ export async function POST(req: Request) { return Response.json({ error: 'Invalid agent key format' }, { status: 400 }) } - const supabase = createServerClient() + const db = getOperationalDb() // Find user by agent key - const { data: user, error } = await supabase - .from('zeude_users') - .select('id, email, name') - .eq('agent_key', agentKey) - .single() + const user = db.users.findByAgentKey(agentKey) - if (isDBConnectionError(error)) { - console.error('DB connection failed during user lookup:', { error, agentKey }) - return Response.json({ error: 'Database connection failed. This is a server infrastructure issue.', code: 'DB_CONNECTION_ERROR' }, { status: 503 }) - } - - if (error || !user) { - console.error('User lookup failed:', { error, user, agentKey }) - return Response.json({ error: 'Invalid agent key', debug: error?.message || 'User not found' }, { status: 401 }) + if (!user) { + console.error('User lookup failed:', { agentKey }) + return Response.json({ error: 'Invalid agent key', debug: 'User not found' }, { status: 401 }) } // Generate OTT (64 chars hex) @@ -58,22 +49,12 @@ export async function POST(req: Request) { const expiresAt = new Date(Date.now() + 60 * 1000) // 60 seconds // Store OTT - const { error: insertError } = await supabase.from('zeude_one_time_tokens').insert({ + db.tokens.create({ token, user_id: user.id, expires_at: expiresAt.toISOString(), }) - if (isDBConnectionError(insertError)) { - console.error('DB connection failed during OTT insert:', { error: insertError }) - return Response.json({ error: 'Database connection failed. This is a server infrastructure issue.', code: 'DB_CONNECTION_ERROR' }, { status: 503 }) - } - - if (insertError) { - console.error('Failed to create OTT:', insertError) - return Response.json({ error: 'Failed to create token', debug: insertError.message }, { status: 500 }) - } - return Response.json({ token }) } catch (err) { console.error('OTT generation error:', err) diff --git a/zeude/dashboard/src/app/api/config/[agentKey]/route.test.ts b/zeude/dashboard/src/app/api/config/[agentKey]/route.test.ts index bf4c57a..9396888 100644 --- a/zeude/dashboard/src/app/api/config/[agentKey]/route.test.ts +++ b/zeude/dashboard/src/app/api/config/[agentKey]/route.test.ts @@ -1,19 +1,26 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { GET } from './route' -// Mock dependencies -const mockFrom = vi.fn() +const mockDb = { + users: { + findByAgentKey: vi.fn(), + }, + mcp: { + listActiveForTeam: vi.fn(), + }, + skills: { + listActiveForTeam: vi.fn(), + }, + hooks: { + listActiveForTeam: vi.fn(), + }, + agents: { + listActiveForTeam: vi.fn(), + }, +} -vi.mock('@/lib/supabase', () => ({ - createServerClient: vi.fn(() => ({ - from: mockFrom, - })), - isDBConnectionError: vi.fn((error: unknown) => { - if (!error || typeof error !== 'object') return false - const e = error as { message?: string; details?: string } - const msg = `${e.message || ''} ${e.details || ''}` - return msg.includes('fetch failed') || msg.includes('ETIMEDOUT') || msg.includes('ECONNREFUSED') - }), +vi.mock('@/lib/db', () => ({ + getOperationalDb: vi.fn(() => mockDb), })) vi.mock('@/lib/rate-limit', () => ({ @@ -43,101 +50,23 @@ function makeRequestWithAuth(agentKey: string): Request { }) } -// Helper to build a chainable supabase mock for a specific table -function mockSupabaseTable(table: string, result: { data: unknown; error: unknown }) { - return { - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - single: vi.fn().mockResolvedValue(result), - or: vi.fn().mockResolvedValue(result), - }), - order: vi.fn().mockResolvedValue(result), - or: vi.fn().mockResolvedValue(result), - }), - } -} - -function setupUserAndDataMocks(options: { - user?: { id: string; email: string; team: string; status: string } | null; - userError?: unknown; - servers?: unknown[]; - skills?: unknown[]; - hooks?: unknown[]; - agents?: unknown[]; - serversError?: unknown; - skillsError?: unknown; - hooksError?: unknown; - agentsError?: unknown; -}) { - const { - user = { id: 'u-1', email: 'test@test.com', team: 'team-a', status: 'active' }, - userError = null, - servers = [], - skills = [], - hooks = [], - agents = [], - serversError = null, - skillsError = null, - hooksError = null, - agentsError = null, - } = options - - mockFrom.mockImplementation((table: string) => { - if (table === 'zeude_users') { - return { - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - single: vi.fn().mockResolvedValue({ data: user, error: userError }), - }), - }), - } - } - if (table === 'zeude_mcp_servers') { - return { - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - or: vi.fn().mockResolvedValue({ data: servers, error: serversError }), - }), - }), - } - } - if (table === 'zeude_skills') { - return { - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - or: vi.fn().mockResolvedValue({ data: skills, error: skillsError }), - }), - }), - } - } - if (table === 'zeude_hooks') { - return { - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - or: vi.fn().mockResolvedValue({ data: hooks, error: hooksError }), - }), - }), - } - } - if (table === 'zeude_agents') { - return { - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - or: vi.fn().mockResolvedValue({ data: agents, error: agentsError }), - }), - }), - } - } - }) -} - beforeEach(() => { vi.clearAllMocks() mockRateLimit.mockReturnValue({ success: true, remaining: 5, resetAt: 0 }) + mockDb.users.findByAgentKey.mockReturnValue({ + id: 'u-1', + email: 'test@test.com', + team: 'team-a', + status: 'active', + disabled_skills: [], + }) + mockDb.mcp.listActiveForTeam.mockReturnValue([]) + mockDb.skills.listActiveForTeam.mockReturnValue([]) + mockDb.hooks.listActiveForTeam.mockReturnValue([]) + mockDb.agents.listActiveForTeam.mockReturnValue([]) }) describe('GET /api/config/[agentKey]', () => { - // Auth validation it('returns 401 when no agent key provided', async () => { const req = new Request('http://localhost/api/config/', {}) const res = await GET(req, { params: Promise.resolve({ agentKey: '' }) }) @@ -151,49 +80,21 @@ describe('GET /api/config/[agentKey]', () => { expect(json.error).toBe('Invalid agent key format') }) - it('returns 400 for agent key with wrong prefix', async () => { - const res = await GET(makeRequest(), makeParams('xx_' + 'a'.repeat(64))) - expect(res.status).toBe(400) - }) - - it('returns 400 for agent key with wrong length', async () => { - const res = await GET(makeRequest(), makeParams('zd_' + 'a'.repeat(32))) - expect(res.status).toBe(400) - }) - it('extracts agent key from Authorization Bearer header', async () => { - setupUserAndDataMocks({}) const req = makeRequestWithAuth(VALID_AGENT_KEY) const res = await GET(req, makeParams('url-key-ignored')) expect(res.status).toBe(200) + expect(mockDb.users.findByAgentKey).toHaveBeenCalledWith(VALID_AGENT_KEY) }) - it('falls back to URL agent key when no auth header', async () => { - setupUserAndDataMocks({}) - const res = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) - expect(res.status).toBe(200) - }) - - // Rate limiting it('returns 429 when rate limited', async () => { mockRateLimit.mockReturnValue({ success: false, remaining: 0, resetAt: Date.now() + 30000 }) const res = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) expect(res.status).toBe(429) - const json = await res.json() - expect(json.error).toBe('Too many requests') - expect(res.headers.get('Retry-After')).toBeTruthy() }) - // User lookup it('returns 401 for unknown agent key', async () => { - mockFrom.mockImplementation(() => ({ - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - single: vi.fn().mockResolvedValue({ data: null, error: { code: 'PGRST116' } }), - }), - }), - })) - + mockDb.users.findByAgentKey.mockReturnValue(null) const res = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) expect(res.status).toBe(401) const json = await res.json() @@ -201,362 +102,59 @@ describe('GET /api/config/[agentKey]', () => { }) it('returns 403 for inactive user', async () => { - setupUserAndDataMocks({ - user: { id: 'u-1', email: 'test@test.com', team: 'team-a', status: 'inactive' }, + mockDb.users.findByAgentKey.mockReturnValue({ + id: 'u-1', + email: 'test@test.com', + team: 'team-a', + status: 'inactive', + disabled_skills: [], }) const res = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) expect(res.status).toBe(403) - const json = await res.json() - expect(json.error).toBe('User account is inactive') - }) - - // DB connection error - it('returns 503 on DB connection error', async () => { - mockFrom.mockImplementation(() => ({ - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - single: vi.fn().mockResolvedValue({ - data: null, - error: { message: 'fetch failed', code: 'NETWORK' }, - }), - }), - }), - })) - - const res = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) - expect(res.status).toBe(503) - const json = await res.json() - expect(json.code).toBe('DB_CONNECTION_ERROR') }) - // AC-33: agents[] in response it('includes agents array in response', async () => { - const testAgents = [ + mockDb.agents.listActiveForTeam.mockReturnValue([ { id: 'a-1', name: 'code-critic', description: 'Reviews code', files: { 'agent.md': '# Agent' }, is_global: true, teams: [] }, - ] - setupUserAndDataMocks({ agents: testAgents }) - - const res = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) - expect(res.status).toBe(200) - const json = await res.json() - expect(json.agents).toBeDefined() - expect(json.agents).toHaveLength(1) - }) - - // AC-37: Agents response format {name, description, files} - it('formats agents as {name, description, files}', async () => { - const testAgents = [ - { id: 'a-1', name: 'code-critic', description: 'Reviews code', files: { 'agent.md': '# Agent' }, is_global: true, teams: [] }, - ] - setupUserAndDataMocks({ agents: testAgents }) - - const res = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) - const json = await res.json() - expect(json.agents[0]).toEqual({ - name: 'code-critic', - description: 'Reviews code', - files: { 'agent.md': '# Agent' }, - }) - // Should NOT include id, is_global, teams in the formatted output - expect(json.agents[0]).not.toHaveProperty('id') - expect(json.agents[0]).not.toHaveProperty('is_global') - expect(json.agents[0]).not.toHaveProperty('teams') - }) - - // AC-36/AC-96: Both content and files in skills response - it('includes both content and files in skills response', async () => { - const testSkills = [ - { - id: 's-1', name: 'deploy', slug: 'deploy', description: 'Deploy', - content: '# Legacy content', files: { 'SKILL.md': '# New content' }, - is_global: true, teams: [], - }, - ] - setupUserAndDataMocks({ skills: testSkills }) - - const res = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) - const json = await res.json() - expect(json.skills[0].content).toBe('# Legacy content') - expect(json.skills[0].files).toEqual({ 'SKILL.md': '# New content' }) - }) - - it('sets files to null when skill has no files', async () => { - const testSkills = [ - { - id: 's-1', name: 'old-skill', slug: 'old-skill', description: 'Old', - content: '# Content only', files: null, - is_global: true, teams: [], - }, - ] - setupUserAndDataMocks({ skills: testSkills }) - - const res = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) - const json = await res.json() - expect(json.skills[0].files).toBeNull() - expect(json.skills[0].content).toBe('# Content only') - }) - - // AC-34/AC-100: Merkle hash includes agents, deterministic sorting - it('includes agents hash in Merkle hash structure', async () => { - setupUserAndDataMocks({ - agents: [ - { id: 'a-1', name: 'agent-a', description: 'A', files: {}, is_global: true, teams: [] }, - ], - }) - - const res = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) - const json = await res.json() - expect(json.hashes).toBeDefined() - expect(json.hashes.agents).toBeDefined() - expect(json.hashes.root).toBeDefined() - expect(json.hashes.mcpServers).toBeDefined() - expect(json.hashes.skills).toBeDefined() - expect(json.hashes.hooks).toBeDefined() - }) - - it('produces deterministic hash regardless of data order', async () => { - const agents = [ - { id: 'a-2', name: 'beta', description: 'B', files: {}, is_global: true, teams: [] }, - { id: 'a-1', name: 'alpha', description: 'A', files: {}, is_global: true, teams: [] }, - ] - setupUserAndDataMocks({ agents }) - - const res1 = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) - const json1 = await res1.json() - - // Reverse order - const agentsReversed = [ - { id: 'a-1', name: 'alpha', description: 'A', files: {}, is_global: true, teams: [] }, - { id: 'a-2', name: 'beta', description: 'B', files: {}, is_global: true, teams: [] }, - ] - setupUserAndDataMocks({ agents: agentsReversed }) - - const res2 = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) - const json2 = await res2.json() - - // Both should produce same root hash since they sort by ID - expect(json1.hashes.root).toBe(json2.hashes.root) - expect(json1.hashes.agents).toBe(json2.hashes.agents) - }) - - // AC-101: ETag/304 support - it('returns 304 when If-None-Match matches current hash', async () => { - setupUserAndDataMocks({}) - - // First request to get the ETag - const res1 = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) - const json1 = await res1.json() - const etag = json1.hashes.root - - // Second request with matching If-None-Match - setupUserAndDataMocks({}) - const req2 = new Request(`http://localhost/api/config/${VALID_AGENT_KEY}`, { - headers: { 'If-None-Match': etag }, - }) - const res2 = await GET(req2, makeParams(VALID_AGENT_KEY)) - expect(res2.status).toBe(304) - }) - - // AC-107: ETag header in 200 - it('includes ETag header in 200 response', async () => { - setupUserAndDataMocks({}) - const res = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) - expect(res.status).toBe(200) - expect(res.headers.get('ETag')).toBeTruthy() - }) - - it('ETag header matches configVersion/root hash', async () => { - setupUserAndDataMocks({}) - const res = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) - const json = await res.json() - expect(res.headers.get('ETag')).toBe(json.hashes.root) - expect(json.configVersion).toBe(json.hashes.root) - }) - - // AC-102: Agents error non-fatal - it('continues without agents when agents query fails', async () => { - setupUserAndDataMocks({ - agentsError: { message: 'agents table error' }, - agents: null as unknown as unknown[], - }) + ]) const res = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) expect(res.status).toBe(200) const json = await res.json() - expect(json.agents).toBeNull() - expect(json.agentCount).toBeNull() - // Other data should still be present - expect(json.mcpServers).toBeDefined() + expect(json.agents).toEqual([ + { name: 'code-critic', description: 'Reviews code', files: { 'agent.md': '# Agent' } }, + ]) }) - it('continues without skills when skills query fails', async () => { - setupUserAndDataMocks({ - skillsError: { message: 'skills table error' }, - skills: null as unknown as unknown[], + it('filters out disabled skills', async () => { + mockDb.users.findByAgentKey.mockReturnValue({ + id: 'u-1', + email: 'test@test.com', + team: 'team-a', + status: 'active', + disabled_skills: ['hidden-skill'], }) + mockDb.skills.listActiveForTeam.mockReturnValue([ + { id: 's-1', name: 'Visible', slug: 'visible-skill', description: null, content: null, files: {}, is_global: true, teams: [] }, + { id: 's-2', name: 'Hidden', slug: 'hidden-skill', description: null, content: null, files: {}, is_global: true, teams: [] }, + ]) const res = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) expect(res.status).toBe(200) const json = await res.json() - expect(json.skills).toBeNull() - expect(json.skillCount).toBeNull() - }) - - it('returns 500 when servers query fails (fatal)', async () => { - setupUserAndDataMocks({ - serversError: { message: 'servers table error' }, - servers: null as unknown as unknown[], - }) - - const res = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) - expect(res.status).toBe(500) - const json = await res.json() - expect(json.error).toBe('Failed to fetch config') - }) - - // agentCount in response - it('includes agentCount in response', async () => { - const testAgents = [ - { id: 'a-1', name: 'agent-one', description: 'One', files: {}, is_global: true, teams: [] }, - { id: 'a-2', name: 'agent-two', description: 'Two', files: {}, is_global: true, teams: [] }, - ] - setupUserAndDataMocks({ agents: testAgents }) - - const res = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) - const json = await res.json() - expect(json.agentCount).toBe(2) - }) - - // User info in response - it('includes user info in response', async () => { - setupUserAndDataMocks({ - user: { id: 'u-1', email: 'test@test.com', team: 'team-a', status: 'active' }, - }) - - const res = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) - const json = await res.json() - expect(json.userId).toBe('u-1') - expect(json.userEmail).toBe('test@test.com') - expect(json.team).toBe('team-a') - }) - - it('defaults team to "default" when user has no team', async () => { - setupUserAndDataMocks({ - user: { id: 'u-1', email: 'test@test.com', team: '', status: 'active' }, - }) - - const res = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) - const json = await res.json() - expect(json.team).toBe('default') - }) - - // MCP servers formatting - it('formats MCP servers correctly', async () => { - const servers = [ - { id: 's-1', name: 'My Server', command: 'npx', args: ['-y', 'server'], env: { API_KEY: 'xxx' }, is_global: true, teams: [] }, - ] - setupUserAndDataMocks({ servers }) - - const res = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) - const json = await res.json() - expect(json.mcpServers['my-server']).toEqual({ - command: 'npx', - args: ['-y', 'server'], - env: { API_KEY: 'xxx' }, - }) - }) - - it('omits env when empty', async () => { - const servers = [ - { id: 's-1', name: 'Simple', command: 'node', args: [], env: {}, is_global: true, teams: [] }, - ] - setupUserAndDataMocks({ servers }) - - const res = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) - const json = await res.json() - expect(json.mcpServers['simple']).toEqual({ - command: 'node', - args: [], - }) - expect(json.mcpServers['simple']).not.toHaveProperty('env') - }) - - // URL-based MCP servers - it('formats URL-based server with type:http', async () => { - const servers = [ - { id: 's-1', name: 'Channeltalk', url: 'https://mcp.example.com/channeltalk', command: '', args: [], env: {}, is_global: true, teams: [] }, - ] - setupUserAndDataMocks({ servers }) - - const res = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) - const json = await res.json() - expect(json.mcpServers['channeltalk']).toEqual({ - type: 'http', - url: 'https://mcp.example.com/channeltalk', - }) - }) - - it('URL-based server does not include command/args/env fields', async () => { - const servers = [ - { id: 's-1', name: 'PostHog', url: 'https://mcp.example.com/posthog', command: '', args: [], env: { UNUSED: 'val' }, is_global: true, teams: [] }, - ] - setupUserAndDataMocks({ servers }) - - const res = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) - const json = await res.json() - expect(json.mcpServers['posthog']).toEqual({ - type: 'http', - url: 'https://mcp.example.com/posthog', - }) - expect(json.mcpServers['posthog']).not.toHaveProperty('command') - expect(json.mcpServers['posthog']).not.toHaveProperty('args') - expect(json.mcpServers['posthog']).not.toHaveProperty('env') - }) - - it('mixes command-based and URL-based servers correctly', async () => { - const servers = [ - { id: 's-1', name: 'Slack Agent', command: 'npx', args: ['-y', 'slack-mcp'], env: { TOKEN: 'abc' }, is_global: true, teams: [] }, - { id: 's-2', name: 'Channeltalk', url: 'https://mcp.example.com/ct', command: '', args: [], env: {}, is_global: true, teams: [] }, - ] - setupUserAndDataMocks({ servers }) - - const res = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) - const json = await res.json() - - // command-based: no type field - expect(json.mcpServers['slack-agent']).toEqual({ - command: 'npx', - args: ['-y', 'slack-mcp'], - env: { TOKEN: 'abc' }, - }) - expect(json.mcpServers['slack-agent']).not.toHaveProperty('type') - - // URL-based: type:http - expect(json.mcpServers['channeltalk']).toEqual({ - type: 'http', - url: 'https://mcp.example.com/ct', - }) + expect(json.skills).toHaveLength(1) + expect(json.skills[0].slug).toBe('visible-skill') }) - it('URL-based server changes mcpServers hash', async () => { - // Without URL server - const servers1 = [ - { id: 's-1', name: 'Basic', command: 'node', args: [], env: {}, is_global: true, teams: [] }, - ] - setupUserAndDataMocks({ servers: servers1 }) - const res1 = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) - const json1 = await res1.json() + it('returns 304 when If-None-Match matches the current root hash', async () => { + const first = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) + const firstJson = await first.json() - // With additional URL server - const servers2 = [ - { id: 's-1', name: 'Basic', command: 'node', args: [], env: {}, is_global: true, teams: [] }, - { id: 's-2', name: 'Remote', url: 'https://mcp.example.com/remote', command: '', args: [], env: {}, is_global: true, teams: [] }, - ] - setupUserAndDataMocks({ servers: servers2 }) - const res2 = await GET(makeRequest(), makeParams(VALID_AGENT_KEY)) - const json2 = await res2.json() + const second = await GET( + makeRequest({ 'If-None-Match': firstJson.hashes.root }), + makeParams(VALID_AGENT_KEY) + ) - expect(json1.hashes.mcpServers).not.toBe(json2.hashes.mcpServers) - expect(json1.hashes.root).not.toBe(json2.hashes.root) + expect(second.status).toBe(304) }) }) diff --git a/zeude/dashboard/src/app/api/config/[agentKey]/route.ts b/zeude/dashboard/src/app/api/config/[agentKey]/route.ts index a0ffe1e..b3f5d69 100644 --- a/zeude/dashboard/src/app/api/config/[agentKey]/route.ts +++ b/zeude/dashboard/src/app/api/config/[agentKey]/route.ts @@ -1,6 +1,6 @@ -import { createServerClient, isDBConnectionError } from '@/lib/supabase' import { rateLimit, getClientIP } from '@/lib/rate-limit' import { createHash } from 'crypto' +import { getOperationalDb } from '@/lib/db' // Stable hash function for deterministic hashing // Uses sorted keys to ensure consistent output regardless of object property order @@ -64,21 +64,12 @@ export async function GET( return Response.json({ error: 'Invalid agent key format' }, { status: 400 }) } - const supabase = createServerClient() + const db = getOperationalDb() // Find user by agent key - const { data: user, error: userError } = await supabase - .from('zeude_users') - .select('id, email, team, status, disabled_skills') - .eq('agent_key', agentKey) - .single() + const user = db.users.findByAgentKey(agentKey) - if (isDBConnectionError(userError)) { - console.error('DB connection failed during config fetch:', { error: userError, agentKey }) - return Response.json({ error: 'Database connection failed. This is a server infrastructure issue.', code: 'DB_CONNECTION_ERROR' }, { status: 503 }) - } - - if (userError || !user) { + if (!user) { return Response.json({ error: 'Invalid agent key' }, { status: 401 }) } @@ -88,63 +79,20 @@ export async function GET( // Fetch MCP servers, skills, hooks, and agents in parallel for better performance // DB-level filtering by team (is_global OR team membership) for efficiency - const [serversResult, skillsResult, hooksResult, agentsResult] = await Promise.all([ - supabase - .from('zeude_mcp_servers') - .select('id, name, url, command, args, env, is_global, teams') - .eq('status', 'active') - .or(`is_global.eq.true,teams.cs.{"${user.team}"}`), - supabase - .from('zeude_skills') - .select('id, name, slug, description, content, files, is_global, teams') - .eq('status', 'active') - .or(`is_global.eq.true,teams.cs.{"${user.team}"}`), - supabase - .from('zeude_hooks') - .select('id, name, event, description, script_content, script_type, env, is_global, teams') - .eq('status', 'active') - .or(`is_global.eq.true,teams.cs.{"${user.team}"}`), - supabase - .from('zeude_agents') - .select('id, name, description, files, is_global, teams') - .eq('status', 'active') - .or(`is_global.eq.true,teams.cs.{"${user.team}"}`), - ]) - - const { data: servers, error: serversError } = serversResult - const { data: skills, error: skillsError } = skillsResult - const { data: hooks, error: hooksError } = hooksResult - const { data: agents, error: agentsError } = agentsResult + const servers = db.mcp.listActiveForTeam(user.team) + const skills = db.skills.listActiveForTeam(user.team) + const hooks = db.hooks.listActiveForTeam(user.team) + const agents = db.agents.listActiveForTeam(user.team) // Sort arrays by ID for deterministic hash generation // Without sorting, DB may return rows in different order causing hash mismatch - servers?.sort((a, b) => a.id.localeCompare(b.id)) - skills?.sort((a, b) => a.id.localeCompare(b.id)) - hooks?.sort((a, b) => a.id.localeCompare(b.id)) - agents?.sort((a, b) => a.id.localeCompare(b.id)) - - if (serversError) { - console.error('Failed to fetch MCP servers:', serversError) - return Response.json({ error: 'Failed to fetch config' }, { status: 500 }) - } - - if (skillsError) { - console.error('Failed to fetch skills:', skillsError) - // Non-fatal: continue without skills - } - - if (hooksError) { - console.error('Failed to fetch hooks:', hooksError) - // Non-fatal: continue without hooks - } - - if (agentsError) { - console.error('Failed to fetch agents:', agentsError) - // Non-fatal: continue without agents - } + servers.sort((a, b) => a.id.localeCompare(b.id)) + skills.sort((a, b) => a.id.localeCompare(b.id)) + hooks.sort((a, b) => a.id.localeCompare(b.id)) + agents.sort((a, b) => a.id.localeCompare(b.id)) // Servers already filtered at DB level - const applicableServers = servers || [] + const applicableServers = servers // Format as claude.json mcpServers format const mcpServers: Record }> = {} @@ -181,24 +129,24 @@ export async function GET( // Skills already filtered at DB level; additionally filter out user's disabled skills const userDisabledSkills: string[] = user.disabled_skills ?? [] - const applicableSkills = skillsError ? null : (skills || []).filter( + const applicableSkills = skills.filter( s => !userDisabledSkills.includes(s.slug) ) // Format skills for CLI (include files for multi-file support, keep content for backward compat) - const skillsList = applicableSkills ? applicableSkills.map(skill => ({ + const skillsList = applicableSkills.map(skill => ({ name: skill.name, slug: skill.slug, description: skill.description, content: skill.content, files: skill.files || null, - })) : null + })) // Hooks already filtered at DB level - const applicableHooks = hooksError ? null : (hooks || []) + const applicableHooks = hooks // Format hooks for CLI - const hooksList = applicableHooks ? applicableHooks.map(hook => ({ + const hooksList = applicableHooks.map(hook => ({ id: hook.id, name: hook.name, event: hook.event, @@ -206,17 +154,17 @@ export async function GET( script: hook.script_content, scriptType: hook.script_type, env: hook.env || {}, - })) : null + })) // Agents already filtered at DB level - const applicableAgents = agentsError ? null : (agents || []) + const applicableAgents = agents // Format agents for CLI - const agentsList = applicableAgents ? applicableAgents.map(agent => ({ + const agentsList = applicableAgents.map(agent => ({ name: agent.name, description: agent.description, files: agent.files, - })) : null + })) // Compute category-level hashes for efficient sync (Merkle-tree style) const mcpServersHash = stableHash(mcpServers) @@ -250,9 +198,9 @@ export async function GET( }, configVersion: rootHash, // Root hash as version (replaces timestamp) serverCount: applicableServers.length, - skillCount: skillsList?.length ?? null, - hookCount: hooksList?.length ?? null, - agentCount: agentsList?.length ?? null, + skillCount: skillsList.length, + hookCount: hooksList.length, + agentCount: agentsList.length, // User info for hook env var injection and OTEL telemetry userId: user.id, // Supabase UUID - used to match ClickHouse data with Supabase userEmail: user.email, diff --git a/zeude/dashboard/src/app/api/invite/[token]/route.ts b/zeude/dashboard/src/app/api/invite/[token]/route.ts index 3b1b85c..f5723b5 100644 --- a/zeude/dashboard/src/app/api/invite/[token]/route.ts +++ b/zeude/dashboard/src/app/api/invite/[token]/route.ts @@ -1,6 +1,6 @@ -import { createServerClient } from '@/lib/supabase' import { randomBytes } from 'crypto' import { rateLimit } from '@/lib/rate-limit' +import { getOperationalDb } from '@/lib/db' // Email validation regex (RFC 5322 simplified) const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ @@ -17,15 +17,10 @@ export async function GET( return Response.json({ valid: false, reason: 'invalid_format' }, { status: 400 }) } - const supabase = createServerClient() + const db = getOperationalDb() + const invite = db.invites.findByToken(token) - const { data: invite, error } = await supabase - .from('zeude_invites') - .select('id, team, role, expires_at, used_at') - .eq('token', token) - .single() - - if (error || !invite) { + if (!invite) { return Response.json({ valid: false, reason: 'not_found' }) } @@ -85,27 +80,16 @@ export async function POST( return Response.json({ error: 'Valid email is required' }, { status: 400 }) } - const supabase = createServerClient() + const db = getOperationalDb() // ATOMIC: Claim invite FIRST using update-first pattern to prevent race condition // This ensures only one request can successfully claim an unused, non-expired invite - const { data: claimedInvite, error: claimError } = await supabase - .from('zeude_invites') - .update({ used_at: new Date().toISOString() }) - .eq('token', token) - .is('used_at', null) - .gt('expires_at', new Date().toISOString()) - .select('id, team, role, created_by') - .single() - - if (claimError || !claimedInvite) { + const claimedInvite = db.invites.claimByToken(token, new Date().toISOString()) + + if (!claimedInvite) { // Could be: not found, already used, or expired // Check which case for better error message - const { data: invite } = await supabase - .from('zeude_invites') - .select('used_at, expires_at') - .eq('token', token) - .single() + const invite = db.invites.findByToken(token) if (!invite) { return Response.json({ error: 'Invite not found' }, { status: 404 }) @@ -120,18 +104,11 @@ export async function POST( } // Check if email already exists - const { data: existingUser } = await supabase - .from('zeude_users') - .select('id') - .eq('email', email.toLowerCase()) - .single() + const existingUser = db.users.findByEmail(email.toLowerCase()) if (existingUser) { // Rollback: unmark the invite since we can't create the user - await supabase - .from('zeude_invites') - .update({ used_at: null, used_by: null }) - .eq('id', claimedInvite.id) + db.invites.resetClaim(claimedInvite.id) return Response.json({ error: 'Email already registered' }, { status: 400 }) } @@ -139,35 +116,27 @@ export async function POST( const agentKey = 'zd_' + randomBytes(32).toString('hex') // Create user - const { data: newUser, error: userError } = await supabase - .from('zeude_users') - .insert({ + let newUser + try { + newUser = db.users.create({ email: email.toLowerCase(), name, agent_key: agentKey, team: claimedInvite.team, role: claimedInvite.role, status: 'active', + disabled_skills: [], invited_by: claimedInvite.created_by, }) - .select('id') - .single() - - if (userError) { + } catch (userError) { console.error('Failed to create user:', userError) // Rollback: unmark the invite - await supabase - .from('zeude_invites') - .update({ used_at: null, used_by: null }) - .eq('id', claimedInvite.id) + db.invites.resetClaim(claimedInvite.id) return Response.json({ error: 'Failed to create user' }, { status: 500 }) } // Update invite with the user ID who used it - await supabase - .from('zeude_invites') - .update({ used_by: newUser.id }) - .eq('id', claimedInvite.id) + db.invites.markUsedBy(claimedInvite.id, newUser.id) return Response.json({ agentKey, diff --git a/zeude/dashboard/src/app/api/leaderboard/route.ts b/zeude/dashboard/src/app/api/leaderboard/route.ts index 27fd3ce..dd26da6 100644 --- a/zeude/dashboard/src/app/api/leaderboard/route.ts +++ b/zeude/dashboard/src/app/api/leaderboard/route.ts @@ -1,6 +1,6 @@ import { getSession } from '@/lib/session' import { getClickHouseClient, buildMVSourceCondition, parseSourceParam, escapeClickHouseString } from '@/lib/clickhouse' -import { createServerClient } from '@/lib/supabase' +import { getOperationalDb } from '@/lib/db' import { EXCLUDED_SKILLS } from '@/lib/skill-utils' import { resolveUserNames } from '@/lib/name-resolution' @@ -69,7 +69,7 @@ export async function GET(req: Request) { const excludedSkills = EXCLUDED_SKILLS.map(escapeClickHouseString).join(', ') const excludedSkillClause = excludedSkills ? `AND d_invoked_name NOT IN (${excludedSkills})` : '' const cohortFilter = cohortKey - ? await resolveCohortFilter(cohortKey, clickhouse) + ? await resolveCohortFilter(cohortKey) : null if (cohortFilter && cohortFilter.memberCount === 0) { @@ -235,9 +235,8 @@ export async function GET(req: Request) { // === Name Resolution (Zeude Identity SSOT) === // Shared utility handles Supabase lookup + email fallback + error handling - const supabase = createServerClient() const allRows = [...tokenData, ...previousTokenData, ...skillUsersData] - const { getDisplayName } = await resolveUserNames(supabase, allRows) + const { getDisplayName } = await resolveUserNames(allRows) // Format token leaderboard const topTokenUsers: LeaderboardUser[] = tokenData.map((row, index) => { @@ -266,16 +265,11 @@ export async function GET(req: Request) { const skillDescriptionMap = new Map() if (skillData.length > 0) { try { - const supabase = createServerClient() const slugs = skillData.map(r => r.skill_name) - const { data: skillRows } = await supabase - .from('zeude_skills') - .select('slug, description') - .in('slug', slugs) - if (skillRows) { - for (const row of skillRows) { - if (row.description) skillDescriptionMap.set(row.slug, row.description) - } + const db = getOperationalDb() + const skillRows = await db.skills.listBySlugs(slugs) + for (const row of skillRows) { + if (row.description) skillDescriptionMap.set(row.slug, row.description) } } catch (descError) { console.error('Leaderboard skill description lookup failed:', descError) @@ -469,17 +463,13 @@ function buildEmptyLeaderboardResponse( } async function resolveCohortFilter( - cohortKey: string, - clickhouse: ReturnType + cohortKey: string ): Promise { - const supabase = createServerClient() - - const { data: members, error: membersError } = await supabase - .from('zeude_cohort_members') - .select('user_id, created_at') - .eq('cohort_key', cohortKey) - - if (membersError) { + const db = getOperationalDb() + let members + try { + members = await db.cohorts.listMembers(cohortKey) + } catch (membersError) { console.error('Failed to fetch cohort members:', membersError) return { cohortKey, @@ -512,12 +502,10 @@ async function resolveCohortFilter( } } - const { data: users, error: usersError } = await supabase - .from('zeude_users') - .select('id, email') - .in('id', memberIds) - - if (usersError) { + let users + try { + users = await db.users.listByIds(memberIds) + } catch (usersError) { console.error('Failed to fetch cohort users:', usersError) return { cohortKey, @@ -574,4 +562,3 @@ function getKstDayWindowFromUtcMs(utcMs: number): { endUtcMs, } } - diff --git a/zeude/dashboard/src/app/api/prompts/[id]/route.ts b/zeude/dashboard/src/app/api/prompts/[id]/route.ts index 0c9bb7a..0870278 100644 --- a/zeude/dashboard/src/app/api/prompts/[id]/route.ts +++ b/zeude/dashboard/src/app/api/prompts/[id]/route.ts @@ -1,5 +1,5 @@ import { getClickHouseClient } from '@/lib/clickhouse' -import { createServerClient } from '@/lib/supabase' +import { getOperationalDb } from '@/lib/db' import { rateLimit } from '@/lib/rate-limit' import { AGENT_KEY_PATTERN, @@ -73,14 +73,10 @@ export async function PATCH( } // Validate user - include status in select - const supabase = createServerClient() - const { data: user, error: userError } = await supabase - .from('zeude_users') - .select('id, status') - .eq('agent_key', agentKey) - .single() - - if (userError || !user || user.status !== 'active') { + const db = getOperationalDb() + const user = await db.users.findByAgentKey(agentKey) + + if (!user || user.status !== 'active') { return Response.json({ error: 'Invalid or inactive user' }, { status: 401 }) } diff --git a/zeude/dashboard/src/app/api/prompts/route.ts b/zeude/dashboard/src/app/api/prompts/route.ts index 9499e9d..c4b93c3 100644 --- a/zeude/dashboard/src/app/api/prompts/route.ts +++ b/zeude/dashboard/src/app/api/prompts/route.ts @@ -1,5 +1,5 @@ import { getClickHouseClient } from '@/lib/clickhouse' -import { createServerClient } from '@/lib/supabase' +import { getOperationalDb } from '@/lib/db' import { rateLimit } from '@/lib/rate-limit' import { AGENT_KEY_PATTERN, @@ -55,14 +55,10 @@ export async function POST(req: Request) { } // Validate user - const supabase = createServerClient() - const { data: user, error: userError } = await supabase - .from('zeude_users') - .select('id, email, team, status') - .eq('agent_key', agentKey) - .single() + const db = getOperationalDb() + const user = await db.users.findByAgentKey(agentKey) - if (userError || !user || user.status !== 'active') { + if (!user || user.status !== 'active') { return Response.json({ error: 'Invalid or inactive user' }, { status: 401 }) } diff --git a/zeude/dashboard/src/app/api/skill-rules/route.ts b/zeude/dashboard/src/app/api/skill-rules/route.ts index 4dd869c..92d883f 100644 --- a/zeude/dashboard/src/app/api/skill-rules/route.ts +++ b/zeude/dashboard/src/app/api/skill-rules/route.ts @@ -1,4 +1,4 @@ -import { createServerClient } from '@/lib/supabase' +import { getOperationalDb } from '@/lib/db' import { rateLimit } from '@/lib/rate-limit' import { AGENT_KEY_PATTERN } from '@/lib/prompt-utils' import { EXCLUDED_SKILLS } from '@/lib/skill-utils' @@ -64,36 +64,13 @@ export async function GET(req: Request) { ) } - const supabase = createServerClient() + const db = getOperationalDb() + const user = await db.users.findByAgentKey(agentKey) - // Find user by agent key - const { data: user, error: userError } = await supabase - .from('zeude_users') - .select('id, team, status') - .eq('agent_key', agentKey) - .single() - - if (userError || !user || user.status !== 'active') { + if (!user || user.status !== 'active') { return Response.json({ error: 'Invalid or inactive user' }, { status: 401 }) } - - // Fetch skills with rules - filter at SQL level for efficiency - // Include command-style skills as well so guidance can suggest all installed skills. - // Keep column selection minimal for performance. - const { data: skills, error: skillsError } = await supabase - .from('zeude_skills') - .select( - 'slug, description, keywords, primary_keywords, secondary_keywords, hint, is_general, is_global, teams' - ) - .eq('status', 'active') - .or(`is_global.eq.true,teams.cs.{${user.team || ''}}`) - - if (skillsError) { - console.error('Failed to fetch skills:', skillsError) - return Response.json({ error: 'Failed to fetch skills' }, { status: 500 }) - } - - const applicableSkills = skills || [] + const applicableSkills = await db.skills.listActiveForTeam(user.team || '') // Build skill-rules.json format // Also exclude skills in EXCLUDED_SKILLS list diff --git a/zeude/dashboard/src/app/api/skill-suggest/route.ts b/zeude/dashboard/src/app/api/skill-suggest/route.ts index 435e9e8..9c40054 100644 --- a/zeude/dashboard/src/app/api/skill-suggest/route.ts +++ b/zeude/dashboard/src/app/api/skill-suggest/route.ts @@ -5,7 +5,7 @@ * * This endpoint remains for backward compatibility but will be removed in a future version. */ -import { createServerClient } from '@/lib/supabase' +import { getOperationalDb } from '@/lib/db' import { rateLimit } from '@/lib/rate-limit' import { AGENT_KEY_PATTERN } from '@/lib/prompt-utils' @@ -116,14 +116,10 @@ export async function POST(req: Request) { } // Validate user - const supabase = createServerClient() - const { data: user, error: userError } = await supabase - .from('zeude_users') - .select('id, status') - .eq('agent_key', agentKey) - .single() - - if (userError || !user || user.status !== 'active') { + const db = getOperationalDb() + const user = await db.users.findByAgentKey(agentKey) + + if (!user || user.status !== 'active') { return Response.json({ error: 'Invalid or inactive user' }, { status: 401 }) } diff --git a/zeude/dashboard/src/app/api/skill-suggestions/route.ts b/zeude/dashboard/src/app/api/skill-suggestions/route.ts index 5121d0c..ca8c967 100644 --- a/zeude/dashboard/src/app/api/skill-suggestions/route.ts +++ b/zeude/dashboard/src/app/api/skill-suggestions/route.ts @@ -1,5 +1,5 @@ import { getClickHouseClient } from '@/lib/clickhouse' -import { createServerClient } from '@/lib/supabase' +import { getOperationalDb } from '@/lib/db' import { rateLimit } from '@/lib/rate-limit' import { AGENT_KEY_PATTERN } from '@/lib/prompt-utils' @@ -45,14 +45,10 @@ export async function POST(req: Request) { } // Validate user - const supabase = createServerClient() - const { data: user, error: userError } = await supabase - .from('zeude_users') - .select('id, email, team, status') - .eq('agent_key', agentKey) - .single() + const db = getOperationalDb() + const user = await db.users.findByAgentKey(agentKey) - if (userError || !user || user.status !== 'active') { + if (!user || user.status !== 'active') { return Response.json({ error: 'Invalid or inactive user' }, { status: 401 }) } diff --git a/zeude/dashboard/src/app/api/status/[agentKey]/route.ts b/zeude/dashboard/src/app/api/status/[agentKey]/route.ts index 2b9b6b5..1da1f4f 100644 --- a/zeude/dashboard/src/app/api/status/[agentKey]/route.ts +++ b/zeude/dashboard/src/app/api/status/[agentKey]/route.ts @@ -1,5 +1,5 @@ -import { createServerClient } from '@/lib/supabase' import { rateLimit } from '@/lib/rate-limit' +import { getOperationalDb } from '@/lib/db' // Agent key format: zd_ followed by 64 hex characters const AGENT_KEY_PATTERN = /^zd_[a-f0-9]{64}$/ @@ -59,16 +59,12 @@ export async function POST( return Response.json({ error: 'Invalid agent key format' }, { status: 400 }) } - const supabase = createServerClient() + const db = getOperationalDb() // Find user by agent key - const { data: user, error: userError } = await supabase - .from('zeude_users') - .select('id, team, status') - .eq('agent_key', agentKey) - .single() + const user = db.users.findByAgentKey(agentKey) - if (userError || !user) { + if (!user) { return Response.json({ error: 'Invalid agent key' }, { status: 401 }) } @@ -84,19 +80,11 @@ export async function POST( } // Get MCP servers to map server names to IDs - const { data: mcpServers, error: serversError } = await supabase - .from('zeude_mcp_servers') - .select('id, name') - .eq('status', 'active') - - if (serversError) { - console.error('Failed to fetch MCP servers:', serversError) - return Response.json({ error: 'Failed to fetch servers' }, { status: 500 }) - } + const mcpServers = db.mcp.listActiveNamesAndIds() // Create a map of server name (kebab-cased) to server ID const serverNameToId: Record = {} - for (const server of mcpServers || []) { + for (const server of mcpServers) { const kebabName = server.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '') serverNameToId[kebabName] = server.id // Also map the exact name @@ -125,16 +113,7 @@ export async function POST( } if (mcpUpsertData.length > 0) { - const { error: upsertError } = await supabase - .from('zeude_mcp_install_status') - .upsert(mcpUpsertData, { - onConflict: 'user_id,mcp_server_id', - }) - - if (upsertError) { - console.error('Failed to upsert MCP install status:', upsertError) - return Response.json({ error: 'Failed to save MCP status' }, { status: 500 }) - } + db.installStatus.upsertMcpStatuses(mcpUpsertData) } } @@ -155,16 +134,7 @@ export async function POST( } if (hookUpsertData.length > 0) { - const { error: upsertError } = await supabase - .from('zeude_hook_install_status') - .upsert(hookUpsertData, { - onConflict: 'user_id,hook_id', - }) - - if (upsertError) { - console.error('Failed to upsert hook install status:', upsertError) - return Response.json({ error: 'Failed to save hook status' }, { status: 500 }) - } + db.installStatus.upsertHookStatuses(hookUpsertData) } } diff --git a/zeude/dashboard/src/app/api/user/skills/route.ts b/zeude/dashboard/src/app/api/user/skills/route.ts index c12d6d0..a25e7a6 100644 --- a/zeude/dashboard/src/app/api/user/skills/route.ts +++ b/zeude/dashboard/src/app/api/user/skills/route.ts @@ -1,5 +1,5 @@ -import { createServerClient } from '@/lib/supabase' import { getSession } from '@/lib/session' +import { getOperationalDb } from '@/lib/db' // GET: List all available skills with user's disable status export async function GET() { @@ -9,37 +9,15 @@ export async function GET() { return Response.json({ error: 'Not authenticated' }, { status: 401 }) } - const supabase = createServerClient() + const db = getOperationalDb() const user = session.user // Fetch skills available to this user's team - const { data: skills, error: skillsError } = await supabase - .from('zeude_skills') - .select('id, name, slug, description, is_global, teams, status') - .eq('status', 'active') - .or(`is_global.eq.true,teams.cs.{"${user.team}"}`) - .order('name') + const skills = db.skills.listActiveForTeam(user.team).sort((a, b) => a.name.localeCompare(b.name)) + const currentUser = db.users.findById(user.id) + const disabledSkills: string[] = currentUser?.disabled_skills || [] - if (skillsError) { - console.error('Failed to fetch skills:', skillsError) - return Response.json({ error: 'Failed to fetch skills' }, { status: 500 }) - } - - // Get user's disabled skills - const { data: userData, error: userError } = await supabase - .from('zeude_users') - .select('disabled_skills') - .eq('id', user.id) - .single() - - if (userError) { - console.error('Failed to fetch user preferences:', userError) - return Response.json({ error: 'Failed to fetch preferences' }, { status: 500 }) - } - - const disabledSkills: string[] = userData?.disabled_skills || [] - - const skillsWithStatus = (skills || []).map(skill => ({ + const skillsWithStatus = skills.map(skill => ({ ...skill, disabled: disabledSkills.includes(skill.slug), })) @@ -47,7 +25,7 @@ export async function GET() { return Response.json({ skills: skillsWithStatus, disabledCount: disabledSkills.length, - totalCount: skills?.length || 0, + totalCount: skills.length, }) } catch (err) { console.error('User skills fetch error:', err) @@ -79,35 +57,17 @@ export async function PATCH(req: Request) { return Response.json({ error: 'Invalid slug format' }, { status: 400 }) } - const supabase = createServerClient() + const db = getOperationalDb() const userId = session.user.id // Verify the slug exists as an active skill accessible to this user - const { data: skillExists, error: skillCheckError } = await supabase - .from('zeude_skills') - .select('id') - .eq('slug', slug) - .eq('status', 'active') - .or(`is_global.eq.true,teams.cs.{"${session.user.team}"}`) - .limit(1) - .single() + const skillExists = db.skills.findAccessibleActiveBySlug(session.user.team, slug) - if (skillCheckError || !skillExists) { + if (!skillExists) { return Response.json({ error: 'Skill not found or not accessible' }, { status: 404 }) } - // Atomic toggle using PostgreSQL function (avoids read-then-write race condition) - const { data: disabledSkills, error: rpcError } = await supabase - .rpc('toggle_disabled_skill', { - p_user_id: userId, - p_slug: slug, - p_disabled: disabled, - }) - - if (rpcError) { - console.error('Failed to toggle disabled skill:', rpcError) - return Response.json({ error: 'Failed to update preferences' }, { status: 500 }) - } + const disabledSkills = db.users.toggleDisabledSkill(userId, slug, disabled) return Response.json({ slug, diff --git a/zeude/dashboard/src/lib/data/admin-hooks.ts b/zeude/dashboard/src/lib/data/admin-hooks.ts index 2196186..538bfd4 100644 --- a/zeude/dashboard/src/lib/data/admin-hooks.ts +++ b/zeude/dashboard/src/lib/data/admin-hooks.ts @@ -1,5 +1,5 @@ -import { createServerClient } from '@/lib/supabase' import { getSession } from '@/lib/session' +import { getOperationalDb } from '@/lib/db' interface InstallStatusSummary { installed: number @@ -18,26 +18,14 @@ export async function fetchHooksData() { if (!session) throw new Error('Not authenticated') if (session.user.role !== 'admin') throw new Error('Admin access required') - const supabase = createServerClient() - - const { data: hooks, error } = await supabase - .from('zeude_hooks') - .select('*') - .order('created_at', { ascending: false }) - - if (error) throw new Error(`Failed to fetch hooks: ${error.message}`) - - const { data: usersData } = await supabase - .from('zeude_users') - .select('id, name, email, team') - .order('team') + const db = getOperationalDb() + const hooks = await db.hooks.listAll() + const usersData = await db.users.listAll() const teams = [...new Set(usersData?.map(u => u.team) || [])] const users = usersData || [] - const { data: installStatus } = await supabase - .from('zeude_hook_install_status') - .select('user_id, hook_id, installed, version, last_checked_at') + const installStatus = await db.installStatus.listHookStatuses() // Pre-compute maps for O(1) lookups const userMap = new Map(users.map(u => [u.id, u])) diff --git a/zeude/dashboard/src/lib/data/admin-mcp.ts b/zeude/dashboard/src/lib/data/admin-mcp.ts index ff3bbed..b27e2bc 100644 --- a/zeude/dashboard/src/lib/data/admin-mcp.ts +++ b/zeude/dashboard/src/lib/data/admin-mcp.ts @@ -1,5 +1,5 @@ -import { createServerClient } from '@/lib/supabase' import { getSession } from '@/lib/session' +import { getOperationalDb } from '@/lib/db' interface InstallStatusSummary { installed: number @@ -18,26 +18,14 @@ export async function fetchMCPData() { if (!session) throw new Error('Not authenticated') if (session.user.role !== 'admin') throw new Error('Admin access required') - const supabase = createServerClient() - - const { data: servers, error } = await supabase - .from('zeude_mcp_servers') - .select('*') - .order('created_at', { ascending: false }) - - if (error) throw new Error(`Failed to fetch MCP servers: ${error.message}`) - - const { data: usersData } = await supabase - .from('zeude_users') - .select('id, name, email, team') - .order('team') + const db = getOperationalDb() + const servers = await db.mcp.listAll() + const usersData = await db.users.listAll() const teams = [...new Set(usersData?.map(u => u.team) || [])] const users = usersData || [] - const { data: installStatus } = await supabase - .from('zeude_mcp_install_status') - .select('user_id, mcp_server_id, installed, version, last_checked_at') + const installStatus = await db.installStatus.listMcpStatuses() const installStatusByServer: Record = {} diff --git a/zeude/dashboard/src/lib/data/admin-skills.ts b/zeude/dashboard/src/lib/data/admin-skills.ts index 375c2f4..69ee0a6 100644 --- a/zeude/dashboard/src/lib/data/admin-skills.ts +++ b/zeude/dashboard/src/lib/data/admin-skills.ts @@ -1,24 +1,14 @@ -import { createServerClient } from '@/lib/supabase' import { getSession } from '@/lib/session' +import { getOperationalDb } from '@/lib/db' export async function fetchSkillsData() { const session = await getSession() if (!session) throw new Error('Not authenticated') if (session.user.role !== 'admin') throw new Error('Admin access required') - const supabase = createServerClient() - - const { data: skills, error } = await supabase - .from('zeude_skills') - .select('*') - .order('created_at', { ascending: false }) - - if (error) throw new Error(`Failed to fetch skills: ${error.message}`) - - const { data: usersData } = await supabase - .from('zeude_users') - .select('id, name, email, team, status') - .order('team') + const db = getOperationalDb() + const skills = await db.skills.listAll() + const usersData = await db.users.listAll() const teams = [...new Set(usersData?.map(u => u.team) || [])] @@ -44,14 +34,8 @@ export async function fetchSkillsStats() { if (!session) throw new Error('Not authenticated') if (session.user.role !== 'admin') throw new Error('Admin access required') - const supabase = createServerClient() - - const { data: users, error } = await supabase - .from('zeude_users') - .select('disabled_skills') - .eq('status', 'active') - - if (error) throw new Error(`Failed to fetch stats: ${error.message}`) + const db = getOperationalDb() + const users = (await db.users.listAll()).filter(user => user.status === 'active') const disableCounts: Record = {} const totalActiveUsers = users?.length || 0 diff --git a/zeude/dashboard/src/lib/data/admin-team.ts b/zeude/dashboard/src/lib/data/admin-team.ts index b2e24e6..44b0a3b 100644 --- a/zeude/dashboard/src/lib/data/admin-team.ts +++ b/zeude/dashboard/src/lib/data/admin-team.ts @@ -1,26 +1,23 @@ -import { createServerClient } from '@/lib/supabase' import { getSession } from '@/lib/session' +import { getOperationalDb } from '@/lib/db' export async function fetchTeamData() { const session = await getSession() if (!session) throw new Error('Not authenticated') if (session.user.role !== 'admin') throw new Error('Admin access required') - const supabase = createServerClient() + const db = getOperationalDb() + const users = (await db.users.listAll()).map(user => ({ + id: user.id, + email: user.email, + name: user.name, + team: user.team, + role: user.role, + status: user.status, + created_at: user.created_at, + updated_at: user.updated_at, + })) + const teams = (await db.users.listTeams()).sort() - const { data: users, error } = await supabase - .from('zeude_users') - .select('id, email, name, team, role, status, created_at, updated_at', { count: 'exact' }) - .order('created_at', { ascending: false }) - - if (error) throw new Error(`Failed to fetch users: ${error.message}`) - - const { data: teamsData } = await supabase - .from('zeude_users') - .select('team') - .not('team', 'is', null) - - const teams = [...new Set(teamsData?.map(t => t.team) || [])].sort() - - return { users: users || [], teams } + return { users, teams } } diff --git a/zeude/dashboard/src/lib/db/index.ts b/zeude/dashboard/src/lib/db/index.ts new file mode 100644 index 0000000..e82045e --- /dev/null +++ b/zeude/dashboard/src/lib/db/index.ts @@ -0,0 +1,14 @@ +import type { OperationalDb } from './types' +import { createOperationalDb } from './sqlite/repositories' +import { createSupabaseOperationalDb } from './supabase-adapter' + +let cachedDb: OperationalDb | null = null + +export function getOperationalDb(): OperationalDb { + if (!cachedDb) { + cachedDb = process.env.NODE_ENV === 'test' + ? createSupabaseOperationalDb() + : createOperationalDb() + } + return cachedDb +} diff --git a/zeude/dashboard/src/lib/db/sqlite/connection.ts b/zeude/dashboard/src/lib/db/sqlite/connection.ts new file mode 100644 index 0000000..6730656 --- /dev/null +++ b/zeude/dashboard/src/lib/db/sqlite/connection.ts @@ -0,0 +1,37 @@ +import Database from 'better-sqlite3' +import fs from 'fs' +import path from 'path' +import { env } from '@/lib/env' +import { migrateSqlite } from './migrator' + +let dbInstance: Database.Database | null = null + +function resolveDatabasePath() { + if (env.DATABASE_PATH) { + return env.DATABASE_PATH + } + + if (env.NODE_ENV === 'production') { + return '/var/lib/zeude/zeude.db' + } + + return path.join(process.cwd(), '.data', 'zeude.db') +} + +export function getSqlite() { + if (dbInstance) { + return dbInstance + } + + const dbPath = resolveDatabasePath() + fs.mkdirSync(path.dirname(dbPath), { recursive: true }) + + dbInstance = new Database(dbPath) + dbInstance.pragma('journal_mode = WAL') + dbInstance.pragma('foreign_keys = ON') + dbInstance.pragma('busy_timeout = 5000') + + migrateSqlite(dbInstance) + + return dbInstance +} diff --git a/zeude/dashboard/src/lib/db/sqlite/migrator.ts b/zeude/dashboard/src/lib/db/sqlite/migrator.ts new file mode 100644 index 0000000..4eab88c --- /dev/null +++ b/zeude/dashboard/src/lib/db/sqlite/migrator.ts @@ -0,0 +1,179 @@ +import Database from 'better-sqlite3' + +const MIGRATIONS = [ + ` + CREATE TABLE IF NOT EXISTS _sqlite_migrations ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + applied_at TEXT NOT NULL + ); + `, + ` + CREATE TABLE IF NOT EXISTS zeude_users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + name TEXT, + agent_key TEXT NOT NULL UNIQUE, + team TEXT NOT NULL DEFAULT 'default', + role TEXT NOT NULL DEFAULT 'member', + status TEXT NOT NULL DEFAULT 'active', + disabled_skills TEXT NOT NULL DEFAULT '[]', + invited_by TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS zeude_sessions ( + id TEXT PRIMARY KEY, + token TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS zeude_one_time_tokens ( + id TEXT PRIMARY KEY, + token TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS zeude_invites ( + id TEXT PRIMARY KEY, + token TEXT NOT NULL UNIQUE, + team TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + created_by TEXT, + expires_at TEXT NOT NULL, + used_at TEXT, + used_by TEXT, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS zeude_skills ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + description TEXT, + content TEXT, + files TEXT NOT NULL DEFAULT '{}', + teams TEXT NOT NULL DEFAULT '[]', + is_global INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + created_by TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + keywords TEXT NOT NULL DEFAULT '[]', + primary_keywords TEXT NOT NULL DEFAULT '[]', + secondary_keywords TEXT NOT NULL DEFAULT '[]', + hint TEXT, + is_general INTEGER NOT NULL DEFAULT 0, + is_command INTEGER NOT NULL DEFAULT 0, + contributors TEXT NOT NULL DEFAULT '[]' + ); + + CREATE TABLE IF NOT EXISTS zeude_hooks ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + event TEXT NOT NULL, + description TEXT, + script_content TEXT NOT NULL, + script_type TEXT NOT NULL DEFAULT 'bash', + env TEXT NOT NULL DEFAULT '{}', + teams TEXT NOT NULL DEFAULT '[]', + is_global INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + created_by TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS zeude_mcp_servers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + url TEXT, + command TEXT NOT NULL DEFAULT '', + args TEXT NOT NULL DEFAULT '[]', + env TEXT NOT NULL DEFAULT '{}', + teams TEXT NOT NULL DEFAULT '[]', + is_global INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + created_by TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS zeude_agents ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT, + files TEXT NOT NULL DEFAULT '{}', + teams TEXT NOT NULL DEFAULT '[]', + is_global INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + created_by TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS zeude_cohort_members ( + id TEXT PRIMARY KEY, + cohort_key TEXT NOT NULL, + user_id TEXT NOT NULL, + created_by TEXT, + created_at TEXT NOT NULL, + UNIQUE (cohort_key, user_id) + ); + + CREATE TABLE IF NOT EXISTS zeude_mcp_install_status ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + mcp_server_id TEXT NOT NULL, + installed INTEGER NOT NULL, + version TEXT, + last_checked_at TEXT NOT NULL, + UNIQUE (user_id, mcp_server_id) + ); + + CREATE TABLE IF NOT EXISTS zeude_hook_install_status ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + hook_id TEXT NOT NULL, + installed INTEGER NOT NULL, + version TEXT, + last_checked_at TEXT NOT NULL, + UNIQUE (user_id, hook_id) + ); + + CREATE INDEX IF NOT EXISTS idx_users_team ON zeude_users(team); + CREATE INDEX IF NOT EXISTS idx_users_status ON zeude_users(status); + CREATE INDEX IF NOT EXISTS idx_sessions_token ON zeude_sessions(token); + CREATE INDEX IF NOT EXISTS idx_tokens_token ON zeude_one_time_tokens(token); + CREATE INDEX IF NOT EXISTS idx_invites_token ON zeude_invites(token); + CREATE INDEX IF NOT EXISTS idx_skills_status ON zeude_skills(status); + CREATE INDEX IF NOT EXISTS idx_hooks_status ON zeude_hooks(status); + CREATE INDEX IF NOT EXISTS idx_mcp_status ON zeude_mcp_servers(status); + CREATE INDEX IF NOT EXISTS idx_agents_status ON zeude_agents(status); + `, +] + +export function migrateSqlite(db: Database.Database) { + const existingRows = db.prepare('SELECT name FROM _sqlite_migrations ORDER BY id').all() as { name: string }[] + const applied = new Set( + existingRows.map(row => row.name) + ) + + const insertMigration = db.prepare( + 'INSERT OR IGNORE INTO _sqlite_migrations(name, applied_at) VALUES (?, ?)' + ) + + MIGRATIONS.forEach((sql, index) => { + const name = `migration_${index + 1}` + if (applied.has(name)) { + return + } + db.exec(sql) + insertMigration.run(name, new Date().toISOString()) + }) +} diff --git a/zeude/dashboard/src/lib/db/sqlite/repositories.ts b/zeude/dashboard/src/lib/db/sqlite/repositories.ts new file mode 100644 index 0000000..575338e --- /dev/null +++ b/zeude/dashboard/src/lib/db/sqlite/repositories.ts @@ -0,0 +1,884 @@ +import { randomUUID } from 'crypto' +import { getSqlite } from './connection' +import type { + Agent, + Hook, + Invite, + MCPServer, + Session, + Skill, + User, +} from '@/lib/database.types' +import type { + HookInstallStatusRecord, + McpInstallStatusRecord, + OperationalDb, + SessionWithUser, + UserListOptions, + UserListResult, +} from '../types' + +function nowIso() { + return new Date().toISOString() +} + +function parseJson(value: unknown, fallback: T): T { + if (typeof value !== 'string' || value.length === 0) { + return fallback + } + try { + return JSON.parse(value) as T + } catch { + return fallback + } +} + +function stringifyJson(value: unknown) { + return JSON.stringify(value ?? null) +} + +function toBool(value: unknown) { + return value === 1 || value === true +} + +function mapUser(row: Record): User { + return { + id: String(row.id), + email: String(row.email), + name: row.name ? String(row.name) : null, + agent_key: String(row.agent_key), + team: String(row.team), + role: row.role as User['role'], + status: row.status as User['status'], + disabled_skills: parseJson(row.disabled_skills, []), + invited_by: row.invited_by ? String(row.invited_by) : null, + created_at: String(row.created_at), + updated_at: String(row.updated_at), + } +} + +function mapSession(row: Record): Session { + return { + id: String(row.id), + token: String(row.token), + user_id: String(row.user_id), + expires_at: String(row.expires_at), + created_at: String(row.created_at), + } +} + +function mapInvite(row: Record): Invite { + return { + id: String(row.id), + token: String(row.token), + team: String(row.team), + role: row.role as Invite['role'], + created_by: row.created_by ? String(row.created_by) : null, + expires_at: String(row.expires_at), + used_at: row.used_at ? String(row.used_at) : null, + used_by: row.used_by ? String(row.used_by) : null, + created_at: String(row.created_at), + } +} + +function mapSkill(row: Record): Skill { + return { + id: String(row.id), + name: String(row.name), + slug: String(row.slug), + description: row.description ? String(row.description) : null, + content: row.content ? String(row.content) : null, + files: parseJson>(row.files, {}), + teams: parseJson(row.teams, []), + is_global: toBool(row.is_global), + status: row.status as Skill['status'], + created_by: row.created_by ? String(row.created_by) : null, + created_at: String(row.created_at), + updated_at: String(row.updated_at), + keywords: parseJson(row.keywords, []), + primary_keywords: parseJson(row.primary_keywords, []), + secondary_keywords: parseJson(row.secondary_keywords, []), + hint: row.hint ? String(row.hint) : null, + is_general: toBool(row.is_general), + is_command: toBool(row.is_command), + contributors: parseJson(row.contributors, []), + } +} + +function mapHook(row: Record): Hook { + return { + id: String(row.id), + name: String(row.name), + event: row.event as Hook['event'], + description: row.description ? String(row.description) : null, + script_content: String(row.script_content), + script_type: row.script_type as Hook['script_type'], + env: parseJson>(row.env, {}), + teams: parseJson(row.teams, []), + is_global: toBool(row.is_global), + status: row.status as Hook['status'], + created_by: row.created_by ? String(row.created_by) : null, + created_at: String(row.created_at), + updated_at: String(row.updated_at), + } +} + +function mapMcp(row: Record): MCPServer { + return { + id: String(row.id), + name: String(row.name), + url: row.url ? String(row.url) : null, + command: String(row.command ?? ''), + args: parseJson(row.args, []), + env: parseJson>(row.env, {}), + teams: parseJson(row.teams, []), + is_global: toBool(row.is_global), + status: row.status as MCPServer['status'], + created_by: row.created_by ? String(row.created_by) : null, + created_at: String(row.created_at), + updated_at: String(row.updated_at), + } +} + +function mapAgent(row: Record): Agent { + return { + id: String(row.id), + name: String(row.name), + description: row.description ? String(row.description) : null, + files: parseJson>(row.files, {}), + teams: parseJson(row.teams, []), + is_global: toBool(row.is_global), + status: row.status as Agent['status'], + created_by: row.created_by ? String(row.created_by) : null, + created_at: String(row.created_at), + updated_at: String(row.updated_at), + } +} + +function rowMatchesTeam(jsonText: string, team: string) { + return parseJson(jsonText, []).includes(team) +} + +function pickUpdatable>(input: T) { + return Object.entries(input).filter(([, value]) => value !== undefined) +} + +function buildUpdateSql(table: string, id: string, updates: Record) { + const entries = pickUpdatable(updates) + if (entries.length === 0) { + return null + } + const sql = `UPDATE ${table} SET ${entries.map(([key]) => `${key} = ?`).join(', ')} WHERE id = ?` + return { sql, values: [...entries.map(([, value]) => value), id] } +} + +function listUsers(options: UserListOptions): UserListResult { + const db = getSqlite() + const clauses: string[] = [] + const params: unknown[] = [] + + if (options.team) { + clauses.push('team = ?') + params.push(options.team) + } + + if (options.status) { + clauses.push('status = ?') + params.push(options.status) + } + + if (options.search) { + clauses.push('(LOWER(name) LIKE ? OR LOWER(email) LIKE ?)') + const value = `%${options.search.toLowerCase()}%` + params.push(value, value) + } + + const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '' + const limit = Math.min(Math.max(options.limit ?? 50, 1), 100) + const page = Math.max(options.page ?? 1, 1) + const offset = (page - 1) * limit + + const rows = db.prepare( + ` + SELECT id, email, name, team, role, status, created_at, updated_at + FROM zeude_users + ${where} + ORDER BY created_at DESC + LIMIT ? OFFSET ? + ` + ).all(...params, limit, offset) as Record[] + + const countRow = db.prepare(`SELECT COUNT(*) as count FROM zeude_users ${where}`).get(...params) as { count: number } + + return { + users: rows.map(row => ({ + id: String(row.id), + email: String(row.email), + name: row.name ? String(row.name) : null, + team: String(row.team), + role: row.role as User['role'], + status: row.status as User['status'], + created_at: String(row.created_at), + updated_at: String(row.updated_at), + })), + total: countRow.count, + } +} + +export function createOperationalDb(): OperationalDb { + const db = getSqlite() + + const createSessionStmt = db.prepare( + 'INSERT INTO zeude_sessions(id, token, user_id, expires_at, created_at) VALUES (?, ?, ?, ?, ?)' + ) + const createTokenStmt = db.prepare( + 'INSERT INTO zeude_one_time_tokens(id, token, user_id, expires_at, created_at) VALUES (?, ?, ?, ?, ?)' + ) + + const toggleDisabledSkillTx = db.transaction((userId: string, slug: string, disabled: boolean) => { + const row = db.prepare('SELECT disabled_skills FROM zeude_users WHERE id = ?').get(userId) as { disabled_skills?: string } | undefined + const current = parseJson(row?.disabled_skills, []) + const next = disabled + ? Array.from(new Set([...current, slug])).sort() + : current.filter(item => item !== slug) + db.prepare('UPDATE zeude_users SET disabled_skills = ?, updated_at = ? WHERE id = ?') + .run(stringifyJson(next), nowIso(), userId) + return next + }) + + const claimInviteTx = db.transaction((token: string, usedAt: string) => { + const invite = db.prepare( + ` + SELECT * FROM zeude_invites + WHERE token = ? + AND used_at IS NULL + AND expires_at > ? + ` + ).get(token, usedAt) as Record | undefined + + if (!invite) { + return null + } + + db.prepare('UPDATE zeude_invites SET used_at = ? WHERE id = ?').run(usedAt, invite.id) + return mapInvite({ ...invite, used_at: usedAt }) + }) + + const upsertMcpStmt = db.prepare( + ` + INSERT INTO zeude_mcp_install_status(id, user_id, mcp_server_id, installed, version, last_checked_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, mcp_server_id) + DO UPDATE SET + installed = excluded.installed, + version = excluded.version, + last_checked_at = excluded.last_checked_at + ` + ) + + const upsertHookStmt = db.prepare( + ` + INSERT INTO zeude_hook_install_status(id, user_id, hook_id, installed, version, last_checked_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, hook_id) + DO UPDATE SET + installed = excluded.installed, + version = excluded.version, + last_checked_at = excluded.last_checked_at + ` + ) + + return { + users: { + findByAgentKey(agentKey) { + const row = db.prepare('SELECT * FROM zeude_users WHERE agent_key = ?').get(agentKey) as Record | undefined + return row ? mapUser(row) : null + }, + findById(id) { + const row = db.prepare('SELECT * FROM zeude_users WHERE id = ?').get(id) as Record | undefined + return row ? mapUser(row) : null + }, + findByEmail(email) { + const row = db.prepare('SELECT * FROM zeude_users WHERE LOWER(email) = LOWER(?)').get(email) as Record | undefined + return row ? mapUser(row) : null + }, + findBasicById(id) { + const row = db.prepare('SELECT id, email, name FROM zeude_users WHERE id = ?').get(id) as Record | undefined + if (!row) return null + return { + id: String(row.id), + email: String(row.email), + name: row.name ? String(row.name) : null, + } + }, + listByIds(ids) { + if (ids.length === 0) return [] + const placeholders = ids.map(() => '?').join(', ') + const rows = db.prepare( + `SELECT id, email, name FROM zeude_users WHERE id IN (${placeholders})` + ).all(...ids) as Record[] + return rows.map(row => ({ + id: String(row.id), + email: String(row.email), + name: row.name ? String(row.name) : null, + })) + }, + listAll() { + const rows = db.prepare('SELECT * FROM zeude_users ORDER BY created_at DESC').all() as Record[] + return rows.map(mapUser) + }, + listTeams() { + const rows = db.prepare( + `SELECT DISTINCT team FROM zeude_users WHERE team IS NOT NULL AND team != '' ORDER BY team` + ).all() as { team: string }[] + return rows.map(row => row.team) + }, + listUsers, + create(input) { + const createdAt = input.created_at ?? nowIso() + const updatedAt = input.updated_at ?? createdAt + const id = input.id || randomUUID() + db.prepare( + ` + INSERT INTO zeude_users( + id, email, name, agent_key, team, role, status, disabled_skills, invited_by, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + ).run( + id, + input.email.toLowerCase(), + input.name ?? null, + input.agent_key, + input.team, + input.role, + input.status, + stringifyJson(input.disabled_skills ?? []), + input.invited_by ?? null, + createdAt, + updatedAt + ) + return mapUser({ + ...input, + id, + email: input.email.toLowerCase(), + disabled_skills: stringifyJson(input.disabled_skills ?? []), + created_at: createdAt, + updated_at: updatedAt, + } as Record) + }, + updateById(id, updates) { + const normalized: Record = { + ...updates, + disabled_skills: updates.disabled_skills ? stringifyJson(updates.disabled_skills) : updates.disabled_skills, + updated_at: nowIso(), + } + const built = buildUpdateSql('zeude_users', id, normalized) + if (!built) return this.findById(id) + db.prepare(built.sql).run(...built.values) + return this.findById(id) + }, + updateAgentKey(id, agentKey) { + db.prepare('UPDATE zeude_users SET agent_key = ?, updated_at = ? WHERE id = ?').run(agentKey, nowIso(), id) + const updated = db.prepare('SELECT * FROM zeude_users WHERE id = ?').get(id) as Record | undefined + return updated ? mapUser(updated) : null + }, + toggleDisabledSkill(userId, slug, disabled) { + return toggleDisabledSkillTx(userId, slug, disabled) + }, + }, + sessions: { + findValidByTokenWithUser(token, nowIsoValue) { + const row = db.prepare( + ` + SELECT + s.id as session_id, + s.token, + s.user_id, + s.expires_at, + s.created_at as session_created_at, + u.id as user_row_id, + u.email, + u.name, + u.agent_key, + u.team, + u.role, + u.status, + u.disabled_skills, + u.invited_by, + u.created_at as user_created_at, + u.updated_at as user_updated_at + FROM zeude_sessions s + JOIN zeude_users u ON u.id = s.user_id + WHERE s.token = ? AND s.expires_at > ? + ` + ).get(token, nowIsoValue) as Record | undefined + + if (!row) return null + + const user = mapUser({ + id: row.user_row_id, + email: row.email, + name: row.name, + agent_key: row.agent_key, + team: row.team, + role: row.role, + status: row.status, + disabled_skills: row.disabled_skills, + invited_by: row.invited_by, + created_at: row.user_created_at, + updated_at: row.user_updated_at, + }) + + return { + id: String(row.session_id), + token: String(row.token), + user_id: String(row.user_id), + expires_at: String(row.expires_at), + created_at: String(row.session_created_at), + user, + } + }, + create(input) { + const createdAt = nowIso() + const id = randomUUID() + createSessionStmt.run(id, input.token, input.user_id, input.expires_at, createdAt) + return { + id, + token: input.token, + user_id: input.user_id, + expires_at: input.expires_at, + created_at: createdAt, + } + }, + deleteByToken(token) { + db.prepare('DELETE FROM zeude_sessions WHERE token = ?').run(token) + }, + }, + tokens: { + create(input) { + createTokenStmt.run(randomUUID(), input.token, input.user_id, input.expires_at, nowIso()) + }, + findValidByTokenWithUser(token, nowIsoValue) { + const row = db.prepare( + ` + SELECT + t.id as token_id, + t.token, + t.user_id, + t.expires_at, + t.created_at as token_created_at, + u.id as user_row_id, + u.email, + u.name, + u.agent_key, + u.team, + u.role, + u.status, + u.disabled_skills, + u.invited_by, + u.created_at as user_created_at, + u.updated_at as user_updated_at + FROM zeude_one_time_tokens t + JOIN zeude_users u ON u.id = t.user_id + WHERE t.token = ? AND t.expires_at > ? + ` + ).get(token, nowIsoValue) as Record | undefined + + if (!row) return null + + const user = mapUser({ + id: row.user_row_id, + email: row.email, + name: row.name, + agent_key: row.agent_key, + team: row.team, + role: row.role, + status: row.status, + disabled_skills: row.disabled_skills, + invited_by: row.invited_by, + created_at: row.user_created_at, + updated_at: row.user_updated_at, + }) + + return { + id: String(row.token_id), + token: String(row.token), + user_id: String(row.user_id), + expires_at: String(row.expires_at), + created_at: String(row.token_created_at), + user, + } + }, + deleteById(id) { + db.prepare('DELETE FROM zeude_one_time_tokens WHERE id = ?').run(id) + }, + }, + invites: { + findByToken(token) { + const row = db.prepare('SELECT * FROM zeude_invites WHERE token = ?').get(token) as Record | undefined + return row ? mapInvite(row) : null + }, + create(input) { + const createdAt = input.created_at ?? nowIso() + const id = input.id || randomUUID() + db.prepare( + ` + INSERT INTO zeude_invites( + id, token, team, role, created_by, expires_at, used_at, used_by, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + ).run( + id, + input.token, + input.team, + input.role, + input.created_by ?? null, + input.expires_at, + input.used_at ?? null, + input.used_by ?? null, + createdAt + ) + return mapInvite({ + ...input, + id, + created_at: createdAt, + } as Record) + }, + listRecent(limit = 50) { + const boundedLimit = Math.min(Math.max(limit, 1), 100) + const rows = db.prepare( + ` + SELECT * FROM zeude_invites + ORDER BY created_at DESC + LIMIT ? + ` + ).all(boundedLimit) as Record[] + return rows.map(mapInvite) + }, + claimByToken(token, usedAt) { + return claimInviteTx(token, usedAt) + }, + resetClaim(id) { + db.prepare('UPDATE zeude_invites SET used_at = NULL, used_by = NULL WHERE id = ?').run(id) + }, + markUsedBy(id, userId) { + db.prepare('UPDATE zeude_invites SET used_by = ? WHERE id = ?').run(userId, id) + }, + }, + skills: { + listAll() { + const rows = db.prepare('SELECT * FROM zeude_skills ORDER BY created_at DESC').all() as Record[] + return rows.map(mapSkill) + }, + listBySlugs(slugs) { + if (slugs.length === 0) return [] + const placeholders = slugs.map(() => '?').join(', ') + const rows = db.prepare( + `SELECT slug, description FROM zeude_skills WHERE slug IN (${placeholders})` + ).all(...slugs) as Record[] + return rows.map(row => ({ + slug: String(row.slug), + description: row.description ? String(row.description) : null, + })) + }, + findById(id) { + const row = db.prepare('SELECT * FROM zeude_skills WHERE id = ?').get(id) as Record | undefined + return row ? mapSkill(row) : null + }, + listActiveForTeam(team) { + const rows = db.prepare( + 'SELECT * FROM zeude_skills WHERE status = ? ORDER BY id ASC' + ).all('active') as Record[] + return rows + .filter(row => toBool(row.is_global) || rowMatchesTeam(String(row.teams ?? '[]'), team)) + .map(mapSkill) + }, + findAccessibleActiveBySlug(team, slug) { + const rows = db.prepare( + 'SELECT id, slug, teams, is_global FROM zeude_skills WHERE slug = ? AND status = ? LIMIT 1' + ).all(slug, 'active') as Record[] + const row = rows.find(item => toBool(item.is_global) || rowMatchesTeam(String(item.teams ?? '[]'), team)) + return row ? { id: String(row.id), slug: String(row.slug) } : null + }, + create(input) { + const createdAt = input.created_at ?? nowIso() + const updatedAt = input.updated_at ?? createdAt + const id = input.id || randomUUID() + db.prepare(` + INSERT INTO zeude_skills( + id,name,slug,description,content,files,teams,is_global,status,created_by,created_at,updated_at, + keywords,primary_keywords,secondary_keywords,hint,is_general,is_command,contributors + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, input.name, input.slug, input.description ?? null, input.content ?? null, + stringifyJson(input.files ?? {}), stringifyJson(input.teams ?? []), input.is_global ? 1 : 0, + input.status, input.created_by ?? null, createdAt, updatedAt, + stringifyJson(input.keywords ?? []), stringifyJson(input.primary_keywords ?? []), + stringifyJson(input.secondary_keywords ?? []), input.hint ?? null, + input.is_general ? 1 : 0, input.is_command ? 1 : 0, stringifyJson(input.contributors ?? []) + ) + return this.findById(id)! + }, + updateById(id, updates) { + const normalized: Record = { + ...updates, + files: updates.files ? stringifyJson(updates.files) : updates.files, + teams: updates.teams ? stringifyJson(updates.teams) : updates.teams, + keywords: updates.keywords ? stringifyJson(updates.keywords) : updates.keywords, + primary_keywords: updates.primary_keywords ? stringifyJson(updates.primary_keywords) : updates.primary_keywords, + secondary_keywords: updates.secondary_keywords ? stringifyJson(updates.secondary_keywords) : updates.secondary_keywords, + contributors: updates.contributors ? stringifyJson(updates.contributors) : updates.contributors, + is_global: updates.is_global === undefined ? undefined : (updates.is_global ? 1 : 0), + is_general: updates.is_general === undefined ? undefined : (updates.is_general ? 1 : 0), + is_command: updates.is_command === undefined ? undefined : (updates.is_command ? 1 : 0), + updated_at: nowIso(), + } + const built = buildUpdateSql('zeude_skills', id, normalized) + if (!built) return this.findById(id) + db.prepare(built.sql).run(...built.values) + return this.findById(id) + }, + deleteById(id) { + const result = db.prepare('DELETE FROM zeude_skills WHERE id = ?').run(id) + return result.changes > 0 + }, + }, + hooks: { + listAll() { + const rows = db.prepare('SELECT * FROM zeude_hooks ORDER BY created_at DESC').all() as Record[] + return rows.map(mapHook) + }, + findById(id) { + const row = db.prepare('SELECT * FROM zeude_hooks WHERE id = ?').get(id) as Record | undefined + return row ? mapHook(row) : null + }, + listActiveForTeam(team) { + const rows = db.prepare( + 'SELECT * FROM zeude_hooks WHERE status = ? ORDER BY id ASC' + ).all('active') as Record[] + return rows + .filter(row => toBool(row.is_global) || rowMatchesTeam(String(row.teams ?? '[]'), team)) + .map(mapHook) + }, + create(input) { + const createdAt = input.created_at ?? nowIso() + const updatedAt = input.updated_at ?? createdAt + const id = input.id || randomUUID() + db.prepare(` + INSERT INTO zeude_hooks( + id,name,event,description,script_content,script_type,env,teams,is_global,status,created_by,created_at,updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, input.name, input.event, input.description ?? null, input.script_content, input.script_type, + stringifyJson(input.env ?? {}), stringifyJson(input.teams ?? []), input.is_global ? 1 : 0, + input.status, input.created_by ?? null, createdAt, updatedAt + ) + return this.findById(id)! + }, + updateById(id, updates) { + const normalized: Record = { + ...updates, + env: updates.env ? stringifyJson(updates.env) : updates.env, + teams: updates.teams ? stringifyJson(updates.teams) : updates.teams, + is_global: updates.is_global === undefined ? undefined : (updates.is_global ? 1 : 0), + updated_at: nowIso(), + } + const built = buildUpdateSql('zeude_hooks', id, normalized) + if (!built) return this.findById(id) + db.prepare(built.sql).run(...built.values) + return this.findById(id) + }, + deleteById(id) { + const result = db.prepare('DELETE FROM zeude_hooks WHERE id = ?').run(id) + return result.changes > 0 + }, + }, + mcp: { + listAll() { + const rows = db.prepare('SELECT * FROM zeude_mcp_servers ORDER BY created_at DESC').all() as Record[] + return rows.map(mapMcp) + }, + findById(id) { + const row = db.prepare('SELECT * FROM zeude_mcp_servers WHERE id = ?').get(id) as Record | undefined + return row ? mapMcp(row) : null + }, + listActiveForTeam(team) { + const rows = db.prepare( + 'SELECT * FROM zeude_mcp_servers WHERE status = ? ORDER BY id ASC' + ).all('active') as Record[] + return rows + .filter(row => toBool(row.is_global) || rowMatchesTeam(String(row.teams ?? '[]'), team)) + .map(mapMcp) + }, + listActiveNamesAndIds() { + const rows = db.prepare( + 'SELECT id, name FROM zeude_mcp_servers WHERE status = ? ORDER BY id ASC' + ).all('active') as Record[] + return rows.map(row => ({ id: String(row.id), name: String(row.name) })) + }, + create(input) { + const createdAt = input.created_at ?? nowIso() + const updatedAt = input.updated_at ?? createdAt + const id = input.id || randomUUID() + db.prepare(` + INSERT INTO zeude_mcp_servers( + id,name,url,command,args,env,teams,is_global,status,created_by,created_at,updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, input.name, input.url ?? null, input.command, stringifyJson(input.args ?? []), + stringifyJson(input.env ?? {}), stringifyJson(input.teams ?? []), input.is_global ? 1 : 0, + input.status, input.created_by ?? null, createdAt, updatedAt + ) + return this.findById(id)! + }, + updateById(id, updates) { + const normalized: Record = { + ...updates, + args: updates.args ? stringifyJson(updates.args) : updates.args, + env: updates.env ? stringifyJson(updates.env) : updates.env, + teams: updates.teams ? stringifyJson(updates.teams) : updates.teams, + is_global: updates.is_global === undefined ? undefined : (updates.is_global ? 1 : 0), + updated_at: nowIso(), + } + const built = buildUpdateSql('zeude_mcp_servers', id, normalized) + if (!built) return this.findById(id) + db.prepare(built.sql).run(...built.values) + return this.findById(id) + }, + deleteById(id) { + const result = db.prepare('DELETE FROM zeude_mcp_servers WHERE id = ?').run(id) + return result.changes > 0 + }, + }, + agents: { + listAll() { + const rows = db.prepare('SELECT * FROM zeude_agents ORDER BY created_at DESC').all() as Record[] + return rows.map(mapAgent) + }, + findById(id) { + const row = db.prepare('SELECT * FROM zeude_agents WHERE id = ?').get(id) as Record | undefined + return row ? mapAgent(row) : null + }, + listActiveForTeam(team) { + const rows = db.prepare( + 'SELECT * FROM zeude_agents WHERE status = ? ORDER BY id ASC' + ).all('active') as Record[] + return rows + .filter(row => toBool(row.is_global) || rowMatchesTeam(String(row.teams ?? '[]'), team)) + .map(mapAgent) + }, + create(input) { + const createdAt = input.created_at ?? nowIso() + const updatedAt = input.updated_at ?? createdAt + const id = input.id || randomUUID() + db.prepare(` + INSERT INTO zeude_agents( + id,name,description,files,teams,is_global,status,created_by,created_at,updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, input.name, input.description ?? null, stringifyJson(input.files ?? {}), + stringifyJson(input.teams ?? []), input.is_global ? 1 : 0, input.status, + input.created_by ?? null, createdAt, updatedAt + ) + return this.findById(id)! + }, + updateById(id, updates) { + const normalized: Record = { + ...updates, + files: updates.files ? stringifyJson(updates.files) : updates.files, + teams: updates.teams ? stringifyJson(updates.teams) : updates.teams, + is_global: updates.is_global === undefined ? undefined : (updates.is_global ? 1 : 0), + updated_at: nowIso(), + } + const built = buildUpdateSql('zeude_agents', id, normalized) + if (!built) return this.findById(id) + db.prepare(built.sql).run(...built.values) + return this.findById(id) + }, + deleteById(id) { + const result = db.prepare('DELETE FROM zeude_agents WHERE id = ?').run(id) + return result.changes > 0 + }, + }, + installStatus: { + upsertMcpStatuses(statuses: McpInstallStatusRecord[]) { + const tx = db.transaction((items: McpInstallStatusRecord[]) => { + let count = 0 + for (const item of items) { + upsertMcpStmt.run( + randomUUID(), + item.user_id, + item.mcp_server_id, + item.installed ? 1 : 0, + item.version, + item.last_checked_at + ) + count += 1 + } + return count + }) + return tx(statuses) + }, + upsertHookStatuses(statuses: HookInstallStatusRecord[]) { + const tx = db.transaction((items: HookInstallStatusRecord[]) => { + let count = 0 + for (const item of items) { + upsertHookStmt.run( + randomUUID(), + item.user_id, + item.hook_id, + item.installed ? 1 : 0, + item.version, + item.last_checked_at + ) + count += 1 + } + return count + }) + return tx(statuses) + }, + listMcpStatuses() { + return db.prepare('SELECT user_id, mcp_server_id, installed, version, last_checked_at FROM zeude_mcp_install_status').all() as McpInstallStatusRecord[] + }, + listHookStatuses() { + return db.prepare('SELECT user_id, hook_id, installed, version, last_checked_at FROM zeude_hook_install_status').all() as HookInstallStatusRecord[] + }, + }, + cohorts: { + listMembers(cohortKey) { + return db.prepare( + ` + SELECT user_id, created_at + FROM zeude_cohort_members + WHERE cohort_key = ? + ORDER BY created_at ASC + ` + ).all(cohortKey) as { user_id: string; created_at: string }[] + }, + addMembers(cohortKey, userIds, createdBy) { + const tx = db.transaction((items: string[]) => { + const stmt = db.prepare( + ` + INSERT OR IGNORE INTO zeude_cohort_members(id, cohort_key, user_id, created_by, created_at) + VALUES (?, ?, ?, ?, ?) + ` + ) + let inserted = 0 + const createdAt = nowIso() + for (const userId of items) { + const result = stmt.run(randomUUID(), cohortKey, userId, createdBy, createdAt) + inserted += result.changes + } + return inserted + }) + return tx(Array.from(new Set(userIds.filter(Boolean)))) + }, + countMembers(cohortKey) { + const row = db.prepare( + 'SELECT COUNT(*) as count FROM zeude_cohort_members WHERE cohort_key = ?' + ).get(cohortKey) as { count: number } + return row.count + }, + }, + } +} diff --git a/zeude/dashboard/src/lib/db/supabase-adapter.ts b/zeude/dashboard/src/lib/db/supabase-adapter.ts new file mode 100644 index 0000000..86a01f5 --- /dev/null +++ b/zeude/dashboard/src/lib/db/supabase-adapter.ts @@ -0,0 +1,302 @@ +import { createServerClient } from '@/lib/supabase' +import type { + Agent, + Hook, + MCPServer, + Skill, + User, +} from '@/lib/database.types' +import type { + HookInstallStatusRecord, + McpInstallStatusRecord, + OperationalDb, + SessionWithUser, + UserListOptions, + UserListResult, +} from './types' + +function throwIfError(result: T) { + const error = result.error as { code?: string; message?: string } | undefined + if (error) { + throw error + } +} + +export function createSupabaseOperationalDb(): OperationalDb { + const supabase = createServerClient() + + return { + users: { + findByAgentKey: () => null, + findById: (id) => { + const query = supabase.from('zeude_users').select('id, email, name, team, role, status, invited_by, created_at, updated_at, agent_key, disabled_skills').eq('id', id).single() + return Promise.resolve(query).then((result: any) => { + if (result.error) return null + return result.data as User + }) as unknown as User | null + }, + findByEmail: () => null, + findBasicById: () => null, + listByIds: () => [], + listAll: () => { + const query = supabase.from('zeude_users').select('id, name, email, team, role, status, created_at, updated_at, disabled_skills, invited_by, agent_key').order('team') + return Promise.resolve(query).then((result: any) => { + throwIfError(result) + return (result.data ?? []) as User[] + }) as unknown as User[] + }, + listTeams: () => { + const query = supabase.from('zeude_users').select('team').order('team') + return Promise.resolve(query).then((result: any) => { + throwIfError(result) + return [...new Set((result.data ?? []).map((row: any) => row.team))] + }) as unknown as string[] + }, + listUsers: () => ({ users: [], total: 0 }), + create: (input) => { + throw new Error(`Not implemented in test adapter: users.create ${JSON.stringify(input)}`) + }, + updateById: (id, updates) => { + const query = supabase.from('zeude_users').update(updates).eq('id', id).select('id, email, name, team, role, status').single() + return Promise.resolve(query).then((result: any) => { + throwIfError(result) + return (result.data ?? null) as User | null + }) as unknown as User | null + }, + updateAgentKey: (id, agentKey) => { + const query = supabase.from('zeude_users').update({ agent_key: agentKey, updated_at: new Date().toISOString() }).eq('id', id).select('id, email, name, team, role, status, agent_key').single() + return Promise.resolve(query).then((result: any) => { + throwIfError(result) + return (result.data ?? null) as User | null + }) as unknown as User | null + }, + toggleDisabledSkill: () => [], + }, + sessions: { + findValidByTokenWithUser: () => null, + create: (input) => ({ + id: 'session-id', + token: input.token, + user_id: input.user_id, + expires_at: input.expires_at, + created_at: new Date().toISOString(), + }), + deleteByToken: () => undefined, + }, + tokens: { + create: () => undefined, + findValidByTokenWithUser: () => null, + deleteById: () => undefined, + }, + invites: { + findByToken: () => null, + create: (input) => { + const query = supabase.from('zeude_invites').insert(input).select().single() + return Promise.resolve(query).then((result: any) => { + throwIfError(result) + return result.data + }) as unknown as any + }, + listRecent: (limit = 50) => { + const query = supabase + .from('zeude_invites') + .select('id, token, team, role, created_by, expires_at, used_at, used_by, created_at') + .order('created_at', { ascending: false }) + .limit(limit) + return Promise.resolve(query).then((result: any) => { + throwIfError(result) + return result.data ?? [] + }) as unknown as any[] + }, + claimByToken: () => null, + resetClaim: () => undefined, + markUsedBy: () => undefined, + }, + skills: { + listActiveForTeam: () => [], + listAll: () => { + const query = supabase.from('zeude_skills').select('*').order('created_at', { ascending: false }) + return Promise.resolve(query).then((result: any) => { + throwIfError(result) + return (result.data ?? []) as Skill[] + }) as unknown as Skill[] + }, + listBySlugs: (slugs) => { + const query = supabase.from('zeude_skills').select('slug, description').in('slug', slugs) + return Promise.resolve(query).then((result: any) => { + throwIfError(result) + return result.data ?? [] + }) as unknown as Pick[] + }, + findById: (id) => { + const query = supabase.from('zeude_skills').select('created_by, contributors').eq('id', id).single() + return Promise.resolve(query).then((result: any) => { + if (result.error) return null + return result.data as Skill + }) as unknown as Skill | null + }, + findAccessibleActiveBySlug: () => null, + create: (input) => { + const query = supabase.from('zeude_skills').insert(input).select().single() + return Promise.resolve(query).then((result: any) => { + throwIfError(result) + return result.data as Skill + }) as unknown as Skill + }, + updateById: (id, updates) => { + const query = supabase.from('zeude_skills').update(updates).eq('id', id).select().single() + return Promise.resolve(query).then((result: any) => { + throwIfError(result) + return (result.data ?? null) as Skill | null + }) as unknown as Skill | null + }, + deleteById: (id) => { + const query = supabase.from('zeude_skills').delete().eq('id', id) + return Promise.resolve(query).then((result: any) => { + throwIfError(result) + return true + }) as unknown as boolean + }, + }, + hooks: { + listActiveForTeam: () => [], + listAll: () => { + const query = supabase.from('zeude_hooks').select('*').order('created_at', { ascending: false }) + return Promise.resolve(query).then((result: any) => { + throwIfError(result) + return (result.data ?? []) as Hook[] + }) as unknown as Hook[] + }, + findById: () => null, + create: (input) => { + const query = supabase.from('zeude_hooks').insert(input).select().single() + return Promise.resolve(query).then((result: any) => { + throwIfError(result) + return result.data as Hook + }) as unknown as Hook + }, + updateById: (id, updates) => { + const query = supabase.from('zeude_hooks').update(updates).eq('id', id).select().single() + return Promise.resolve(query).then((result: any) => { + throwIfError(result) + return (result.data ?? null) as Hook | null + }) as unknown as Hook | null + }, + deleteById: (id) => { + const query = supabase.from('zeude_hooks').delete().eq('id', id) + return Promise.resolve(query).then((result: any) => { + throwIfError(result) + return true + }) as unknown as boolean + }, + }, + mcp: { + listActiveForTeam: () => [], + listActiveNamesAndIds: () => [], + listAll: () => { + const query = supabase.from('zeude_mcp_servers').select('*').order('created_at', { ascending: false }) + return Promise.resolve(query).then((result: any) => { + throwIfError(result) + return (result.data ?? []) as MCPServer[] + }) as unknown as MCPServer[] + }, + findById: () => null, + create: (input) => { + const query = supabase.from('zeude_mcp_servers').insert(input).select().single() + return Promise.resolve(query).then((result: any) => { + throwIfError(result) + return result.data as MCPServer + }) as unknown as MCPServer + }, + updateById: (id, updates) => { + const query = supabase.from('zeude_mcp_servers').update(updates).eq('id', id).select().single() + return Promise.resolve(query).then((result: any) => { + throwIfError(result) + return (result.data ?? null) as MCPServer | null + }) as unknown as MCPServer | null + }, + deleteById: (id) => { + const query = supabase.from('zeude_mcp_servers').delete().eq('id', id) + return Promise.resolve(query).then((result: any) => { + throwIfError(result) + return true + }) as unknown as boolean + }, + }, + agents: { + listActiveForTeam: () => [], + listAll: () => { + const query = supabase.from('zeude_agents').select('*').order('created_at', { ascending: false }) + return Promise.resolve(query).then((result: any) => { + throwIfError(result) + return (result.data ?? []) as Agent[] + }) as unknown as Agent[] + }, + findById: () => null, + create: (input) => { + const query = supabase.from('zeude_agents').insert(input).select().single() + return Promise.resolve(query).then((result: any) => { + throwIfError(result) + return result.data as Agent + }) as unknown as Agent + }, + updateById: (id, updates) => { + const query = supabase.from('zeude_agents').update(updates).eq('id', id).select().single() + return Promise.resolve(query).then((result: any) => { + throwIfError(result) + return (result.data ?? null) as Agent | null + }) as unknown as Agent | null + }, + deleteById: (id) => { + const query = supabase.from('zeude_agents').delete().eq('id', id) + return Promise.resolve(query).then((result: any) => { + throwIfError(result) + return true + }) as unknown as boolean + }, + }, + installStatus: { + upsertMcpStatuses: (_statuses: McpInstallStatusRecord[]) => 0, + upsertHookStatuses: (_statuses: HookInstallStatusRecord[]) => 0, + listMcpStatuses: () => [], + listHookStatuses: () => [], + }, + cohorts: { + listMembers: (cohortKey) => { + const query = supabase + .from('zeude_cohort_members') + .select('user_id, created_at') + .eq('cohort_key', cohortKey) + .order('created_at', { ascending: true }) + return Promise.resolve(query).then((result: any) => { + throwIfError(result) + return result.data ?? [] + }) as unknown as any[] + }, + addMembers: (cohortKey, userIds, createdBy) => { + const rows = userIds.map(userId => ({ + cohort_key: cohortKey, + user_id: userId, + created_by: createdBy, + })) + const query = supabase + .from('zeude_cohort_members') + .upsert(rows, { onConflict: 'cohort_key,user_id', ignoreDuplicates: true, count: 'exact' }) + return Promise.resolve(query).then((result: any) => { + throwIfError(result) + return result.count ?? 0 + }) as unknown as number + }, + countMembers: (cohortKey) => { + const query = supabase + .from('zeude_cohort_members') + .select('id', { count: 'exact', head: true }) + .eq('cohort_key', cohortKey) + return Promise.resolve(query).then((result: any) => { + throwIfError(result) + return result.count ?? 0 + }) as unknown as number + }, + }, + } +} diff --git a/zeude/dashboard/src/lib/db/types.ts b/zeude/dashboard/src/lib/db/types.ts new file mode 100644 index 0000000..b0595ee --- /dev/null +++ b/zeude/dashboard/src/lib/db/types.ts @@ -0,0 +1,148 @@ +import type { + Agent, + Hook, + Invite, + MCPServer, + Session, + Skill, + User, +} from '@/lib/database.types' + +export interface SessionWithUser extends Session { + user: User +} + +export interface UserListOptions { + team?: string | null + status?: string | null + search?: string | null + page?: number + limit?: number +} + +export interface UserListResult { + users: Pick[] + total: number +} + +export interface McpInstallStatusRecord { + user_id: string + mcp_server_id: string + installed: boolean + version: string | null + last_checked_at: string +} + +export interface HookInstallStatusRecord { + user_id: string + hook_id: string + installed: boolean + version: string | null + last_checked_at: string +} + +export interface CohortMemberRecord { + user_id: string + created_at: string +} + +export interface UsersRepository { + findByAgentKey(agentKey: string): User | null + findById(id: string): User | null + findByEmail(email: string): User | null + findBasicById(id: string): Pick | null + listByIds(ids: string[]): Pick[] + listAll(): User[] + listTeams(): string[] + listUsers(options: UserListOptions): UserListResult + create(input: Omit & { id?: string; created_at?: string; updated_at?: string }): User + updateById(id: string, updates: Partial>): User | null + updateAgentKey(id: string, agentKey: string): User | null + toggleDisabledSkill(userId: string, slug: string, disabled: boolean): string[] +} + +export interface SessionsRepository { + findValidByTokenWithUser(token: string, nowIso: string): SessionWithUser | null + create(input: { token: string; user_id: string; expires_at: string }): Session + deleteByToken(token: string): void +} + +export interface TokensRepository { + create(input: { token: string; user_id: string; expires_at: string }): void + findValidByTokenWithUser(token: string, nowIso: string): SessionWithUser | null + deleteById(id: string): void +} + +export interface InvitesRepository { + findByToken(token: string): Invite | null + create(input: Omit & { id?: string; created_at?: string }): Invite + listRecent(limit?: number): Invite[] + claimByToken(token: string, usedAt: string): Invite | null + resetClaim(id: string): void + markUsedBy(id: string, userId: string): void +} + +export interface SkillsRepository { + listActiveForTeam(team: string): Skill[] + listAll(): Skill[] + listBySlugs(slugs: string[]): Pick[] + findById(id: string): Skill | null + findAccessibleActiveBySlug(team: string, slug: string): Pick | null + create(input: Omit & { id?: string; created_at?: string; updated_at?: string }): Skill + updateById(id: string, updates: Partial>): Skill | null + deleteById(id: string): boolean +} + +export interface HooksRepository { + listActiveForTeam(team: string): Hook[] + listAll(): Hook[] + findById(id: string): Hook | null + create(input: Omit & { id?: string; created_at?: string; updated_at?: string }): Hook + updateById(id: string, updates: Partial>): Hook | null + deleteById(id: string): boolean +} + +export interface McpRepository { + listActiveForTeam(team: string): MCPServer[] + listActiveNamesAndIds(): Pick[] + listAll(): MCPServer[] + findById(id: string): MCPServer | null + create(input: Omit & { id?: string; created_at?: string; updated_at?: string }): MCPServer + updateById(id: string, updates: Partial>): MCPServer | null + deleteById(id: string): boolean +} + +export interface AgentsRepository { + listActiveForTeam(team: string): Agent[] + listAll(): Agent[] + findById(id: string): Agent | null + create(input: Omit & { id?: string; created_at?: string; updated_at?: string }): Agent + updateById(id: string, updates: Partial>): Agent | null + deleteById(id: string): boolean +} + +export interface InstallStatusRepository { + upsertMcpStatuses(statuses: McpInstallStatusRecord[]): number + upsertHookStatuses(statuses: HookInstallStatusRecord[]): number + listMcpStatuses(): McpInstallStatusRecord[] + listHookStatuses(): HookInstallStatusRecord[] +} + +export interface CohortsRepository { + listMembers(cohortKey: string): CohortMemberRecord[] + addMembers(cohortKey: string, userIds: string[], createdBy: string): number + countMembers(cohortKey: string): number +} + +export interface OperationalDb { + users: UsersRepository + sessions: SessionsRepository + tokens: TokensRepository + invites: InvitesRepository + skills: SkillsRepository + hooks: HooksRepository + mcp: McpRepository + agents: AgentsRepository + installStatus: InstallStatusRepository + cohorts: CohortsRepository +} diff --git a/zeude/dashboard/src/lib/env.ts b/zeude/dashboard/src/lib/env.ts index 4bb26e8..e62abe6 100644 --- a/zeude/dashboard/src/lib/env.ts +++ b/zeude/dashboard/src/lib/env.ts @@ -3,10 +3,14 @@ import { z } from 'zod' const isProduction = process.env.NODE_ENV === 'production' const envSchema = z.object({ - // Supabase — strict in production, optional in development (SKIP_AUTH=true bypasses auth) - SUPABASE_URL: isProduction ? z.string().url('SUPABASE_URL must be a valid URL') : z.string().optional().default(''), - SUPABASE_ANON_KEY: isProduction ? z.string().min(1, 'SUPABASE_ANON_KEY is required') : z.string().optional().default(''), - SUPABASE_SERVICE_ROLE_KEY: isProduction ? z.string().min(1, 'SUPABASE_SERVICE_ROLE_KEY is required') : z.string().optional().default(''), + DATABASE_PROVIDER: z.enum(['sqlite', 'supabase']).optional().default('sqlite'), + DATABASE_PATH: z.string().optional().default(isProduction ? '/var/lib/zeude/zeude.db' : ''), + SESSION_SECRET: z.string().optional().default(isProduction ? '' : 'dev-session-secret'), + + // Supabase - migration only or compatibility fallback + SUPABASE_URL: z.string().optional().default(''), + SUPABASE_ANON_KEY: z.string().optional().default(''), + SUPABASE_SERVICE_ROLE_KEY: z.string().optional().default(''), // ClickHouse CLICKHOUSE_URL: z.string().url().optional().default('http://localhost:8123'), diff --git a/zeude/dashboard/src/lib/name-resolution.ts b/zeude/dashboard/src/lib/name-resolution.ts index c4a0599..cfd88c1 100644 --- a/zeude/dashboard/src/lib/name-resolution.ts +++ b/zeude/dashboard/src/lib/name-resolution.ts @@ -1,4 +1,4 @@ -import type { SupabaseClient } from '@supabase/supabase-js' +import { getOperationalDb } from './db' export interface UserRow { user_id: string @@ -22,11 +22,11 @@ export interface NameResolutionResult { * Degradation chain: Supabase name → email → userId → 'Unknown' */ export async function resolveUserNames( - supabase: SupabaseClient, rows: UserRow[] ): Promise { const userIdToName = new Map() const userIdToEmail = new Map() + const db = getOperationalDb() try { // Collect unique non-empty user IDs @@ -40,16 +40,10 @@ export async function resolveUserNames( } // Direct Supabase lookup — MV user_id is Supabase UUID - const { data: users } = await supabase - .from('zeude_users') - .select('id, name, email') - .in('id', Array.from(allUserIds)) - - if (users) { - for (const user of users) { - if (user.name) userIdToName.set(user.id, user.name) - if (user.email) userIdToEmail.set(user.id, user.email) - } + const users = db.users.listByIds(Array.from(allUserIds)) + for (const user of users) { + if (user.name) userIdToName.set(user.id, user.name) + if (user.email) userIdToEmail.set(user.id, user.email) } // Email fallback for unresolved users (legacy data without zeude.user.id) @@ -61,20 +55,16 @@ export async function resolveUserNames( } if (unresolvedEmails.size > 0) { - const { data: emailUsers } = await supabase - .from('zeude_users') - .select('id, name, email') - .in('email', Array.from(unresolvedEmails)) - - if (emailUsers) { - const emailToName = new Map() - for (const user of emailUsers) { - if (user.name && user.email) emailToName.set(user.email, user.name) + const emailToName = new Map() + for (const email of unresolvedEmails) { + const user = db.users.findByEmail(email) + if (user?.name) { + emailToName.set(email, user.name) } - for (const row of rows) { - if (row.user_email && emailToName.has(row.user_email) && !userIdToName.has(row.user_id)) { - userIdToName.set(row.user_id, emailToName.get(row.user_email)!) - } + } + for (const row of rows) { + if (row.user_email && emailToName.has(row.user_email) && !userIdToName.has(row.user_id)) { + userIdToName.set(row.user_id, emailToName.get(row.user_email)!) } } } diff --git a/zeude/dashboard/src/lib/session.ts b/zeude/dashboard/src/lib/session.ts index 21f4bd1..ea51501 100644 --- a/zeude/dashboard/src/lib/session.ts +++ b/zeude/dashboard/src/lib/session.ts @@ -1,8 +1,8 @@ import { cookies } from 'next/headers' -import { createServerClient, isDBConnectionError } from './supabase' import type { User } from './database.types' import { redirect } from 'next/navigation' import { cache } from 'react' +import { getOperationalDb } from './db' interface SessionWithUser { id: string @@ -42,12 +42,8 @@ export const getSession = cache(async (): Promise => { if (process.env.NODE_ENV === 'development' && process.env.SKIP_AUTH === 'true') { try { const mockEmail = process.env.MOCK_EMAIL || 'dev@localhost' - const supabase = createServerClient() - const { data: realUser } = await supabase - .from('zeude_users') - .select('id, email, name, agent_key, team, role, status, disabled_skills, invited_by, created_at, updated_at') - .eq('email', mockEmail) - .single() + const db = getOperationalDb() + const realUser = db.users.findByEmail(mockEmail) if (realUser) { return { @@ -77,30 +73,18 @@ export const getSession = cache(async (): Promise => { return null } - const supabase = createServerClient() - - // Select only required columns to reduce data transfer - const { data: session, error } = await supabase - .from('zeude_sessions') - .select('id, token, user_id, expires_at, created_at, user:zeude_users(id, email, name, team, role, status, created_at)') - .eq('token', sessionToken) - .gt('expires_at', new Date().toISOString()) - .single() + const db = getOperationalDb() + const session = db.sessions.findValidByTokenWithUser(sessionToken, new Date().toISOString()) if (process.env.NODE_ENV === 'development') { console.log('[SESSION] DB query result:', { hasSession: !!session, hasUser: !!session?.user }) } - // Distinguish DB connection errors (ETIMEDOUT, etc.) from actual missing sessions - if (isDBConnectionError(error)) { - throw new Error('DB_CONNECTION_ERROR') - } - if (!session || !session.user) { return null } - return session as unknown as SessionWithUser + return session }) export async function getUser(): Promise { @@ -142,8 +126,8 @@ export async function logout() { const sessionToken = cookieStore.get('session')?.value if (sessionToken) { - const supabase = createServerClient() - await supabase.from('zeude_sessions').delete().eq('token', sessionToken) + const db = getOperationalDb() + db.sessions.deleteByToken(sessionToken) cookieStore.delete('session') } } From 6e1844f62516ea9ca72651931cbd81cd6bef7612 Mon Sep 17 00:00:00 2001 From: jihyunzep Date: Fri, 10 Apr 2026 16:59:10 +0900 Subject: [PATCH 2/9] Add single-host server installer --- zeude/Dockerfile | 1 + zeude/README.md | 18 +++ zeude/dashboard/README.md | 14 ++ zeude/dashboard/docker-compose.yaml | 5 +- zeude/scripts/build-release.sh | 1 + zeude/scripts/install-server.sh | 222 ++++++++++++++++++++++++++++ 6 files changed, 257 insertions(+), 4 deletions(-) create mode 100755 zeude/scripts/install-server.sh diff --git a/zeude/Dockerfile b/zeude/Dockerfile index de50400..f622e03 100644 --- a/zeude/Dockerfile +++ b/zeude/Dockerfile @@ -33,6 +33,7 @@ RUN VERSION=$(cat /tmp/version) && \ # Copy install and uninstall scripts COPY scripts/install.sh /releases/install.sh +COPY scripts/install-server.sh /releases/install-server.sh COPY scripts/uninstall.sh /releases/uninstall.sh # Node build stage diff --git a/zeude/README.md b/zeude/README.md index 99cfd9b..1f229f9 100644 --- a/zeude/README.md +++ b/zeude/README.md @@ -73,6 +73,13 @@ cp .env.example .env docker compose up -d --build ``` +Or install from the repo root with the server installer: + +```bash +cd zeude +bash scripts/install-server.sh +``` + ### 2. Install the CLI shim ```bash @@ -166,6 +173,17 @@ npm run migrate:supabase-to-sqlite Use `--force` only when you intentionally want to import into a non-empty SQLite DB. +### Basic server verification + +After `install-server.sh` or `docker compose up -d --build`: + +```bash +curl -s http://localhost:3000/api/health +docker compose --env-file /opt/zeude/config/zeude.env -f /opt/zeude/app/dashboard/docker-compose.yaml ps +docker compose --env-file /opt/zeude/config/zeude.env -f /opt/zeude/app/dashboard/docker-compose.yaml logs -f +sqlite3 /var/lib/zeude/zeude.db ".tables" +``` + ### Go binaries ```bash diff --git a/zeude/dashboard/README.md b/zeude/dashboard/README.md index 14246d0..c2e9f53 100644 --- a/zeude/dashboard/README.md +++ b/zeude/dashboard/README.md @@ -38,3 +38,17 @@ Use `--force` only if the target SQLite DB already has data and you intend to re - a persistent volume mounted at `/var/lib/zeude` ClickHouse remains external and must still be configured via env vars. + +From the repo root you can also run: + +```bash +bash scripts/install-server.sh +``` + +Quick smoke checks after install: + +```bash +curl -s http://localhost:3000/api/health +docker compose --env-file /opt/zeude/config/zeude.env -f /opt/zeude/app/dashboard/docker-compose.yaml ps +sqlite3 /var/lib/zeude/zeude.db ".tables" +``` diff --git a/zeude/dashboard/docker-compose.yaml b/zeude/dashboard/docker-compose.yaml index 25febaf..b87de76 100644 --- a/zeude/dashboard/docker-compose.yaml +++ b/zeude/dashboard/docker-compose.yaml @@ -18,7 +18,7 @@ services: - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-} - OPENROUTER_MODEL=${OPENROUTER_MODEL:-anthropic/claude-3.5-sonnet} volumes: - - zeude_data:/var/lib/zeude + - ${ZEUDE_DATA_DIR:-/var/lib/zeude}:/var/lib/zeude restart: unless-stopped healthcheck: test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] @@ -26,6 +26,3 @@ services: timeout: 10s retries: 3 start_period: 40s - -volumes: - zeude_data: diff --git a/zeude/scripts/build-release.sh b/zeude/scripts/build-release.sh index c15eb72..5b3d73b 100755 --- a/zeude/scripts/build-release.sh +++ b/zeude/scripts/build-release.sh @@ -73,6 +73,7 @@ done echo "" echo -n "Copying install script... " cp "$SCRIPT_DIR/install.sh" "$OUTPUT_DIR/install.sh" +cp "$SCRIPT_DIR/install-server.sh" "$OUTPUT_DIR/install-server.sh" echo -e "${GREEN}OK${NC}" echo "" diff --git a/zeude/scripts/install-server.sh b/zeude/scripts/install-server.sh new file mode 100755 index 0000000..a8aa459 --- /dev/null +++ b/zeude/scripts/install-server.sh @@ -0,0 +1,222 @@ +#!/bin/bash +# Zeude server installation script +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +INSTALL_DIR="${ZEUDE_INSTALL_DIR:-/opt/zeude}" +APP_DIR="$INSTALL_DIR/app" +CONFIG_DIR="$INSTALL_DIR/config" +ENV_FILE="$CONFIG_DIR/zeude.env" +DATA_DIR="${ZEUDE_DATA_DIR:-/var/lib/zeude}" +REPO_URL="${ZEUDE_REPO_URL:-https://github.com/zep-us/zeude.git}" +GIT_REF="${ZEUDE_GIT_REF:-main}" +APP_URL="${NEXT_PUBLIC_APP_URL:-http://localhost:3000}" +CLICKHOUSE_URL="${CLICKHOUSE_URL:-http://host.docker.internal:8123}" +CLICKHOUSE_USER="${CLICKHOUSE_USER:-default}" +CLICKHOUSE_PASSWORD="${CLICKHOUSE_PASSWORD:-dev}" +CLICKHOUSE_DATABASE="${CLICKHOUSE_DATABASE:-default}" +OPENROUTER_API_KEY="${OPENROUTER_API_KEY:-}" +OPENROUTER_MODEL="${OPENROUTER_MODEL:-anthropic/claude-3.5-sonnet}" +SESSION_SECRET="${SESSION_SECRET:-}" +DRY_RUN="${DRY_RUN:-0}" +SKIP_SYSTEMD="${SKIP_SYSTEMD:-0}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +printf "${GREEN}Zeude Server Installer${NC}\n" +echo "======================================" + +run_cmd() { + if [ "$DRY_RUN" = "1" ]; then + printf "${BLUE}[dry-run]${NC} %s\n" "$*" + return 0 + fi + "$@" +} + +run_shell() { + if [ "$DRY_RUN" = "1" ]; then + printf "${BLUE}[dry-run]${NC} %s\n" "$1" + return 0 + fi + bash -lc "$1" +} + +need_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + printf "${RED}Missing required command:${NC} %s\n" "$1" + exit 1 + fi +} + +generate_secret() { + if [ -n "$SESSION_SECRET" ]; then + printf "%s" "$SESSION_SECRET" + return 0 + fi + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex 32 + return 0 + fi + python3 - <<'PY' +import secrets +print(secrets.token_hex(32)) +PY +} + +prepare_source_tree() { + if [ -f "$REPO_ROOT/dashboard/docker-compose.yaml" ] && [ -f "$REPO_ROOT/Dockerfile" ]; then + echo "local" + return 0 + fi + echo "remote" +} + +copy_local_repo() { + printf "Copying local repository into %s... " "$APP_DIR" + run_cmd mkdir -p "$APP_DIR" + run_shell "rsync -a --delete --exclude '.git' --exclude 'node_modules' --exclude '.next' --exclude '.data' --exclude '.omc' '$REPO_ROOT/' '$APP_DIR/'" + printf "${GREEN}OK${NC}\n" +} + +clone_remote_repo() { + printf "Fetching repository %s (%s)... " "$REPO_URL" "$GIT_REF" + run_cmd mkdir -p "$INSTALL_DIR" + if [ "$DRY_RUN" = "1" ]; then + printf "${GREEN}OK${NC}\n" + return 0 + fi + rm -rf "$APP_DIR" + git clone --depth 1 --branch "$GIT_REF" "$REPO_URL" "$APP_DIR" >/dev/null 2>&1 + printf "${GREEN}OK${NC}\n" +} + +write_env_file() { + local secret + secret="$(generate_secret)" + printf "Writing env file... " + run_cmd mkdir -p "$CONFIG_DIR" "$DATA_DIR" + if [ "$DRY_RUN" = "1" ]; then + printf "${GREEN}OK${NC}\n" + return 0 + fi + cat > "$ENV_FILE" </dev/null 2>&1; then + printf "${YELLOW}systemd not available, skipping service registration${NC}\n" + return 0 + fi + + local service_path="/etc/systemd/system/zeude-dashboard.service" + local docker_bin + docker_bin="$(command -v docker)" + printf "Registering systemd service... " + + if [ "$DRY_RUN" = "1" ]; then + printf "${GREEN}OK${NC}\n" + return 0 + fi + + cat > /tmp/zeude-dashboard.service </dev/null + else + need_cmd sudo + sudo mv /tmp/zeude-dashboard.service "$service_path" + sudo systemctl daemon-reload + sudo systemctl enable zeude-dashboard.service >/dev/null + fi + printf "${GREEN}OK${NC}\n" +} + +start_stack() { + printf "Starting Zeude dashboard stack... " + local cmd="cd '$APP_DIR/dashboard' && docker compose --env-file '$ENV_FILE' -f docker-compose.yaml up -d --build" + run_shell "$cmd" + printf "${GREEN}OK${NC}\n" +} + +main() { + need_cmd docker + run_shell "docker compose version >/dev/null" + + local source_mode + source_mode="$(prepare_source_tree)" + + printf "Install directory: ${BLUE}%s${NC}\n" "$INSTALL_DIR" + printf "Data directory: ${BLUE}%s${NC}\n" "$DATA_DIR" + printf "Source mode: ${BLUE}%s${NC}\n" "$source_mode" + + if [ "$source_mode" = "local" ]; then + need_cmd rsync + copy_local_repo + else + need_cmd git + clone_remote_repo + fi + + write_env_file + start_stack + install_systemd_unit + + echo "" + printf "${GREEN}Install complete${NC}\n" + echo "======================================" + printf "Dashboard: ${BLUE}%s${NC}\n" "$APP_URL" + printf "Env file: ${BLUE}%s${NC}\n" "$ENV_FILE" + printf "Data dir: ${BLUE}%s${NC}\n" "$DATA_DIR" + echo "" + echo "Basic checks:" + echo " docker compose --env-file '$ENV_FILE' -f '$APP_DIR/dashboard/docker-compose.yaml' ps" + echo " curl -s '$APP_URL/api/health'" + echo " docker compose --env-file '$ENV_FILE' -f '$APP_DIR/dashboard/docker-compose.yaml' logs -f" +} + +main "$@" From 52e22f4b8a8a47bdc38a33346c34b9f48383d08e Mon Sep 17 00:00:00 2001 From: jihyunzep Date: Fri, 10 Apr 2026 17:15:58 +0900 Subject: [PATCH 3/9] Fix install script dashboard summary output --- zeude/releases/install.sh | 4 ++-- zeude/scripts/install.sh | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/zeude/releases/install.sh b/zeude/releases/install.sh index 8911a16..d892123 100644 --- a/zeude/releases/install.sh +++ b/zeude/releases/install.sh @@ -257,7 +257,7 @@ printf "${GREEN}OK${NC}\n" if [ -z "$ZEUDE_AGENT_KEY" ]; then echo "" printf "${YELLOW}Agent key required for telemetry collection.${NC}\n" - printf "Get your key from: ${BLUE}${DEFAULT_DASHBOARD}${NC}\n" + printf "Get your key from: ${BLUE}${ZEUDE_DASHBOARD_URL:-$DEFAULT_DASHBOARD}${NC}\n" echo "" printf "Enter your agent key (zd_xxx): " read -r ZEUDE_AGENT_KEY < /dev/tty @@ -285,7 +285,7 @@ echo "======================================" echo "Real claude: $REAL_CLAUDE" echo "Shim binary: $INSTALL_DIR/claude" echo "Config dir: $CONFIG_DIR" -echo "Dashboard: $DEFAULT_DASHBOARD" +echo "Dashboard: ${ZEUDE_DASHBOARD_URL:-$DEFAULT_DASHBOARD}" echo "" printf "${YELLOW}Next steps:${NC}\n" printf " 1. Restart your shell or run: ${BLUE}source $SHELL_RC${NC}\n" diff --git a/zeude/scripts/install.sh b/zeude/scripts/install.sh index 8911a16..d892123 100644 --- a/zeude/scripts/install.sh +++ b/zeude/scripts/install.sh @@ -257,7 +257,7 @@ printf "${GREEN}OK${NC}\n" if [ -z "$ZEUDE_AGENT_KEY" ]; then echo "" printf "${YELLOW}Agent key required for telemetry collection.${NC}\n" - printf "Get your key from: ${BLUE}${DEFAULT_DASHBOARD}${NC}\n" + printf "Get your key from: ${BLUE}${ZEUDE_DASHBOARD_URL:-$DEFAULT_DASHBOARD}${NC}\n" echo "" printf "Enter your agent key (zd_xxx): " read -r ZEUDE_AGENT_KEY < /dev/tty @@ -285,7 +285,7 @@ echo "======================================" echo "Real claude: $REAL_CLAUDE" echo "Shim binary: $INSTALL_DIR/claude" echo "Config dir: $CONFIG_DIR" -echo "Dashboard: $DEFAULT_DASHBOARD" +echo "Dashboard: ${ZEUDE_DASHBOARD_URL:-$DEFAULT_DASHBOARD}" echo "" printf "${YELLOW}Next steps:${NC}\n" printf " 1. Restart your shell or run: ${BLUE}source $SHELL_RC${NC}\n" From 6e2bba14f3eea25d11baba08dea811a508ddabe4 Mon Sep 17 00:00:00 2001 From: jihyunzep Date: Fri, 10 Apr 2026 17:28:08 +0900 Subject: [PATCH 4/9] Track server install release assets --- zeude/dashboard/.env.example | 21 +++ zeude/dashboard/.gitignore | 1 + zeude/releases/install-server.sh | 222 +++++++++++++++++++++++++++++++ 3 files changed, 244 insertions(+) create mode 100644 zeude/dashboard/.env.example create mode 100755 zeude/releases/install-server.sh diff --git a/zeude/dashboard/.env.example b/zeude/dashboard/.env.example new file mode 100644 index 0000000..159614f --- /dev/null +++ b/zeude/dashboard/.env.example @@ -0,0 +1,21 @@ +NODE_ENV=development +DATABASE_PROVIDER=sqlite +DATABASE_PATH=.data/zeude.db +ZEUDE_DATA_DIR=.data +SESSION_SECRET=dev-session-secret + +NEXT_PUBLIC_APP_URL=http://localhost:3000 + +CLICKHOUSE_URL=http://localhost:8123 +CLICKHOUSE_USER=default +CLICKHOUSE_PASSWORD=dev +CLICKHOUSE_DATABASE=default + +# Optional, only for one-time migration from an existing Supabase deployment +SUPABASE_URL= +SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= + +# Optional +OPENROUTER_API_KEY= +OPENROUTER_MODEL=anthropic/claude-3.5-sonnet diff --git a/zeude/dashboard/.gitignore b/zeude/dashboard/.gitignore index 5736a1b..59e2ccb 100644 --- a/zeude/dashboard/.gitignore +++ b/zeude/dashboard/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/zeude/releases/install-server.sh b/zeude/releases/install-server.sh new file mode 100755 index 0000000..a8aa459 --- /dev/null +++ b/zeude/releases/install-server.sh @@ -0,0 +1,222 @@ +#!/bin/bash +# Zeude server installation script +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +INSTALL_DIR="${ZEUDE_INSTALL_DIR:-/opt/zeude}" +APP_DIR="$INSTALL_DIR/app" +CONFIG_DIR="$INSTALL_DIR/config" +ENV_FILE="$CONFIG_DIR/zeude.env" +DATA_DIR="${ZEUDE_DATA_DIR:-/var/lib/zeude}" +REPO_URL="${ZEUDE_REPO_URL:-https://github.com/zep-us/zeude.git}" +GIT_REF="${ZEUDE_GIT_REF:-main}" +APP_URL="${NEXT_PUBLIC_APP_URL:-http://localhost:3000}" +CLICKHOUSE_URL="${CLICKHOUSE_URL:-http://host.docker.internal:8123}" +CLICKHOUSE_USER="${CLICKHOUSE_USER:-default}" +CLICKHOUSE_PASSWORD="${CLICKHOUSE_PASSWORD:-dev}" +CLICKHOUSE_DATABASE="${CLICKHOUSE_DATABASE:-default}" +OPENROUTER_API_KEY="${OPENROUTER_API_KEY:-}" +OPENROUTER_MODEL="${OPENROUTER_MODEL:-anthropic/claude-3.5-sonnet}" +SESSION_SECRET="${SESSION_SECRET:-}" +DRY_RUN="${DRY_RUN:-0}" +SKIP_SYSTEMD="${SKIP_SYSTEMD:-0}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +printf "${GREEN}Zeude Server Installer${NC}\n" +echo "======================================" + +run_cmd() { + if [ "$DRY_RUN" = "1" ]; then + printf "${BLUE}[dry-run]${NC} %s\n" "$*" + return 0 + fi + "$@" +} + +run_shell() { + if [ "$DRY_RUN" = "1" ]; then + printf "${BLUE}[dry-run]${NC} %s\n" "$1" + return 0 + fi + bash -lc "$1" +} + +need_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + printf "${RED}Missing required command:${NC} %s\n" "$1" + exit 1 + fi +} + +generate_secret() { + if [ -n "$SESSION_SECRET" ]; then + printf "%s" "$SESSION_SECRET" + return 0 + fi + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex 32 + return 0 + fi + python3 - <<'PY' +import secrets +print(secrets.token_hex(32)) +PY +} + +prepare_source_tree() { + if [ -f "$REPO_ROOT/dashboard/docker-compose.yaml" ] && [ -f "$REPO_ROOT/Dockerfile" ]; then + echo "local" + return 0 + fi + echo "remote" +} + +copy_local_repo() { + printf "Copying local repository into %s... " "$APP_DIR" + run_cmd mkdir -p "$APP_DIR" + run_shell "rsync -a --delete --exclude '.git' --exclude 'node_modules' --exclude '.next' --exclude '.data' --exclude '.omc' '$REPO_ROOT/' '$APP_DIR/'" + printf "${GREEN}OK${NC}\n" +} + +clone_remote_repo() { + printf "Fetching repository %s (%s)... " "$REPO_URL" "$GIT_REF" + run_cmd mkdir -p "$INSTALL_DIR" + if [ "$DRY_RUN" = "1" ]; then + printf "${GREEN}OK${NC}\n" + return 0 + fi + rm -rf "$APP_DIR" + git clone --depth 1 --branch "$GIT_REF" "$REPO_URL" "$APP_DIR" >/dev/null 2>&1 + printf "${GREEN}OK${NC}\n" +} + +write_env_file() { + local secret + secret="$(generate_secret)" + printf "Writing env file... " + run_cmd mkdir -p "$CONFIG_DIR" "$DATA_DIR" + if [ "$DRY_RUN" = "1" ]; then + printf "${GREEN}OK${NC}\n" + return 0 + fi + cat > "$ENV_FILE" </dev/null 2>&1; then + printf "${YELLOW}systemd not available, skipping service registration${NC}\n" + return 0 + fi + + local service_path="/etc/systemd/system/zeude-dashboard.service" + local docker_bin + docker_bin="$(command -v docker)" + printf "Registering systemd service... " + + if [ "$DRY_RUN" = "1" ]; then + printf "${GREEN}OK${NC}\n" + return 0 + fi + + cat > /tmp/zeude-dashboard.service </dev/null + else + need_cmd sudo + sudo mv /tmp/zeude-dashboard.service "$service_path" + sudo systemctl daemon-reload + sudo systemctl enable zeude-dashboard.service >/dev/null + fi + printf "${GREEN}OK${NC}\n" +} + +start_stack() { + printf "Starting Zeude dashboard stack... " + local cmd="cd '$APP_DIR/dashboard' && docker compose --env-file '$ENV_FILE' -f docker-compose.yaml up -d --build" + run_shell "$cmd" + printf "${GREEN}OK${NC}\n" +} + +main() { + need_cmd docker + run_shell "docker compose version >/dev/null" + + local source_mode + source_mode="$(prepare_source_tree)" + + printf "Install directory: ${BLUE}%s${NC}\n" "$INSTALL_DIR" + printf "Data directory: ${BLUE}%s${NC}\n" "$DATA_DIR" + printf "Source mode: ${BLUE}%s${NC}\n" "$source_mode" + + if [ "$source_mode" = "local" ]; then + need_cmd rsync + copy_local_repo + else + need_cmd git + clone_remote_repo + fi + + write_env_file + start_stack + install_systemd_unit + + echo "" + printf "${GREEN}Install complete${NC}\n" + echo "======================================" + printf "Dashboard: ${BLUE}%s${NC}\n" "$APP_URL" + printf "Env file: ${BLUE}%s${NC}\n" "$ENV_FILE" + printf "Data dir: ${BLUE}%s${NC}\n" "$DATA_DIR" + echo "" + echo "Basic checks:" + echo " docker compose --env-file '$ENV_FILE' -f '$APP_DIR/dashboard/docker-compose.yaml' ps" + echo " curl -s '$APP_URL/api/health'" + echo " docker compose --env-file '$ENV_FILE' -f '$APP_DIR/dashboard/docker-compose.yaml' logs -f" +} + +main "$@" From c0f96103ce0c6a824cebe51550f6c6835035333e Mon Sep 17 00:00:00 2001 From: jihyunzep Date: Fri, 10 Apr 2026 17:48:05 +0900 Subject: [PATCH 5/9] Fix fresh single-host install bootstrap --- zeude/Dockerfile | 10 ++++++++++ zeude/dashboard/Dockerfile | 10 ++++++++++ zeude/dashboard/src/lib/db/sqlite/migrator.ts | 8 ++++++++ 3 files changed, 28 insertions(+) diff --git a/zeude/Dockerfile b/zeude/Dockerfile index f622e03..bfd1d50 100644 --- a/zeude/Dockerfile +++ b/zeude/Dockerfile @@ -45,6 +45,16 @@ WORKDIR /app COPY dashboard/package*.json ./ RUN npm ci +ENV NODE_ENV=production +ENV DATABASE_PROVIDER=sqlite +ENV DATABASE_PATH=/tmp/zeude-build.db +ENV SESSION_SECRET=build-session-secret +ENV NEXT_PUBLIC_APP_URL=http://localhost:3000 +ENV CLICKHOUSE_URL=http://localhost:8123 +ENV CLICKHOUSE_USER=default +ENV CLICKHOUSE_PASSWORD=build +ENV CLICKHOUSE_DATABASE=default + COPY dashboard . # Copy pre-built Go binaries diff --git a/zeude/dashboard/Dockerfile b/zeude/dashboard/Dockerfile index 1c20789..4dfbafa 100644 --- a/zeude/dashboard/Dockerfile +++ b/zeude/dashboard/Dockerfile @@ -10,6 +10,16 @@ WORKDIR /app COPY package*.json ./ RUN npm ci +ENV NODE_ENV=production +ENV DATABASE_PROVIDER=sqlite +ENV DATABASE_PATH=/tmp/zeude-build.db +ENV SESSION_SECRET=build-session-secret +ENV NEXT_PUBLIC_APP_URL=http://localhost:3000 +ENV CLICKHOUSE_URL=http://localhost:8123 +ENV CLICKHOUSE_USER=default +ENV CLICKHOUSE_PASSWORD=build +ENV CLICKHOUSE_DATABASE=default + # Copy source and build COPY . . diff --git a/zeude/dashboard/src/lib/db/sqlite/migrator.ts b/zeude/dashboard/src/lib/db/sqlite/migrator.ts index 4eab88c..9c29670 100644 --- a/zeude/dashboard/src/lib/db/sqlite/migrator.ts +++ b/zeude/dashboard/src/lib/db/sqlite/migrator.ts @@ -159,6 +159,14 @@ const MIGRATIONS = [ ] export function migrateSqlite(db: Database.Database) { + db.exec(` + CREATE TABLE IF NOT EXISTS _sqlite_migrations ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + applied_at TEXT NOT NULL + ); + `) + const existingRows = db.prepare('SELECT name FROM _sqlite_migrations ORDER BY id').all() as { name: string }[] const applied = new Set( existingRows.map(row => row.name) From e39ca5b33c3c2f71e54aec93c0433a8d72ea6e4e Mon Sep 17 00:00:00 2001 From: jihyunzep Date: Fri, 10 Apr 2026 18:12:09 +0900 Subject: [PATCH 6/9] Remove migration plan doc from PR --- ...026-04-10-sqlite-runtime-migration-plan.md | 585 ------------------ 1 file changed, 585 deletions(-) delete mode 100644 docs/plans/2026-04-10-sqlite-runtime-migration-plan.md diff --git a/docs/plans/2026-04-10-sqlite-runtime-migration-plan.md b/docs/plans/2026-04-10-sqlite-runtime-migration-plan.md deleted file mode 100644 index b3a9f4d..0000000 --- a/docs/plans/2026-04-10-sqlite-runtime-migration-plan.md +++ /dev/null @@ -1,585 +0,0 @@ -# SQLite Runtime + Supabase Migration Implementation Plan - -> Status: revised for execution readiness on 2026-04-10 - -**Goal:** Replace Supabase as the runtime operational datastore with SQLite, keep ClickHouse for analytics, preserve the existing client `releases/install.sh` UX, and add a supported migration path from existing Supabase deployments to SQLite. - -**Architecture:** The dashboard runtime becomes `SQLite + ClickHouse`. All operational reads and writes move behind a repository layer so API routes, auth/session code, and mixed analytics helpers stop calling Supabase directly. Supabase remains only as a migration source for existing installations. Release packaging is extended so the dashboard continues to serve CLI artifacts and additionally serves a server bundle plus `install-server.sh` for single-host deployment. - -**Tech Stack:** Next.js App Router, TypeScript, Vitest, Go CLI binaries, SQLite runtime, ClickHouse analytics, shell install scripts, Docker multi-stage release build. - ---- - -## Scope - -In scope: -- Replace runtime operational storage with SQLite. -- Keep ClickHouse analytics code paths and envs. -- Add repository layer for users, sessions, OTT, invites, skills, hooks, MCP servers, agents, cohorts, install status, and related admin metadata. -- Add SQLite schema, migration runner, and bootstrap path. -- Add Supabase-to-SQLite migration tool with dry-run and verification. -- Add server install packaging and `install-server.sh`. -- Keep `/releases/install.sh` and CLI binaries working. - -Out of scope: -- Supporting runtime `postgres` or runtime `supabase` provider selection. -- Replacing ClickHouse analytics with SQLite. -- Re-architecting the CLI sync protocol. -- Multi-node deployment support. - -## Success Criteria - -- Fresh install can run on a single host using SQLite for operational data. -- Existing client install command still works against the new dashboard. -- All runtime operational APIs and helpers no longer depend on `@supabase/supabase-js`. -- Existing analytics pages and routes continue using ClickHouse. -- Existing Supabase deployments can migrate operational data into SQLite with a verifiable tool. -- Route tests, migration tests, and packaging smoke checks cover the high-risk flows. - -## Current State Summary - -Runtime operational logic currently depends directly on Supabase in these areas: -- Auth and session handling: `zeude/dashboard/src/lib/session.ts` -- Supabase client creation: `zeude/dashboard/src/lib/supabase.ts` -- Config sync API: `zeude/dashboard/src/app/api/config/[agentKey]/route.ts` -- Auth callback, OTT issuance, and logout: `zeude/dashboard/src/app/api/auth/**` -- Public invite validation and claim flow: `zeude/dashboard/src/app/api/invite/[token]/route.ts` -- User skill preference APIs: `zeude/dashboard/src/app/api/user/skills/route.ts` -- Status and install-reporting APIs: `zeude/dashboard/src/app/api/status/[agentKey]/route.ts` -- Admin CRUD and helper modules under `zeude/dashboard/src/app/api/admin/**` and `zeude/dashboard/src/lib/data/**` -- Mixed analytics-plus-operational routes: `leaderboard`, `prompts`, `skill-suggestions`, `admin/analytics/**` -- User name resolution helper: `zeude/dashboard/src/lib/name-resolution.ts` - -Analytics already has a partial optional boundary: -- ClickHouse client and analytics helpers: `zeude/dashboard/src/lib/clickhouse.ts` - -Release packaging already serves CLI assets from the dashboard: -- `zeude/Dockerfile` -- `zeude/scripts/build-release.sh` -- `zeude/releases/install.sh` - -## Execution Preconditions - -Lock these before implementation starts: -- Choose the SQLite driver and confirm it works with `node:20-alpine`, Next standalone output, and CI. -- Decide whether install status tables remain in SQLite v1 or are intentionally dropped with route changes. -- Decide whether admin bootstrap is installer-driven, web-first, or both. -- Decide release artifact shape: tarball only, Docker image only, or both. - -If these decisions are not locked first, the plan will churn mid-implementation. - -## SQLite Driver Decision - -Required properties: -- Must work in the Next.js server runtime used by the dashboard. -- Must work inside the current Docker build and runtime model. -- Must support transactions, prepared statements, and predictable file locking behavior. -- Must have a testable story for local dev and CI. - -Recommended default: -- Prefer `better-sqlite3` if it builds cleanly in the current Alpine-based image and standalone bundling includes it correctly. -- If Alpine/native build friction is too high, switch the runtime image from Alpine to Debian slim before implementation rather than forcing a weaker driver choice late. - -Do not start repository implementation until this is decided and proven with a throwaway build spike. - -## Data Model To Preserve In SQLite - -Operational tables that must exist in SQLite unless explicitly removed by product decision: -- `zeude_users` -- `zeude_sessions` -- `zeude_one_time_tokens` -- `zeude_invites` -- `zeude_skills` -- `zeude_hooks` -- `zeude_mcp_servers` -- `zeude_agents` -- `zeude_cohort_members` -- `zeude_mcp_install_status` -- `zeude_hook_install_status` -- any supporting lookup/admin tables referenced by existing APIs - -Field handling rules: -- Keep IDs stable during migration. -- Store array/object fields as JSON text in SQLite. -- Normalize timestamps as ISO 8601 UTC strings. -- Preserve `status`, `role`, `team`, `agent_key`, `disabled_skills`, `files`, `env`, `teams`, `contributors`, and similar fields with the same semantics. -- Add unique indexes for runtime invariants such as `email`, `agent_key`, `token`, and route-level conflict keys. - -## Supabase Semantics Inventory To Reproduce - -This migration is not just table copying. The runtime currently depends on Supabase/Postgres semantics that must be reimplemented explicitly: - -- Atomic skill disable toggle via Postgres RPC `toggle_disabled_skill` -- `upsert` with `onConflict` and duplicate-ignore behavior for cohort registration and status reporting -- Joined selects used by session/auth callbacks -- Count-only reads such as `select(..., { count: 'exact', head: true })` -- Array containment filters such as `teams.cs` -- Claim-first invite flow with rollback on downstream failure -- Bulk ID and email lookups used by analytics name resolution -- Stable ordering relied on by config sync hashing or response determinism - -Every one of these must map to either: -- a repository method plus transaction boundary -- a unique index plus conflict handling policy -- or a deliberate behavior change documented in advance - -## Environment Model - -New runtime env: -- `DATABASE_PROVIDER=sqlite` -- `DATABASE_PATH=/var/lib/zeude/zeude.db` -- `SESSION_SECRET=` -- `NEXT_PUBLIC_APP_URL=` -- `CLICKHOUSE_URL`, `CLICKHOUSE_USER`, `CLICKHOUSE_PASSWORD`, `CLICKHOUSE_DATABASE` - -Migration-only env or flags: -- `SUPABASE_URL` -- `SUPABASE_SERVICE_ROLE_KEY` - -Runtime rules: -- Supabase env validation must be removed from startup for SQLite mode. -- ClickHouse remains optional for runtime startup but required for analytics features. -- SQLite path creation, permissions, and backup location must be part of installer behavior. - -## Test Strategy - -Meaningful coverage is possible and required. - -Add or update tests in these layers: -- Repository unit tests for SQLite CRUD, filtering, and transaction behavior. -- Route tests for auth, config sync, admin CRUD, invite claim, skill toggle, and status reporting. -- Migration tests that seed Supabase-shaped records, import them into SQLite, and verify row counts and transformed fields. -- Packaging smoke tests for release artifacts and installer assumptions. -- Build verification for the chosen SQLite driver inside the target runtime image. - -Minimum required automated verification: -- `vitest` on all touched route tests. -- New SQLite repository tests. -- New migration command tests. -- Existing ClickHouse schema/config tests must still pass. -- `npm run build` for dashboard after runtime env cleanup. -- `go test ./...` for CLI/install code touched by release changes. - -## Phase 0: Spike and Semantic Inventory - -This is a required phase before the current Task 1. - -**Files:** -- Create: `docs/plans/2026-04-10-sqlite-runtime-semantic-inventory.md` -- Optionally create: temporary driver spike files under `zeude/dashboard/scripts/` or `zeude/dashboard/src/lib/db/sqlite/` - -**Steps:** -1. Confirm the exact set of Supabase-backed routes and helpers still in runtime use. -2. Inventory every Postgres/Supabase behavior that needs explicit SQLite replacement. -3. Run a small driver compatibility spike against the current Docker/runtime model. -4. Lock the SQLite driver decision before schema and repository work starts. -5. Record any deliberate v1 exclusions, if there are any. - -**Verification:** -- Produce a short semantic inventory doc and a passing driver smoke result. -- Do not start schema work until this phase is complete. - -## Task 1: Create the planning and scaffolding directories - -**Files:** -- Keep: `docs/plans/2026-04-10-sqlite-runtime-migration-plan.md` -- Create: `zeude/dashboard/src/lib/db/` -- Create: `zeude/dashboard/src/lib/db/sqlite/` -- Create: `zeude/dashboard/src/lib/db/repositories/` -- Create: `zeude/dashboard/src/lib/db/migration/` -- Create: `zeude/dashboard/scripts/` -- Create: `zeude/server-install/` - -**Steps:** -1. Create the DB-layer, migration, and packaging directories. -2. Add placeholder modules so import paths are stable before larger refactors. -3. Commit only directory and scaffold additions. - -**Verification:** -- Run: `find zeude/dashboard/src/lib/db -maxdepth 3 -type d | sort` -- Expected: `repositories`, `sqlite`, and `migration` directories exist. - -## Task 2: Introduce the operational DB boundary - -**Files:** -- Create: `zeude/dashboard/src/lib/db/types.ts` -- Create: `zeude/dashboard/src/lib/db/index.ts` -- Create repository interfaces for: - - `users` - - `sessions` - - `tokens` - - `invites` - - `skills` - - `hooks` - - `mcp` - - `agents` - - `cohorts` - - `install-status` -- Modify: `zeude/dashboard/src/lib/session.ts` -- Modify: `zeude/dashboard/src/app/api/config/[agentKey]/route.ts` -- Modify: `zeude/dashboard/src/lib/name-resolution.ts` - -**Steps:** -1. Define repository interfaces that cover all current operational queries and writes, not just simple CRUD. -2. Add a runtime `getOperationalDb()` factory that returns SQLite-backed repositories. -3. Refactor one narrow path first, preferably config sync, to prove the boundary. -4. Refactor session/auth helpers and name-resolution to depend on repositories, not Supabase. -5. Commit the boundary before converting every route. - -**Tests:** -- Create: `zeude/dashboard/src/lib/db/repositories/repositories.test.ts` -- Modify: `zeude/dashboard/src/app/api/config/[agentKey]/route.test.ts` - -**Verification:** -- Run: `npm test -- --runInBand src/app/api/config/[agentKey]/route.test.ts` -- Expected: config route still passes after removing direct Supabase dependency from the route. - -## Task 3: Add SQLite connection and migration runner - -**Files:** -- Create: `zeude/dashboard/src/lib/db/sqlite/connection.ts` -- Create: `zeude/dashboard/src/lib/db/sqlite/migrator.ts` -- Create: `zeude/dashboard/src/lib/db/sqlite/migrations/0001_initial_operational_schema.sql` -- Create: `zeude/dashboard/src/lib/db/sqlite/migrations/0002_indexes_and_conflicts.sql` -- Create: `zeude/dashboard/src/lib/db/sqlite/migrations/0003_seed_defaults.sql` -- Create: `zeude/dashboard/scripts/migrate-sqlite.ts` -- Modify: `zeude/dashboard/package.json` - -**Steps:** -1. Implement one connection helper with configurable DB path. -2. Add migrations for all runtime tables, indexes, and conflict constraints. -3. Encode route-critical invariants in schema, not just in TypeScript. -4. Build a migration runner script and package command. -5. Ensure migrations are idempotent enough for installer use. - -**Tests:** -- Create: `zeude/dashboard/src/lib/db/sqlite/migrator.test.ts` - -**Verification:** -- Run: `npm run migrate:sqlite` -- Expected: SQLite file is created and schema migrations apply successfully. - -## Task 4: Implement SQLite repositories - -**Files:** -- Create SQLite-backed repositories for: - - `users` - - `sessions` - - `tokens` - - `invites` - - `skills` - - `hooks` - - `mcp` - - `agents` - - `cohorts` - - `install-status` -- Create: `zeude/dashboard/src/lib/db/sqlite/admin-support.ts` - -**Steps:** -1. Implement repository methods for each operational entity. -2. Mirror current filtering semantics for `is_global`, `teams`, `status`, contributors, and disabled skills. -3. Preserve deterministic sorting where config sync depends on stable hashes. -4. Add helper functions for JSON column serialization and deserialization. -5. Add transaction wrappers for invite claim, skill toggle, status upsert, and cohort registration. -6. Commit by logical slice if needed: auth/session first, then config data, then admin/public CRUD. - -**Tests:** -- Create: `zeude/dashboard/src/lib/db/sqlite/users-repository.test.ts` -- Create: `zeude/dashboard/src/lib/db/sqlite/sessions-repository.test.ts` -- Create: `zeude/dashboard/src/lib/db/sqlite/invites-repository.test.ts` -- Create: `zeude/dashboard/src/lib/db/sqlite/skills-repository.test.ts` -- Create: `zeude/dashboard/src/lib/db/sqlite/config-repositories.test.ts` -- Create: `zeude/dashboard/src/lib/db/sqlite/install-status-repository.test.ts` - -**Verification:** -- Run: `npm test -- --runInBand src/lib/db/sqlite` -- Expected: repository tests pass for inserts, lookups, updates, filters, deletes, and concurrent conflict paths. - -## Task 5: Refactor auth, session, and public invite flows to SQLite - -**Files:** -- Modify: `zeude/dashboard/src/lib/session.ts` -- Modify: `zeude/dashboard/src/app/api/auth/ott/route.ts` -- Modify: `zeude/dashboard/src/app/api/auth/callback/route.ts` -- Modify: `zeude/dashboard/src/app/api/auth/logout/route.ts` -- Modify: `zeude/dashboard/src/app/api/invite/[token]/route.ts` -- Modify: `zeude/dashboard/src/app/(auth)/auth/page.tsx` if needed for error messages - -**Steps:** -1. Replace Supabase lookups in OTT issuance with repository calls. -2. Replace session creation, validation, and logout flows with SQLite repository calls. -3. Move invite validation and claim flow to a transaction-backed SQLite implementation. -4. Keep cookie behavior identical to avoid client-facing regressions. -5. Remove Supabase-specific connection error handling from runtime auth paths. -6. Commit auth/session/public invite changes separately from admin CRUD. - -**Tests:** -- Create: `zeude/dashboard/src/app/api/auth/ott/route.test.ts` -- Create: `zeude/dashboard/src/app/api/auth/callback/route.test.ts` -- Create: `zeude/dashboard/src/app/api/invite/[token]/route.test.ts` -- Modify: existing auth/session tests if added during refactor - -**Verification:** -- Run: `npm test -- --runInBand src/app/api/auth src/app/api/invite` -- Expected: OTT creation, callback, invalid token, logout, invite validation, invite double-claim, and invite rollback flows pass. - -## Task 6: Refactor config sync, status, and admin/public operational APIs to SQLite - -**Files:** -- Modify: `zeude/dashboard/src/app/api/config/[agentKey]/route.ts` -- Modify: `zeude/dashboard/src/app/api/status/[agentKey]/route.ts` -- Modify: `zeude/dashboard/src/app/api/user/skills/route.ts` -- Modify: `zeude/dashboard/src/app/api/skill-rules/route.ts` -- Modify: `zeude/dashboard/src/app/api/skill-suggest/route.ts` -- Modify: `zeude/dashboard/src/app/api/skill-suggestions/route.ts` -- Modify: `zeude/dashboard/src/app/api/admin/agents/route.ts` -- Modify: `zeude/dashboard/src/app/api/admin/agents/[id]/route.ts` -- Modify: `zeude/dashboard/src/app/api/admin/hooks/route.ts` -- Modify: `zeude/dashboard/src/app/api/admin/hooks/[id]/route.ts` -- Modify: `zeude/dashboard/src/app/api/admin/skills/route.ts` -- Modify: `zeude/dashboard/src/app/api/admin/skills/[id]/route.ts` -- Modify: `zeude/dashboard/src/app/api/admin/mcp/route.ts` -- Modify: `zeude/dashboard/src/app/api/admin/mcp/[id]/route.ts` -- Modify: `zeude/dashboard/src/app/api/admin/users/route.ts` -- Modify: `zeude/dashboard/src/app/api/admin/users/[id]/route.ts` -- Modify: `zeude/dashboard/src/app/api/admin/users/[id]/key/route.ts` -- Modify: `zeude/dashboard/src/app/api/admin/invites/route.ts` -- Modify: `zeude/dashboard/src/app/api/admin/cohorts/route.ts` -- Modify: `zeude/dashboard/src/lib/data/*.ts` as needed - -**Steps:** -1. Convert config sync route fully to repository-backed reads. -2. Convert status reporting and user skill APIs. -3. Replace skill toggle RPC semantics with a repository transaction or conflict-safe update. -4. Convert admin CRUD routes one resource at a time. -5. Keep response shapes stable to avoid frontend churn. -6. Remove unused `supabase.ts` imports from refactored routes and helpers. - -**Tests:** -- Modify existing route tests: - - `zeude/dashboard/src/app/api/admin/agents/[id]/route.test.ts` - - `zeude/dashboard/src/app/api/admin/agents/route.test.ts` - - `zeude/dashboard/src/app/api/admin/hooks/[id]/route.test.ts` - - `zeude/dashboard/src/app/api/admin/invites/route.test.ts` - - `zeude/dashboard/src/app/api/admin/skills/[id]/route.test.ts` - - `zeude/dashboard/src/app/api/admin/skills/route.test.ts` - - `zeude/dashboard/src/app/api/admin/users/[id]/route.test.ts` - - `zeude/dashboard/src/app/api/config/[agentKey]/route.test.ts` -- Create new tests where coverage is missing: - - `status/[agentKey]` - - `user/skills` - - `admin/cohorts` - - `admin/users/[id]/key` - - `skill-rules` - - `skill-suggest` - -**Verification:** -- Run: `npm test -- --runInBand src/app/api/admin src/app/api/config src/app/api/status src/app/api/user src/app/api/skill-rules src/app/api/skill-suggest` -- Expected: all operational route tests pass using SQLite-backed setup. - -## Task 7: Remove Supabase runtime validation and runtime-only dependencies - -**Files:** -- Modify: `zeude/dashboard/src/lib/env.ts` -- Modify or delete: `zeude/dashboard/src/lib/supabase.ts` -- Modify: `zeude/dashboard/package.json` -- Modify: lockfile used by the dashboard - -**Steps:** -1. Change env validation so runtime no longer requires Supabase keys. -2. Demote `supabase.ts` to migration-only utility or delete it if fully replaced. -3. Remove runtime imports of `@supabase/supabase-js` from dashboard code. -4. Keep the library only if the migration tool still uses it. -5. Commit dependency and env cleanup separately. - -**Tests:** -- Create: `zeude/dashboard/src/lib/env.test.ts` - -**Verification:** -- Run: `npm run build` -- Expected: production build succeeds without Supabase runtime envs. - -## Task 8: Implement Supabase-to-SQLite migration tool - -**Files:** -- Create: `zeude/dashboard/src/lib/db/migration/supabase-client.ts` -- Create: `zeude/dashboard/src/lib/db/migration/export.ts` -- Create: `zeude/dashboard/src/lib/db/migration/transform.ts` -- Create: `zeude/dashboard/src/lib/db/migration/import.ts` -- Create: `zeude/dashboard/src/lib/db/migration/verify.ts` -- Create: `zeude/dashboard/scripts/migrate-supabase-to-sqlite.ts` -- Modify: `zeude/dashboard/package.json` - -**Steps:** -1. Add a migration-only Supabase client wrapper. -2. Export operational tables in a deterministic order respecting foreign-key and conflict dependencies. -3. Transform Supabase rows to SQLite row shapes, including JSON field normalization. -4. Import into a fresh SQLite DB inside a transaction per table or logical batch. -5. Add row-count verification and a human-readable summary report. -6. Support `--dry-run`, `--force`, and `--report `. -7. Document cutover sequence: freeze writes, export, import, verify, switch runtime, smoke test, rollback trigger. - -**Tests:** -- Create: `zeude/dashboard/src/lib/db/migration/transform.test.ts` -- Create: `zeude/dashboard/src/lib/db/migration/verify.test.ts` -- Create: `zeude/dashboard/scripts/migrate-supabase-to-sqlite.test.ts` - -**Verification:** -- Run: `npm test -- --runInBand src/lib/db/migration` -- Run: `node ./scripts/migrate-supabase-to-sqlite.ts --dry-run --supabase-url=... --supabase-service-role-key=... --sqlite-path=/tmp/zeude.db` -- Expected: dry-run prints counts and planned table imports without mutating SQLite. - -## Task 9: Keep ClickHouse intact and explicitly separate analytics from operational DB - -**Files:** -- Modify: `zeude/dashboard/src/lib/clickhouse.ts` -- Modify: analytics routes under `zeude/dashboard/src/app/api/admin/analytics/**` -- Modify: `zeude/dashboard/src/app/api/leaderboard/route.ts` -- Modify: `zeude/dashboard/src/app/api/prompts/route.ts` -- Modify: `zeude/dashboard/src/app/api/prompts/[id]/route.ts` -- Modify: `zeude/dashboard/src/lib/name-resolution.ts` - -**Steps:** -1. Audit analytics routes that currently use both ClickHouse and Supabase. -2. Replace operational user/name lookups with SQLite repositories. -3. Leave ClickHouse queries unchanged unless absolutely required. -4. Ensure analytics pages still degrade cleanly when ClickHouse is absent. -5. Preserve email fallback behavior for legacy analytics rows. - -**Tests:** -- Create or update route tests for mixed analytics-plus-operational lookups. - -**Verification:** -- Run: `npm test -- --runInBand src/app/api/admin/analytics src/app/api/leaderboard src/app/api/prompts` -- Expected: analytics routes still resolve user metadata through SQLite and metrics through ClickHouse mocks. - -## Task 10: Add server install packaging - -**Files:** -- Create: `zeude/server-install/install-server.sh` -- Create: `zeude/server-install/zeude.service` -- Create: `zeude/server-install/bootstrap-admin.sh` or `.ts` -- Create: `zeude/server-install/README.md` -- Modify: `zeude/Dockerfile` -- Modify: `zeude/scripts/build-release.sh` - -**Steps:** -1. Package a server bundle containing the built dashboard standalone output and support scripts. -2. Add `install-server.sh` that downloads the bundle, writes env/config, creates the SQLite path, runs migrations, and registers `systemd`. -3. Add admin bootstrap support so a fresh server can be initialized deterministically. -4. Ensure the dashboard still serves CLI binaries under `/releases`. -5. Verify the chosen SQLite driver and its native assets are included in the bundle and runtime image. -6. Commit release packaging separately from application refactor. - -**Tests:** -- Create: `zeude/server-install/install-server-smoke.sh` - -**Verification:** -- Run: `bash zeude/scripts/build-release.sh` -- Expected: release directory contains existing CLI assets plus server bundle and installer. - -## Task 11: Preserve client install UX - -**Files:** -- Modify: `zeude/releases/install.sh` -- Modify: `zeude/scripts/install.sh` -- Modify: `zeude/internal/autoupdate/autoupdate.go` -- Modify: docs where install command is shown - -**Steps:** -1. Keep the client installer contract stable. -2. Update defaults or messaging only where required by the new SQLite server runtime. -3. Ensure agent key flow still works with SQLite-backed OTT issuance. -4. Verify no regression in URL layout or release paths. - -**Tests:** -- Add shell smoke checks for `releases/install.sh` path assumptions. -- Re-run relevant Go tests under `zeude/internal`. - -**Verification:** -- Run: `go test ./...` -- Expected: Go CLI/install-related tests still pass. - -## Task 12: Update docs for fresh install and migration operators - -**Files:** -- Modify: `README.md` -- Modify: `zeude/README.md` -- Create: `zeude/docs/sqlite-self-host.md` -- Create: `zeude/docs/supabase-to-sqlite-migration.md` -- Create: `zeude/docs/release-artifacts.md` - -**Steps:** -1. Rewrite Quick Start around SQLite single-host runtime. -2. Document `install-server.sh`. -3. Document the preserved client installer flow. -4. Document migration prerequisites, dry-run, cutover, rollback, and verification. -5. Document ClickHouse as a continuing analytics dependency. - -**Verification:** -- Manual review that all install commands reference existing artifacts and env names. - -## Suggested Commit Sequence - -1. `docs: lock sqlite runtime migration preconditions` -2. `chore: scaffold sqlite runtime db layer` -3. `refactor: add operational repository interfaces` -4. `feat: add sqlite schema and migration runner` -5. `feat: implement sqlite repositories for runtime data` -6. `refactor: move auth session and invite flows to sqlite` -7. `refactor: migrate config status and admin apis off supabase` -8. `feat: add supabase to sqlite migration tool` -9. `build: add server installer and release bundle` -10. `docs: rewrite install and migration guides` - -## Risks To Review Before Execution - -- SQLite library choice may affect deployment size, native build complexity, and standalone bundling. -- Invite claim, cohort registration, skill toggle, and install-status writes all depend on conflict-safe behavior that must be reimplemented carefully. -- Some analytics routes depend on both ClickHouse and operational lookups, so partial migration can leave runtime still coupled to Supabase. -- The migration tool must define exactly which operational tables are source-of-truth and which ClickHouse data remains untouched. -- Installer behavior will vary across Linux distributions; `systemd` should be the primary supported target. -- SQLite is acceptable for the declared single-host scope, but backup, file permissions, and corruption recovery need explicit operational docs. - -## Open Decisions To Lock Before Coding - -- Exact SQLite driver package and base image implications. -- Whether `zeude_mcp_install_status` and `zeude_hook_install_status` are retained in v1. -- Whether admin bootstrap is web-first or installer-driven. -- Whether the server bundle is a tarball only or also shipped as a Docker image. -- Whether invite and cohort data are fully migrated or intentionally re-seeded. - -## Cutover and Rollback Requirements - -Cutover must be documented and tested: -- Freeze operational writes on the old runtime. -- Run Supabase export and SQLite import. -- Verify row counts and spot-check high-risk entities. -- Switch runtime env to SQLite. -- Run smoke tests for auth, config sync, invite claim, admin CRUD, and analytics pages. - -Rollback must be documented and tested: -- Define the exact point at which rollback is still safe. -- Preserve the pre-cutover Supabase environment and dashboard artifact version. -- Keep the generated SQLite file and verification report for incident analysis. - -## Recommended Test Matrix - -- Fresh SQLite install on macOS or Linux dev machine. -- Fresh SQLite server bundle install on Linux with `systemd`. -- Existing client `install.sh` against new dashboard. -- Supabase dry-run migration. -- Supabase real migration into empty SQLite DB. -- Cutover smoke test: auth, config sync, invite claim, user skill toggle, admin CRUD, analytics pages. -- Conflict-path tests: duplicate cohort registration, invite double-claim, repeated status upsert, concurrent skill toggle. - -## Execution Recommendation - -Use this plan only after Phase 0 is complete. The critical path is: -1. Lock driver and runtime packaging constraints. -2. Build the DB boundary and schema. -3. Move auth/config/invite/status flows first. -4. Remove residual Supabase runtime imports. -5. Add migration tooling and packaging last. - -Do not start with admin CRUD breadth before auth, invite, and config-sync semantics are proven on SQLite. From 428b33d5022e62725ed1a3433a8cffadc1ddb9ff Mon Sep 17 00:00:00 2001 From: jihyunzep Date: Fri, 10 Apr 2026 21:17:34 +0900 Subject: [PATCH 7/9] Fix single-host SQLite install path --- zeude/dashboard/docker-compose.yaml | 2 +- zeude/dashboard/src/lib/db/index.test.ts | 65 +++++++++++++++++++ zeude/dashboard/src/lib/db/index.ts | 13 +++- zeude/scripts/install-server.sh | 83 ++++++++++++++++++++++-- 4 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 zeude/dashboard/src/lib/db/index.test.ts diff --git a/zeude/dashboard/docker-compose.yaml b/zeude/dashboard/docker-compose.yaml index b87de76..48f2cd2 100644 --- a/zeude/dashboard/docker-compose.yaml +++ b/zeude/dashboard/docker-compose.yaml @@ -1,7 +1,7 @@ services: dashboard: build: - context: . + context: .. dockerfile: Dockerfile ports: - "3000:3000" diff --git a/zeude/dashboard/src/lib/db/index.test.ts b/zeude/dashboard/src/lib/db/index.test.ts new file mode 100644 index 0000000..67c59ee --- /dev/null +++ b/zeude/dashboard/src/lib/db/index.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const sqliteDb = { provider: 'sqlite' } +const supabaseDb = { provider: 'supabase' } + +vi.mock('./sqlite/repositories', () => ({ + createOperationalDb: vi.fn(() => sqliteDb), +})) + +vi.mock('./supabase-adapter', () => ({ + createSupabaseOperationalDb: vi.fn(() => supabaseDb), +})) + +describe('getOperationalDb', () => { + const originalEnv = process.env + + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + process.env = { ...originalEnv } + const testEnv = process.env as Record + delete testEnv.DATABASE_PROVIDER + delete testEnv.NODE_ENV + }) + + afterEach(() => { + process.env = originalEnv + }) + + it('uses sqlite when DATABASE_PROVIDER=sqlite', async () => { + process.env.DATABASE_PROVIDER = 'sqlite' + + const { getOperationalDb } = await import('./index') + const { createOperationalDb } = await import('./sqlite/repositories') + const { createSupabaseOperationalDb } = await import('./supabase-adapter') + + expect(getOperationalDb()).toBe(sqliteDb) + expect(createOperationalDb).toHaveBeenCalledTimes(1) + expect(createSupabaseOperationalDb).not.toHaveBeenCalled() + }) + + it('uses supabase when DATABASE_PROVIDER=supabase', async () => { + process.env.DATABASE_PROVIDER = 'supabase' + + const { getOperationalDb } = await import('./index') + const { createOperationalDb } = await import('./sqlite/repositories') + const { createSupabaseOperationalDb } = await import('./supabase-adapter') + + expect(getOperationalDb()).toBe(supabaseDb) + expect(createSupabaseOperationalDb).toHaveBeenCalledTimes(1) + expect(createOperationalDb).not.toHaveBeenCalled() + }) + + it('defaults tests to supabase when DATABASE_PROVIDER is unset', async () => { + ;(process.env as Record).NODE_ENV = 'test' + + const { getOperationalDb } = await import('./index') + const { createOperationalDb } = await import('./sqlite/repositories') + const { createSupabaseOperationalDb } = await import('./supabase-adapter') + + expect(getOperationalDb()).toBe(supabaseDb) + expect(createSupabaseOperationalDb).toHaveBeenCalledTimes(1) + expect(createOperationalDb).not.toHaveBeenCalled() + }) +}) diff --git a/zeude/dashboard/src/lib/db/index.ts b/zeude/dashboard/src/lib/db/index.ts index e82045e..f411dc9 100644 --- a/zeude/dashboard/src/lib/db/index.ts +++ b/zeude/dashboard/src/lib/db/index.ts @@ -4,9 +4,20 @@ import { createSupabaseOperationalDb } from './supabase-adapter' let cachedDb: OperationalDb | null = null +export function resolveOperationalDbProvider( + nodeEnv = process.env.NODE_ENV, + databaseProvider = process.env.DATABASE_PROVIDER +): 'sqlite' | 'supabase' { + if (databaseProvider === 'sqlite' || databaseProvider === 'supabase') { + return databaseProvider + } + + return nodeEnv === 'test' ? 'supabase' : 'sqlite' +} + export function getOperationalDb(): OperationalDb { if (!cachedDb) { - cachedDb = process.env.NODE_ENV === 'test' + cachedDb = resolveOperationalDbProvider() === 'supabase' ? createSupabaseOperationalDb() : createOperationalDb() } diff --git a/zeude/scripts/install-server.sh b/zeude/scripts/install-server.sh index a8aa459..e048336 100755 --- a/zeude/scripts/install-server.sh +++ b/zeude/scripts/install-server.sh @@ -48,6 +48,48 @@ run_shell() { bash -lc "$1" } +path_is_writable() { + local probe="$1" + while [ ! -e "$probe" ] && [ "$probe" != "/" ]; do + probe="$(dirname "$probe")" + done + [ -w "$probe" ] +} + +run_cmd_for_path() { + local path="$1" + shift + if [ "$DRY_RUN" = "1" ]; then + printf "${BLUE}[dry-run]${NC} %s\n" "$*" + return 0 + fi + + if [ "$(id -u)" -ne 0 ] && ! path_is_writable "$path"; then + need_cmd sudo + sudo "$@" + return 0 + fi + + "$@" +} + +run_shell_for_path() { + local path="$1" + local cmd="$2" + if [ "$DRY_RUN" = "1" ]; then + printf "${BLUE}[dry-run]${NC} %s\n" "$cmd" + return 0 + fi + + if [ "$(id -u)" -ne 0 ] && ! path_is_writable "$path"; then + need_cmd sudo + sudo bash -lc "$cmd" + return 0 + fi + + bash -lc "$cmd" +} + need_cmd() { if ! command -v "$1" >/dev/null 2>&1; then printf "${RED}Missing required command:${NC} %s\n" "$1" @@ -80,33 +122,36 @@ prepare_source_tree() { copy_local_repo() { printf "Copying local repository into %s... " "$APP_DIR" - run_cmd mkdir -p "$APP_DIR" - run_shell "rsync -a --delete --exclude '.git' --exclude 'node_modules' --exclude '.next' --exclude '.data' --exclude '.omc' '$REPO_ROOT/' '$APP_DIR/'" + run_cmd_for_path "$APP_DIR" mkdir -p "$APP_DIR" + run_shell_for_path "$APP_DIR" "rsync -a --delete --exclude '.git' --exclude 'node_modules' --exclude '.next' --exclude '.data' --exclude '.omc' '$REPO_ROOT/' '$APP_DIR/'" printf "${GREEN}OK${NC}\n" } clone_remote_repo() { printf "Fetching repository %s (%s)... " "$REPO_URL" "$GIT_REF" - run_cmd mkdir -p "$INSTALL_DIR" + run_cmd_for_path "$INSTALL_DIR" mkdir -p "$INSTALL_DIR" if [ "$DRY_RUN" = "1" ]; then printf "${GREEN}OK${NC}\n" return 0 fi - rm -rf "$APP_DIR" - git clone --depth 1 --branch "$GIT_REF" "$REPO_URL" "$APP_DIR" >/dev/null 2>&1 + run_cmd_for_path "$APP_DIR" rm -rf "$APP_DIR" + run_shell_for_path "$APP_DIR" "git clone --depth 1 --branch '$GIT_REF' '$REPO_URL' '$APP_DIR' >/dev/null 2>&1" printf "${GREEN}OK${NC}\n" } write_env_file() { local secret + local tmp_env secret="$(generate_secret)" printf "Writing env file... " - run_cmd mkdir -p "$CONFIG_DIR" "$DATA_DIR" + run_cmd_for_path "$CONFIG_DIR" mkdir -p "$CONFIG_DIR" + run_cmd_for_path "$DATA_DIR" mkdir -p "$DATA_DIR" if [ "$DRY_RUN" = "1" ]; then printf "${GREEN}OK${NC}\n" return 0 fi - cat > "$ENV_FILE" < "$tmp_env" < Date: Fri, 10 Apr 2026 21:37:13 +0900 Subject: [PATCH 8/9] Fail fast for unsupported Supabase runtime The Supabase operational adapter is incomplete and breaks auth, session, and config flows if DATABASE_PROVIDER=supabase is selected at runtime. Treat Supabase as migration-only, fail fast instead of booting into a broken provider path, and include the lint/test cleanup needed to keep the branch green. --- .../scripts/migrate-from-supabase.cjs | 1 + zeude/dashboard/scripts/migrate-sqlite.cjs | 1 + .../src/app/api/config/[agentKey]/route.ts | 4 +- .../charts/source-user-comparison.tsx | 46 +++++++++++-------- .../src/components/chat/chat-widget.tsx | 8 +--- zeude/dashboard/src/lib/database.types.ts | 4 +- zeude/dashboard/src/lib/db/index.test.ts | 21 ++------- zeude/dashboard/src/lib/db/index.ts | 17 +++---- .../src/lib/db/sqlite/repositories.ts | 12 ----- .../dashboard/src/lib/db/supabase-adapter.ts | 1 + 10 files changed, 49 insertions(+), 66 deletions(-) diff --git a/zeude/dashboard/scripts/migrate-from-supabase.cjs b/zeude/dashboard/scripts/migrate-from-supabase.cjs index 7aa437c..d868889 100644 --- a/zeude/dashboard/scripts/migrate-from-supabase.cjs +++ b/zeude/dashboard/scripts/migrate-from-supabase.cjs @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ const { createClient } = require('@supabase/supabase-js') const { ensureSqliteReady, resolveDatabasePath } = require('./migrate-sqlite.cjs') diff --git a/zeude/dashboard/scripts/migrate-sqlite.cjs b/zeude/dashboard/scripts/migrate-sqlite.cjs index 6c86f3f..38fa0da 100644 --- a/zeude/dashboard/scripts/migrate-sqlite.cjs +++ b/zeude/dashboard/scripts/migrate-sqlite.cjs @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ const fs = require('fs') const path = require('path') const Database = require('better-sqlite3') diff --git a/zeude/dashboard/src/app/api/config/[agentKey]/route.ts b/zeude/dashboard/src/app/api/config/[agentKey]/route.ts index b3f5d69..7f34b5b 100644 --- a/zeude/dashboard/src/app/api/config/[agentKey]/route.ts +++ b/zeude/dashboard/src/app/api/config/[agentKey]/route.ts @@ -1,4 +1,4 @@ -import { rateLimit, getClientIP } from '@/lib/rate-limit' +import { rateLimit } from '@/lib/rate-limit' import { createHash } from 'crypto' import { getOperationalDb } from '@/lib/db' @@ -100,7 +100,7 @@ export async function GET( for (const server of applicableServers) { // Use a sanitized name as the key (lowercase, replace spaces with dashes) - let baseKey = server.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '') + const baseKey = server.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '') // Handle name collisions by appending short UUID suffix let serverKey = baseKey diff --git a/zeude/dashboard/src/components/charts/source-user-comparison.tsx b/zeude/dashboard/src/components/charts/source-user-comparison.tsx index 4415be6..539b2d0 100644 --- a/zeude/dashboard/src/components/charts/source-user-comparison.tsx +++ b/zeude/dashboard/src/components/charts/source-user-comparison.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, memo } from 'react' +import type { ReactNode } from 'react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Table, @@ -31,6 +32,28 @@ function formatCurrency(num: number): string { type SortField = 'userName' | 'total' | 'claude' | 'codex' | 'cost' +interface SortHeaderProps { + field: SortField + children: ReactNode + isActive: boolean + sortDir: 'asc' | 'desc' + onToggle: (field: SortField) => void +} + +function SortHeader({ field, children, isActive, sortDir, onToggle }: SortHeaderProps) { + return ( + onToggle(field)} + > +
+ {children} + {isActive && {sortDir === 'asc' ? '↑' : '↓'}} +
+
+ ) +} + export const SourceUserComparison = memo(function SourceUserComparison({ data }: SourceUserComparisonProps) { const [sortField, setSortField] = useState('total') const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc') @@ -79,21 +102,6 @@ export const SourceUserComparison = memo(function SourceUserComparison({ data }: (u) => (u.claude_inputTokens + u.claude_outputTokens > 0) && (u.codex_inputTokens + u.codex_outputTokens > 0) ) - function SortHeader({ field, children }: { field: SortField; children: React.ReactNode }) { - const isActive = sortField === field - return ( - toggleSort(field)} - > -
- {children} - {isActive && {sortDir === 'asc' ? '↑' : '↓'}} -
-
- ) - } - return ( @@ -119,7 +127,7 @@ export const SourceUserComparison = memo(function SourceUserComparison({ data }: - User + User {/* Claude Code columns */} @@ -130,16 +138,16 @@ export const SourceUserComparison = memo(function SourceUserComparison({ data }: Codex - Total Cost + Total Cost {/* Claude sub-headers */} - Tokens + Tokens Requests Cost {/* Codex sub-headers */} - Tokens + Tokens Requests Cost diff --git a/zeude/dashboard/src/components/chat/chat-widget.tsx b/zeude/dashboard/src/components/chat/chat-widget.tsx index c32269c..d08d912 100644 --- a/zeude/dashboard/src/components/chat/chat-widget.tsx +++ b/zeude/dashboard/src/components/chat/chat-widget.tsx @@ -17,7 +17,6 @@ const QUICK_ACTIONS = [ export function ChatWidget() { const [isOpen, setIsOpen] = useState(false) - const [mounted, setMounted] = useState(false) const messagesEndRef = useRef(null) const { @@ -30,11 +29,6 @@ export function ChatWidget() { clearMessages, } = useChatState() - // Client-side only mounting for portal - useEffect(() => { - setMounted(true) - }, []) - // Auto-scroll to bottom on new messages useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) @@ -44,7 +38,7 @@ export function ChatWidget() { sendMessage(prompt) } - if (!mounted) return null + if (typeof document === 'undefined') return null const widget = (
diff --git a/zeude/dashboard/src/lib/database.types.ts b/zeude/dashboard/src/lib/database.types.ts index fc628f8..640f161 100644 --- a/zeude/dashboard/src/lib/database.types.ts +++ b/zeude/dashboard/src/lib/database.types.ts @@ -183,14 +183,14 @@ export interface Database { } } } - Views: {} + Views: Record Functions: { cleanup_expired_tokens: { Args: Record Returns: undefined } } - Enums: {} + Enums: Record } } diff --git a/zeude/dashboard/src/lib/db/index.test.ts b/zeude/dashboard/src/lib/db/index.test.ts index 67c59ee..b198fac 100644 --- a/zeude/dashboard/src/lib/db/index.test.ts +++ b/zeude/dashboard/src/lib/db/index.test.ts @@ -1,16 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const sqliteDb = { provider: 'sqlite' } -const supabaseDb = { provider: 'supabase' } vi.mock('./sqlite/repositories', () => ({ createOperationalDb: vi.fn(() => sqliteDb), })) -vi.mock('./supabase-adapter', () => ({ - createSupabaseOperationalDb: vi.fn(() => supabaseDb), -})) - describe('getOperationalDb', () => { const originalEnv = process.env @@ -32,34 +27,28 @@ describe('getOperationalDb', () => { const { getOperationalDb } = await import('./index') const { createOperationalDb } = await import('./sqlite/repositories') - const { createSupabaseOperationalDb } = await import('./supabase-adapter') expect(getOperationalDb()).toBe(sqliteDb) expect(createOperationalDb).toHaveBeenCalledTimes(1) - expect(createSupabaseOperationalDb).not.toHaveBeenCalled() }) - it('uses supabase when DATABASE_PROVIDER=supabase', async () => { + it('throws when DATABASE_PROVIDER=supabase', async () => { process.env.DATABASE_PROVIDER = 'supabase' const { getOperationalDb } = await import('./index') const { createOperationalDb } = await import('./sqlite/repositories') - const { createSupabaseOperationalDb } = await import('./supabase-adapter') - expect(getOperationalDb()).toBe(supabaseDb) - expect(createSupabaseOperationalDb).toHaveBeenCalledTimes(1) + expect(() => getOperationalDb()).toThrow('DATABASE_PROVIDER=supabase is no longer supported at runtime') expect(createOperationalDb).not.toHaveBeenCalled() }) - it('defaults tests to supabase when DATABASE_PROVIDER is unset', async () => { + it('defaults tests to sqlite when DATABASE_PROVIDER is unset', async () => { ;(process.env as Record).NODE_ENV = 'test' const { getOperationalDb } = await import('./index') const { createOperationalDb } = await import('./sqlite/repositories') - const { createSupabaseOperationalDb } = await import('./supabase-adapter') - expect(getOperationalDb()).toBe(supabaseDb) - expect(createSupabaseOperationalDb).toHaveBeenCalledTimes(1) - expect(createOperationalDb).not.toHaveBeenCalled() + expect(getOperationalDb()).toBe(sqliteDb) + expect(createOperationalDb).toHaveBeenCalledTimes(1) }) }) diff --git a/zeude/dashboard/src/lib/db/index.ts b/zeude/dashboard/src/lib/db/index.ts index f411dc9..87d2433 100644 --- a/zeude/dashboard/src/lib/db/index.ts +++ b/zeude/dashboard/src/lib/db/index.ts @@ -1,25 +1,26 @@ import type { OperationalDb } from './types' import { createOperationalDb } from './sqlite/repositories' -import { createSupabaseOperationalDb } from './supabase-adapter' let cachedDb: OperationalDb | null = null export function resolveOperationalDbProvider( - nodeEnv = process.env.NODE_ENV, databaseProvider = process.env.DATABASE_PROVIDER -): 'sqlite' | 'supabase' { - if (databaseProvider === 'sqlite' || databaseProvider === 'supabase') { +): 'sqlite' { + if (databaseProvider === 'supabase') { + throw new Error('DATABASE_PROVIDER=supabase is no longer supported at runtime. Supabase remains migration-only.') + } + + if (databaseProvider === 'sqlite') { return databaseProvider } - return nodeEnv === 'test' ? 'supabase' : 'sqlite' + return 'sqlite' } export function getOperationalDb(): OperationalDb { if (!cachedDb) { - cachedDb = resolveOperationalDbProvider() === 'supabase' - ? createSupabaseOperationalDb() - : createOperationalDb() + resolveOperationalDbProvider() + cachedDb = createOperationalDb() } return cachedDb } diff --git a/zeude/dashboard/src/lib/db/sqlite/repositories.ts b/zeude/dashboard/src/lib/db/sqlite/repositories.ts index 575338e..55867b2 100644 --- a/zeude/dashboard/src/lib/db/sqlite/repositories.ts +++ b/zeude/dashboard/src/lib/db/sqlite/repositories.ts @@ -5,7 +5,6 @@ import type { Hook, Invite, MCPServer, - Session, Skill, User, } from '@/lib/database.types' @@ -13,7 +12,6 @@ import type { HookInstallStatusRecord, McpInstallStatusRecord, OperationalDb, - SessionWithUser, UserListOptions, UserListResult, } from '../types' @@ -57,16 +55,6 @@ function mapUser(row: Record): User { } } -function mapSession(row: Record): Session { - return { - id: String(row.id), - token: String(row.token), - user_id: String(row.user_id), - expires_at: String(row.expires_at), - created_at: String(row.created_at), - } -} - function mapInvite(row: Record): Invite { return { id: String(row.id), diff --git a/zeude/dashboard/src/lib/db/supabase-adapter.ts b/zeude/dashboard/src/lib/db/supabase-adapter.ts index 86a01f5..5a35929 100644 --- a/zeude/dashboard/src/lib/db/supabase-adapter.ts +++ b/zeude/dashboard/src/lib/db/supabase-adapter.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars */ import { createServerClient } from '@/lib/supabase' import type { Agent, From 33f0a987fd8d0af1d040f2230b16fa6a53e4e9e4 Mon Sep 17 00:00:00 2001 From: jihyunzep Date: Sat, 11 Apr 2026 01:35:30 +0900 Subject: [PATCH 9/9] Make Supabase migration-only Remove the unused Supabase runtime adapter and the remaining DATABASE_PROVIDER runtime wiring so operational state runs on SQLite only. Document that existing Supabase-backed deployments must migrate to SQLite before upgrading, while keeping Supabase only as the source for migrate:supabase-to-sqlite. --- zeude/README.md | 4 + zeude/dashboard/.env.example | 1 - zeude/dashboard/Dockerfile | 2 - zeude/dashboard/README.md | 5 +- zeude/dashboard/docker-compose.yaml | 1 - zeude/dashboard/src/lib/db/index.test.ts | 22 +- zeude/dashboard/src/lib/db/index.ts | 15 - .../dashboard/src/lib/db/supabase-adapter.ts | 303 ------------------ zeude/dashboard/src/lib/env.ts | 3 +- 9 files changed, 12 insertions(+), 344 deletions(-) delete mode 100644 zeude/dashboard/src/lib/db/supabase-adapter.ts diff --git a/zeude/README.md b/zeude/README.md index 1f229f9..50dd106 100644 --- a/zeude/README.md +++ b/zeude/README.md @@ -57,6 +57,8 @@ When you run `claude` (or `codex`), the Zeude shim: ### 1. Deploy the dashboard +Zeude now supports SQLite as the only operational runtime database. Supabase is migration-only and is no longer supported as a live runtime backend. + ```bash cd dashboard cp .env.example .env.local @@ -158,6 +160,8 @@ SKIP_AUTH=true MOCK_API=true npm run dev ### Existing Supabase -> SQLite migration +If you have an existing Supabase-backed deployment, migrate it to SQLite before upgrading to this runtime model. After the migration, Zeude runs against SQLite for operational state. + ```bash cd dashboard SUPABASE_URL=... \ diff --git a/zeude/dashboard/.env.example b/zeude/dashboard/.env.example index 159614f..b292e9f 100644 --- a/zeude/dashboard/.env.example +++ b/zeude/dashboard/.env.example @@ -1,5 +1,4 @@ NODE_ENV=development -DATABASE_PROVIDER=sqlite DATABASE_PATH=.data/zeude.db ZEUDE_DATA_DIR=.data SESSION_SECRET=dev-session-secret diff --git a/zeude/dashboard/Dockerfile b/zeude/dashboard/Dockerfile index 4dfbafa..99fcdd8 100644 --- a/zeude/dashboard/Dockerfile +++ b/zeude/dashboard/Dockerfile @@ -11,7 +11,6 @@ COPY package*.json ./ RUN npm ci ENV NODE_ENV=production -ENV DATABASE_PROVIDER=sqlite ENV DATABASE_PATH=/tmp/zeude-build.db ENV SESSION_SECRET=build-session-secret ENV NEXT_PUBLIC_APP_URL=http://localhost:3000 @@ -50,7 +49,6 @@ EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" -ENV DATABASE_PROVIDER=sqlite ENV DATABASE_PATH=/var/lib/zeude/zeude.db CMD ["node", "server.js"] diff --git a/zeude/dashboard/README.md b/zeude/dashboard/README.md index c2e9f53..41f3f48 100644 --- a/zeude/dashboard/README.md +++ b/zeude/dashboard/README.md @@ -2,6 +2,8 @@ Operational state lives in SQLite. Analytics stay in ClickHouse. +SQLite is the only supported operational runtime database. Supabase is retained only as a one-time migration source. + ## Local development ```bash @@ -15,6 +17,8 @@ Default local SQLite path is `.data/zeude.db`. ## One-time migration from Supabase +Existing Supabase-backed deployments must migrate to SQLite before upgrading to this runtime model. + ```bash SUPABASE_URL=... \ SUPABASE_SERVICE_ROLE_KEY=... \ @@ -33,7 +37,6 @@ Use `--force` only if the target SQLite DB already has data and you intend to re `docker-compose.yaml` now assumes: -- `DATABASE_PROVIDER=sqlite` - `DATABASE_PATH=/var/lib/zeude/zeude.db` - a persistent volume mounted at `/var/lib/zeude` diff --git a/zeude/dashboard/docker-compose.yaml b/zeude/dashboard/docker-compose.yaml index 48f2cd2..1583440 100644 --- a/zeude/dashboard/docker-compose.yaml +++ b/zeude/dashboard/docker-compose.yaml @@ -7,7 +7,6 @@ services: - "3000:3000" environment: - NODE_ENV=production - - DATABASE_PROVIDER=sqlite - DATABASE_PATH=/var/lib/zeude/zeude.db - SESSION_SECRET=${SESSION_SECRET} - CLICKHOUSE_URL=${CLICKHOUSE_URL} diff --git a/zeude/dashboard/src/lib/db/index.test.ts b/zeude/dashboard/src/lib/db/index.test.ts index b198fac..63d4610 100644 --- a/zeude/dashboard/src/lib/db/index.test.ts +++ b/zeude/dashboard/src/lib/db/index.test.ts @@ -13,18 +13,13 @@ describe('getOperationalDb', () => { vi.resetModules() vi.clearAllMocks() process.env = { ...originalEnv } - const testEnv = process.env as Record - delete testEnv.DATABASE_PROVIDER - delete testEnv.NODE_ENV }) afterEach(() => { process.env = originalEnv }) - it('uses sqlite when DATABASE_PROVIDER=sqlite', async () => { - process.env.DATABASE_PROVIDER = 'sqlite' - + it('creates sqlite operational db', async () => { const { getOperationalDb } = await import('./index') const { createOperationalDb } = await import('./sqlite/repositories') @@ -32,22 +27,11 @@ describe('getOperationalDb', () => { expect(createOperationalDb).toHaveBeenCalledTimes(1) }) - it('throws when DATABASE_PROVIDER=supabase', async () => { - process.env.DATABASE_PROVIDER = 'supabase' - - const { getOperationalDb } = await import('./index') - const { createOperationalDb } = await import('./sqlite/repositories') - - expect(() => getOperationalDb()).toThrow('DATABASE_PROVIDER=supabase is no longer supported at runtime') - expect(createOperationalDb).not.toHaveBeenCalled() - }) - - it('defaults tests to sqlite when DATABASE_PROVIDER is unset', async () => { - ;(process.env as Record).NODE_ENV = 'test' - + it('caches the sqlite operational db instance', async () => { const { getOperationalDb } = await import('./index') const { createOperationalDb } = await import('./sqlite/repositories') + expect(getOperationalDb()).toBe(sqliteDb) expect(getOperationalDb()).toBe(sqliteDb) expect(createOperationalDb).toHaveBeenCalledTimes(1) }) diff --git a/zeude/dashboard/src/lib/db/index.ts b/zeude/dashboard/src/lib/db/index.ts index 87d2433..138f7d4 100644 --- a/zeude/dashboard/src/lib/db/index.ts +++ b/zeude/dashboard/src/lib/db/index.ts @@ -3,23 +3,8 @@ import { createOperationalDb } from './sqlite/repositories' let cachedDb: OperationalDb | null = null -export function resolveOperationalDbProvider( - databaseProvider = process.env.DATABASE_PROVIDER -): 'sqlite' { - if (databaseProvider === 'supabase') { - throw new Error('DATABASE_PROVIDER=supabase is no longer supported at runtime. Supabase remains migration-only.') - } - - if (databaseProvider === 'sqlite') { - return databaseProvider - } - - return 'sqlite' -} - export function getOperationalDb(): OperationalDb { if (!cachedDb) { - resolveOperationalDbProvider() cachedDb = createOperationalDb() } return cachedDb diff --git a/zeude/dashboard/src/lib/db/supabase-adapter.ts b/zeude/dashboard/src/lib/db/supabase-adapter.ts deleted file mode 100644 index 5a35929..0000000 --- a/zeude/dashboard/src/lib/db/supabase-adapter.ts +++ /dev/null @@ -1,303 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars */ -import { createServerClient } from '@/lib/supabase' -import type { - Agent, - Hook, - MCPServer, - Skill, - User, -} from '@/lib/database.types' -import type { - HookInstallStatusRecord, - McpInstallStatusRecord, - OperationalDb, - SessionWithUser, - UserListOptions, - UserListResult, -} from './types' - -function throwIfError(result: T) { - const error = result.error as { code?: string; message?: string } | undefined - if (error) { - throw error - } -} - -export function createSupabaseOperationalDb(): OperationalDb { - const supabase = createServerClient() - - return { - users: { - findByAgentKey: () => null, - findById: (id) => { - const query = supabase.from('zeude_users').select('id, email, name, team, role, status, invited_by, created_at, updated_at, agent_key, disabled_skills').eq('id', id).single() - return Promise.resolve(query).then((result: any) => { - if (result.error) return null - return result.data as User - }) as unknown as User | null - }, - findByEmail: () => null, - findBasicById: () => null, - listByIds: () => [], - listAll: () => { - const query = supabase.from('zeude_users').select('id, name, email, team, role, status, created_at, updated_at, disabled_skills, invited_by, agent_key').order('team') - return Promise.resolve(query).then((result: any) => { - throwIfError(result) - return (result.data ?? []) as User[] - }) as unknown as User[] - }, - listTeams: () => { - const query = supabase.from('zeude_users').select('team').order('team') - return Promise.resolve(query).then((result: any) => { - throwIfError(result) - return [...new Set((result.data ?? []).map((row: any) => row.team))] - }) as unknown as string[] - }, - listUsers: () => ({ users: [], total: 0 }), - create: (input) => { - throw new Error(`Not implemented in test adapter: users.create ${JSON.stringify(input)}`) - }, - updateById: (id, updates) => { - const query = supabase.from('zeude_users').update(updates).eq('id', id).select('id, email, name, team, role, status').single() - return Promise.resolve(query).then((result: any) => { - throwIfError(result) - return (result.data ?? null) as User | null - }) as unknown as User | null - }, - updateAgentKey: (id, agentKey) => { - const query = supabase.from('zeude_users').update({ agent_key: agentKey, updated_at: new Date().toISOString() }).eq('id', id).select('id, email, name, team, role, status, agent_key').single() - return Promise.resolve(query).then((result: any) => { - throwIfError(result) - return (result.data ?? null) as User | null - }) as unknown as User | null - }, - toggleDisabledSkill: () => [], - }, - sessions: { - findValidByTokenWithUser: () => null, - create: (input) => ({ - id: 'session-id', - token: input.token, - user_id: input.user_id, - expires_at: input.expires_at, - created_at: new Date().toISOString(), - }), - deleteByToken: () => undefined, - }, - tokens: { - create: () => undefined, - findValidByTokenWithUser: () => null, - deleteById: () => undefined, - }, - invites: { - findByToken: () => null, - create: (input) => { - const query = supabase.from('zeude_invites').insert(input).select().single() - return Promise.resolve(query).then((result: any) => { - throwIfError(result) - return result.data - }) as unknown as any - }, - listRecent: (limit = 50) => { - const query = supabase - .from('zeude_invites') - .select('id, token, team, role, created_by, expires_at, used_at, used_by, created_at') - .order('created_at', { ascending: false }) - .limit(limit) - return Promise.resolve(query).then((result: any) => { - throwIfError(result) - return result.data ?? [] - }) as unknown as any[] - }, - claimByToken: () => null, - resetClaim: () => undefined, - markUsedBy: () => undefined, - }, - skills: { - listActiveForTeam: () => [], - listAll: () => { - const query = supabase.from('zeude_skills').select('*').order('created_at', { ascending: false }) - return Promise.resolve(query).then((result: any) => { - throwIfError(result) - return (result.data ?? []) as Skill[] - }) as unknown as Skill[] - }, - listBySlugs: (slugs) => { - const query = supabase.from('zeude_skills').select('slug, description').in('slug', slugs) - return Promise.resolve(query).then((result: any) => { - throwIfError(result) - return result.data ?? [] - }) as unknown as Pick[] - }, - findById: (id) => { - const query = supabase.from('zeude_skills').select('created_by, contributors').eq('id', id).single() - return Promise.resolve(query).then((result: any) => { - if (result.error) return null - return result.data as Skill - }) as unknown as Skill | null - }, - findAccessibleActiveBySlug: () => null, - create: (input) => { - const query = supabase.from('zeude_skills').insert(input).select().single() - return Promise.resolve(query).then((result: any) => { - throwIfError(result) - return result.data as Skill - }) as unknown as Skill - }, - updateById: (id, updates) => { - const query = supabase.from('zeude_skills').update(updates).eq('id', id).select().single() - return Promise.resolve(query).then((result: any) => { - throwIfError(result) - return (result.data ?? null) as Skill | null - }) as unknown as Skill | null - }, - deleteById: (id) => { - const query = supabase.from('zeude_skills').delete().eq('id', id) - return Promise.resolve(query).then((result: any) => { - throwIfError(result) - return true - }) as unknown as boolean - }, - }, - hooks: { - listActiveForTeam: () => [], - listAll: () => { - const query = supabase.from('zeude_hooks').select('*').order('created_at', { ascending: false }) - return Promise.resolve(query).then((result: any) => { - throwIfError(result) - return (result.data ?? []) as Hook[] - }) as unknown as Hook[] - }, - findById: () => null, - create: (input) => { - const query = supabase.from('zeude_hooks').insert(input).select().single() - return Promise.resolve(query).then((result: any) => { - throwIfError(result) - return result.data as Hook - }) as unknown as Hook - }, - updateById: (id, updates) => { - const query = supabase.from('zeude_hooks').update(updates).eq('id', id).select().single() - return Promise.resolve(query).then((result: any) => { - throwIfError(result) - return (result.data ?? null) as Hook | null - }) as unknown as Hook | null - }, - deleteById: (id) => { - const query = supabase.from('zeude_hooks').delete().eq('id', id) - return Promise.resolve(query).then((result: any) => { - throwIfError(result) - return true - }) as unknown as boolean - }, - }, - mcp: { - listActiveForTeam: () => [], - listActiveNamesAndIds: () => [], - listAll: () => { - const query = supabase.from('zeude_mcp_servers').select('*').order('created_at', { ascending: false }) - return Promise.resolve(query).then((result: any) => { - throwIfError(result) - return (result.data ?? []) as MCPServer[] - }) as unknown as MCPServer[] - }, - findById: () => null, - create: (input) => { - const query = supabase.from('zeude_mcp_servers').insert(input).select().single() - return Promise.resolve(query).then((result: any) => { - throwIfError(result) - return result.data as MCPServer - }) as unknown as MCPServer - }, - updateById: (id, updates) => { - const query = supabase.from('zeude_mcp_servers').update(updates).eq('id', id).select().single() - return Promise.resolve(query).then((result: any) => { - throwIfError(result) - return (result.data ?? null) as MCPServer | null - }) as unknown as MCPServer | null - }, - deleteById: (id) => { - const query = supabase.from('zeude_mcp_servers').delete().eq('id', id) - return Promise.resolve(query).then((result: any) => { - throwIfError(result) - return true - }) as unknown as boolean - }, - }, - agents: { - listActiveForTeam: () => [], - listAll: () => { - const query = supabase.from('zeude_agents').select('*').order('created_at', { ascending: false }) - return Promise.resolve(query).then((result: any) => { - throwIfError(result) - return (result.data ?? []) as Agent[] - }) as unknown as Agent[] - }, - findById: () => null, - create: (input) => { - const query = supabase.from('zeude_agents').insert(input).select().single() - return Promise.resolve(query).then((result: any) => { - throwIfError(result) - return result.data as Agent - }) as unknown as Agent - }, - updateById: (id, updates) => { - const query = supabase.from('zeude_agents').update(updates).eq('id', id).select().single() - return Promise.resolve(query).then((result: any) => { - throwIfError(result) - return (result.data ?? null) as Agent | null - }) as unknown as Agent | null - }, - deleteById: (id) => { - const query = supabase.from('zeude_agents').delete().eq('id', id) - return Promise.resolve(query).then((result: any) => { - throwIfError(result) - return true - }) as unknown as boolean - }, - }, - installStatus: { - upsertMcpStatuses: (_statuses: McpInstallStatusRecord[]) => 0, - upsertHookStatuses: (_statuses: HookInstallStatusRecord[]) => 0, - listMcpStatuses: () => [], - listHookStatuses: () => [], - }, - cohorts: { - listMembers: (cohortKey) => { - const query = supabase - .from('zeude_cohort_members') - .select('user_id, created_at') - .eq('cohort_key', cohortKey) - .order('created_at', { ascending: true }) - return Promise.resolve(query).then((result: any) => { - throwIfError(result) - return result.data ?? [] - }) as unknown as any[] - }, - addMembers: (cohortKey, userIds, createdBy) => { - const rows = userIds.map(userId => ({ - cohort_key: cohortKey, - user_id: userId, - created_by: createdBy, - })) - const query = supabase - .from('zeude_cohort_members') - .upsert(rows, { onConflict: 'cohort_key,user_id', ignoreDuplicates: true, count: 'exact' }) - return Promise.resolve(query).then((result: any) => { - throwIfError(result) - return result.count ?? 0 - }) as unknown as number - }, - countMembers: (cohortKey) => { - const query = supabase - .from('zeude_cohort_members') - .select('id', { count: 'exact', head: true }) - .eq('cohort_key', cohortKey) - return Promise.resolve(query).then((result: any) => { - throwIfError(result) - return result.count ?? 0 - }) as unknown as number - }, - }, - } -} diff --git a/zeude/dashboard/src/lib/env.ts b/zeude/dashboard/src/lib/env.ts index e62abe6..64eebe6 100644 --- a/zeude/dashboard/src/lib/env.ts +++ b/zeude/dashboard/src/lib/env.ts @@ -3,11 +3,10 @@ import { z } from 'zod' const isProduction = process.env.NODE_ENV === 'production' const envSchema = z.object({ - DATABASE_PROVIDER: z.enum(['sqlite', 'supabase']).optional().default('sqlite'), DATABASE_PATH: z.string().optional().default(isProduction ? '/var/lib/zeude/zeude.db' : ''), SESSION_SECRET: z.string().optional().default(isProduction ? '' : 'dev-session-secret'), - // Supabase - migration only or compatibility fallback + // Supabase migration source SUPABASE_URL: z.string().optional().default(''), SUPABASE_ANON_KEY: z.string().optional().default(''), SUPABASE_SERVICE_ROLE_KEY: z.string().optional().default(''),