From c6120352f2bf14f8897239c076dd71bd28c5d7ee Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Sun, 15 Feb 2026 21:37:51 +0200 Subject: [PATCH 1/8] fix: refactor codebase --- .env.example | 18 +- .gitignore | 10 +- .prettierignore | 2 +- {.agency => .squadhub}/.gitkeep | 0 CLAUDE.md | 6 +- README.md | 24 +- apps/watcher/.env.example | 4 +- apps/watcher/Dockerfile | 2 +- apps/watcher/README.md | 10 +- apps/watcher/src/config.spec.ts | 24 +- apps/watcher/src/config.ts | 6 +- apps/watcher/src/index.ts | 344 ++++-------------- apps/web/CLAUDE.md | 8 +- .../src/app/(dashboard)/@header/default.tsx | 4 +- apps/web/src/app/(dashboard)/agents/page.tsx | 2 +- .../_components/agents-panel/agents-panel.tsx | 2 +- .../_components/business-settings-form.tsx | 2 +- .../general/_components/timezone-settings.tsx | 4 +- .../_components/telegram-integration-card.tsx | 10 +- .../_components/telegram-remove-dialog.tsx | 4 +- .../_components/telegram-setup-dialog.tsx | 16 +- apps/web/src/app/api/agency/health/route.ts | 7 - .../web/src/app/api/business/context/route.ts | 20 +- apps/web/src/app/api/chat/abort/route.spec.ts | 6 +- apps/web/src/app/api/chat/abort/route.ts | 5 +- .../src/app/api/chat/history/route.spec.ts | 6 +- apps/web/src/app/api/chat/history/route.ts | 7 +- apps/web/src/app/api/chat/route.ts | 18 +- apps/web/src/app/api/squadhub/health/route.ts | 8 + .../api/{agency => squadhub}/pairing/route.ts | 13 +- .../web/src/app/api/tenant/provision/route.ts | 37 ++ apps/web/src/app/page.tsx | 2 +- apps/web/src/app/setup/business/page.tsx | 2 +- apps/web/src/app/setup/complete/page.tsx | 2 +- apps/web/src/app/setup/layout.tsx | 6 +- apps/web/src/app/setup/telegram/page.tsx | 18 +- apps/web/src/app/setup/welcome/page.tsx | 12 +- apps/web/src/components/chat/chat-message.tsx | 2 +- ...{agency-status.tsx => squadhub-status.tsx} | 12 +- apps/web/src/hooks/use-onboarding-guard.ts | 4 +- ...gency-status.ts => use-squadhub-status.ts} | 14 +- .../lib/{agency => squadhub}/actions.spec.ts | 17 +- .../src/lib/{agency => squadhub}/actions.ts | 19 +- apps/web/src/lib/squadhub/connection.ts | 8 + apps/web/src/lib/squadhub/provision.ts | 215 +++++++++++ apps/web/vitest.config.ts | 4 +- docker-compose.override.yml | 6 +- docker-compose.yml | 26 +- docker/{agency => squadhub}/Dockerfile | 6 +- docker/{agency => squadhub}/entrypoint.sh | 4 +- .../scripts/init-agents.sh | 0 .../scripts/pair-device.js | 0 .../templates/config.template.json | 0 .../templates/shared/CLAWE-CLI.md | 0 .../templates/shared/WORKFLOW.md | 0 .../templates/shared/WORKING.md | 0 .../templates/workspaces/clawe/AGENTS.md | 0 .../templates/workspaces/clawe/BOOTSTRAP.md | 0 .../templates/workspaces/clawe/HEARTBEAT.md | 0 .../templates/workspaces/clawe/MEMORY.md | 0 .../templates/workspaces/clawe/SOUL.md | 0 .../templates/workspaces/clawe/TOOLS.md | 0 .../templates/workspaces/clawe/USER.md | 0 .../templates/workspaces/inky/AGENTS.md | 0 .../templates/workspaces/inky/HEARTBEAT.md | 0 .../templates/workspaces/inky/MEMORY.md | 0 .../templates/workspaces/inky/SOUL.md | 0 .../templates/workspaces/inky/TOOLS.md | 0 .../templates/workspaces/inky/USER.md | 0 .../templates/workspaces/pixel/AGENTS.md | 0 .../templates/workspaces/pixel/HEARTBEAT.md | 0 .../templates/workspaces/pixel/MEMORY.md | 0 .../templates/workspaces/pixel/SOUL.md | 0 .../templates/workspaces/pixel/TOOLS.md | 0 .../templates/workspaces/pixel/USER.md | 0 .../templates/workspaces/scout/AGENTS.md | 0 .../templates/workspaces/scout/HEARTBEAT.md | 0 .../templates/workspaces/scout/MEMORY.md | 0 .../templates/workspaces/scout/SOUL.md | 0 .../templates/workspaces/scout/TOOLS.md | 0 .../templates/workspaces/scout/USER.md | 0 package.json | 4 +- packages/backend/convex/accounts.ts | 64 ++++ packages/backend/convex/activities.ts | 90 +++-- packages/backend/convex/agents.ts | 123 ++++--- packages/backend/convex/businessContext.ts | 65 ++-- packages/backend/convex/channels.ts | 55 ++- packages/backend/convex/documents.ts | 87 +++-- packages/backend/convex/lib/auth.ts | 230 ++++++++++++ packages/backend/convex/messages.ts | 69 ++-- packages/backend/convex/notifications.ts | 82 +++-- packages/backend/convex/routines.ts | 67 +++- packages/backend/convex/schema.ts | 87 ++++- packages/backend/convex/settings.ts | 109 ------ packages/backend/convex/tasks.ts | 96 ++++- packages/backend/convex/tenants.ts | 68 ++++ packages/backend/convex/tsconfig.json | 1 + packages/backend/convex/users.ts | 85 +++++ packages/backend/package.json | 1 + packages/cli/src/client.spec.ts | 27 +- packages/cli/src/client.ts | 49 ++- .../cli/src/commands/agent-register.spec.ts | 18 +- packages/cli/src/commands/agent-register.ts | 4 +- packages/cli/src/commands/business-get.ts | 4 +- packages/cli/src/commands/business-set.ts | 10 +- packages/cli/src/commands/check.spec.ts | 26 +- packages/cli/src/commands/check.ts | 8 +- packages/cli/src/commands/deliver.spec.ts | 16 +- packages/cli/src/commands/deliver.ts | 6 +- packages/cli/src/commands/feed.spec.ts | 20 +- packages/cli/src/commands/feed.ts | 4 +- packages/cli/src/commands/notify.spec.ts | 16 +- packages/cli/src/commands/notify.ts | 4 +- packages/cli/src/commands/squad.spec.ts | 14 +- packages/cli/src/commands/squad.ts | 4 +- packages/cli/src/commands/subtask-add.spec.ts | 22 +- packages/cli/src/commands/subtask-add.ts | 4 +- .../cli/src/commands/subtask-check.spec.ts | 26 +- packages/cli/src/commands/subtask-check.ts | 10 +- packages/cli/src/commands/task-assign.spec.ts | 14 +- packages/cli/src/commands/task-assign.ts | 4 +- .../cli/src/commands/task-comment.spec.ts | 14 +- packages/cli/src/commands/task-comment.ts | 4 +- packages/cli/src/commands/task-create.spec.ts | 26 +- packages/cli/src/commands/task-create.ts | 4 +- packages/cli/src/commands/task-plan.spec.ts | 32 +- packages/cli/src/commands/task-plan.ts | 4 +- packages/cli/src/commands/task-status.spec.ts | 14 +- packages/cli/src/commands/task-status.ts | 4 +- packages/cli/src/commands/task-view.spec.ts | 18 +- packages/cli/src/commands/task-view.ts | 4 +- packages/cli/src/commands/tasks.spec.ts | 12 +- packages/cli/src/commands/tasks.ts | 4 +- packages/shared/package.json | 6 +- .../src/{agency => squadhub}/client.spec.ts | 22 +- .../shared/src/{agency => squadhub}/client.ts | 82 +++-- .../gateway-client.spec.ts | 36 +- .../{agency => squadhub}/gateway-client.ts | 16 +- .../src/{agency => squadhub}/gateway-types.ts | 0 .../shared/src/{agency => squadhub}/index.ts | 1 + .../src/{agency => squadhub}/pairing.ts | 14 +- .../src/{agency => squadhub}/shared-client.ts | 6 +- .../shared/src/{agency => squadhub}/types.ts | 6 +- pnpm-lock.yaml | 15 + scripts/start.sh | 6 +- turbo.json | 6 +- 146 files changed, 1923 insertions(+), 1134 deletions(-) rename {.agency => .squadhub}/.gitkeep (100%) delete mode 100644 apps/web/src/app/api/agency/health/route.ts create mode 100644 apps/web/src/app/api/squadhub/health/route.ts rename apps/web/src/app/api/{agency => squadhub}/pairing/route.ts (77%) create mode 100644 apps/web/src/app/api/tenant/provision/route.ts rename apps/web/src/components/{agency-status.tsx => squadhub-status.tsx} (85%) rename apps/web/src/hooks/{use-agency-status.ts => use-squadhub-status.ts} (63%) rename apps/web/src/lib/{agency => squadhub}/actions.spec.ts (84%) rename apps/web/src/lib/{agency => squadhub}/actions.ts (62%) create mode 100644 apps/web/src/lib/squadhub/connection.ts create mode 100644 apps/web/src/lib/squadhub/provision.ts rename docker/{agency => squadhub}/Dockerfile (84%) rename docker/{agency => squadhub}/entrypoint.sh (95%) rename docker/{agency => squadhub}/scripts/init-agents.sh (100%) rename docker/{agency => squadhub}/scripts/pair-device.js (100%) rename docker/{agency => squadhub}/templates/config.template.json (100%) rename docker/{agency => squadhub}/templates/shared/CLAWE-CLI.md (100%) rename docker/{agency => squadhub}/templates/shared/WORKFLOW.md (100%) rename docker/{agency => squadhub}/templates/shared/WORKING.md (100%) rename docker/{agency => squadhub}/templates/workspaces/clawe/AGENTS.md (100%) rename docker/{agency => squadhub}/templates/workspaces/clawe/BOOTSTRAP.md (100%) rename docker/{agency => squadhub}/templates/workspaces/clawe/HEARTBEAT.md (100%) rename docker/{agency => squadhub}/templates/workspaces/clawe/MEMORY.md (100%) rename docker/{agency => squadhub}/templates/workspaces/clawe/SOUL.md (100%) rename docker/{agency => squadhub}/templates/workspaces/clawe/TOOLS.md (100%) rename docker/{agency => squadhub}/templates/workspaces/clawe/USER.md (100%) rename docker/{agency => squadhub}/templates/workspaces/inky/AGENTS.md (100%) rename docker/{agency => squadhub}/templates/workspaces/inky/HEARTBEAT.md (100%) rename docker/{agency => squadhub}/templates/workspaces/inky/MEMORY.md (100%) rename docker/{agency => squadhub}/templates/workspaces/inky/SOUL.md (100%) rename docker/{agency => squadhub}/templates/workspaces/inky/TOOLS.md (100%) rename docker/{agency => squadhub}/templates/workspaces/inky/USER.md (100%) rename docker/{agency => squadhub}/templates/workspaces/pixel/AGENTS.md (100%) rename docker/{agency => squadhub}/templates/workspaces/pixel/HEARTBEAT.md (100%) rename docker/{agency => squadhub}/templates/workspaces/pixel/MEMORY.md (100%) rename docker/{agency => squadhub}/templates/workspaces/pixel/SOUL.md (100%) rename docker/{agency => squadhub}/templates/workspaces/pixel/TOOLS.md (100%) rename docker/{agency => squadhub}/templates/workspaces/pixel/USER.md (100%) rename docker/{agency => squadhub}/templates/workspaces/scout/AGENTS.md (100%) rename docker/{agency => squadhub}/templates/workspaces/scout/HEARTBEAT.md (100%) rename docker/{agency => squadhub}/templates/workspaces/scout/MEMORY.md (100%) rename docker/{agency => squadhub}/templates/workspaces/scout/SOUL.md (100%) rename docker/{agency => squadhub}/templates/workspaces/scout/TOOLS.md (100%) rename docker/{agency => squadhub}/templates/workspaces/scout/USER.md (100%) create mode 100644 packages/backend/convex/accounts.ts create mode 100644 packages/backend/convex/lib/auth.ts delete mode 100644 packages/backend/convex/settings.ts create mode 100644 packages/backend/convex/tenants.ts create mode 100644 packages/backend/convex/users.ts rename packages/shared/src/{agency => squadhub}/client.spec.ts (89%) rename packages/shared/src/{agency => squadhub}/client.ts (72%) rename packages/shared/src/{agency => squadhub}/gateway-client.spec.ts (67%) rename packages/shared/src/{agency => squadhub}/gateway-client.ts (94%) rename packages/shared/src/{agency => squadhub}/gateway-types.ts (100%) rename packages/shared/src/{agency => squadhub}/index.ts (98%) rename packages/shared/src/{agency => squadhub}/pairing.ts (91%) rename packages/shared/src/{agency => squadhub}/shared-client.ts (86%) rename packages/shared/src/{agency => squadhub}/types.ts (87%) diff --git a/.env.example b/.env.example index 135c037..c3204c3 100644 --- a/.env.example +++ b/.env.example @@ -13,9 +13,9 @@ ANTHROPIC_API_KEY=sk-ant-... # Apps reference this as NEXT_PUBLIC_CONVEX_URL CONVEX_URL=https://your-deployment.convex.cloud -# Agency gateway authentication token +# SquadHub gateway authentication token # Auto-generated by scripts/start.sh, or set your own secure random string -AGENCY_TOKEN=your-secure-token-here +SQUADHUB_TOKEN=your-secure-token-here # ============================================================================= # OPTIONAL @@ -27,16 +27,16 @@ AGENCY_TOKEN=your-secure-token-here # Environment: dev or prod ENVIRONMENT=dev -# Agency gateway URL +# SquadHub gateway URL # Development: http://localhost:18790 (Docker exposed on host) -# Production: http://agency:18789 (Docker internal network) -AGENCY_URL=http://localhost:18790 +# Production: http://squadhub:18789 (Docker internal network) +SQUADHUB_URL=http://localhost:18790 # ============================================================================= # ADVANCED (usually don't need to change) # ============================================================================= -# Agency state directory (path to config dir, mounted from Docker) -# Development: ./.agency/config (relative to project root) -# Production: /agency-data/config (shared Docker volume) -AGENCY_STATE_DIR=./.agency/config +# SquadHub state directory (path to config dir, mounted from Docker) +# Development: ./.squadhub/config (relative to project root) +# Production: /squadhub-data/config (shared Docker volume) +SQUADHUB_STATE_DIR=./.squadhub/config diff --git a/.gitignore b/.gitignore index 9885419..89b056f 100644 --- a/.gitignore +++ b/.gitignore @@ -49,8 +49,8 @@ convex/_generated # Local data directory (Clawe config) .data/ -# Agency state directory (shared with Docker in dev) -.agency/* -!.agency/.gitkeep -.agency/logs/* -!.agency/logs/.gitkeep \ No newline at end of file +# SquadHub state directory (shared with Docker in dev) +.squadhub/* +!.squadhub/.gitkeep +.squadhub/logs/* +!.squadhub/logs/.gitkeep \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index f76797d..87ca534 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,2 @@ # Template files with shell variable substitution -docker/agency/templates/*.json +docker/squadhub/templates/*.json diff --git a/.agency/.gitkeep b/.squadhub/.gitkeep similarity index 100% rename from .agency/.gitkeep rename to .squadhub/.gitkeep diff --git a/CLAUDE.md b/CLAUDE.md index 772beab..1640422 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,9 +54,9 @@ Core models: `agents`, `tasks`, `messages` (see `packages/backend/convex/schema. ## Environment Variables - `NEXT_PUBLIC_CONVEX_URL`: Convex deployment URL (required) -- `ANTHROPIC_API_KEY`: Anthropic API key for AI operations (required, passed to agency) -- `AGENCY_TOKEN`: Authentication token for agency gateway (required) -- `AGENCY_URL`: Agency gateway URL (set in `.env.development` / `.env.production`) +- `ANTHROPIC_API_KEY`: Anthropic API key for AI operations (required, passed to squadhub) +- `SQUADHUB_TOKEN`: Authentication token for squadhub gateway (required) +- `SQUADHUB_URL`: SquadHub gateway URL (set in `.env.development` / `.env.production`) - `NODE_ENV`: local (`development`) vs deployed (`production`) — controls dev tooling - `ENVIRONMENT`: deployment target (`dev` / `prod`) — controls feature flags diff --git a/README.md b/README.md index 728dc60..dfa3185 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Edit `.env`: ```bash # Required ANTHROPIC_API_KEY=sk-ant-... -AGENCY_TOKEN=your-secure-token +SQUADHUB_TOKEN=your-secure-token CONVEX_URL=https://your-deployment.convex.cloud # Optional @@ -71,7 +71,7 @@ npx convex deploy This script will: - Create `.env` from `.env.example` if missing -- Auto-generate a secure `AGENCY_TOKEN` +- Auto-generate a secure `SQUADHUB_TOKEN` - Validate all required environment variables - Build necessary packages - Start the Docker containers @@ -79,7 +79,7 @@ This script will: **Development:** ```bash -# Start agency gateway only (use local web dev server) +# Start squadhub gateway only (use local web dev server) pnpm dev:docker # In another terminal, start web + Convex @@ -88,7 +88,7 @@ pnpm dev The production stack starts: -- **agency**: Gateway running all agents +- **squadhub**: Gateway running all agents - **watcher**: Notification delivery + cron setup - **clawe**: Web dashboard at http://localhost:3000 @@ -120,7 +120,7 @@ Schedule recurring tasks that automatically create inbox items: ┌─────────────────────────────────────────────────────────────┐ │ DOCKER COMPOSE │ ├─────────────────┬─────────────────────┬─────────────────────┤ -│ agency │ watcher │ clawe │ +│ squadhub │ watcher │ clawe │ │ │ │ │ │ Agent Gateway │ • Register agents │ Web Dashboard │ │ with 4 agents │ • Setup crons │ • Squad status │ @@ -151,10 +151,10 @@ clawe/ ├── packages/ │ ├── backend/ # Convex schema & functions │ ├── cli/ # `clawe` CLI for agents -│ ├── shared/ # Shared agency client +│ ├── shared/ # Shared squadhub client │ └── ui/ # UI components └── docker/ - └── agency/ + └── squadhub/ ├── Dockerfile ├── entrypoint.sh ├── scripts/ # init-agents.sh @@ -221,8 +221,8 @@ Each agent has an isolated workspace with: ### Adding New Agents -1. Create workspace template in `docker/agency/templates/workspaces/{name}/` -2. Add agent to `docker/agency/templates/config.template.json` +1. Create workspace template in `docker/squadhub/templates/workspaces/{name}/` +2. Add agent to `docker/squadhub/templates/config.template.json` 3. Add agent to watcher's `AGENTS` array in `apps/watcher/src/index.ts` 4. Rebuild: `docker compose build && docker compose up -d` @@ -252,13 +252,13 @@ pnpm install # Terminal 1: Start Convex dev server pnpm convex:dev -# Terminal 2: Start agency gateway in Docker +# Terminal 2: Start squadhub gateway in Docker pnpm dev:docker # Terminal 3: Start web dashboard pnpm dev:web -# Or run everything together (Convex + web, but not agency) +# Or run everything together (Convex + web, but not squadhub) pnpm dev ``` @@ -284,6 +284,6 @@ pnpm convex:deploy | Variable | Required | Description | | ------------------- | -------- | --------------------------------- | | `ANTHROPIC_API_KEY` | Yes | Anthropic API key for Claude | -| `AGENCY_TOKEN` | Yes | Auth token for agency gateway | +| `SQUADHUB_TOKEN` | Yes | Auth token for squadhub gateway | | `CONVEX_URL` | Yes | Convex deployment URL | | `OPENAI_API_KEY` | No | OpenAI key (for image generation) | diff --git a/apps/watcher/.env.example b/apps/watcher/.env.example index 34d504a..e2b07ef 100644 --- a/apps/watcher/.env.example +++ b/apps/watcher/.env.example @@ -4,5 +4,5 @@ # Required variables (from root .env): # - CONVEX_URL -# - AGENCY_URL -# - AGENCY_TOKEN +# - SQUADHUB_URL +# - SQUADHUB_TOKEN diff --git a/apps/watcher/Dockerfile b/apps/watcher/Dockerfile index 32158e8..b7e2571 100644 --- a/apps/watcher/Dockerfile +++ b/apps/watcher/Dockerfile @@ -8,7 +8,7 @@ WORKDIR /app # Watcher COPY apps/watcher/dist/ ./dist/ -# Shared package (agency client) +# Shared package (squadhub client) COPY packages/shared/package.json ./node_modules/@clawe/shared/package.json COPY packages/shared/dist/ ./node_modules/@clawe/shared/dist/ diff --git a/apps/watcher/README.md b/apps/watcher/README.md index fba4570..c1ade99 100644 --- a/apps/watcher/README.md +++ b/apps/watcher/README.md @@ -18,8 +18,8 @@ This enables: | Variable | Required | Description | | -------------- | -------- | --------------------------- | | `CONVEX_URL` | Yes | Convex deployment URL | -| `AGENCY_URL` | Yes | Agency gateway URL | -| `AGENCY_TOKEN` | Yes | Agency authentication token | +| `SQUADHUB_URL` | Yes | Squadhub gateway URL | +| `SQUADHUB_TOKEN` | Yes | Squadhub authentication token | ## Running @@ -53,7 +53,7 @@ Schedules are staggered to avoid rate limits. │ │ │ ┌─────────────┐ │ │ │ On Startup │──> Check/create heartbeat crons │ -│ └─────────────┘ via agency cron API │ +│ └─────────────┘ via squadhub cron API │ │ │ │ ┌─────────────┐ ┌─────────────────────────┐ │ │ │ Poll Loop │───────>│ convex.query( │ │ @@ -62,13 +62,13 @@ Schedules are staggered to avoid rate limits. │ │ └─────────────────────────┘ │ │ │ │ │ │ ┌─────────────────────────┐ │ -│ └──────────────>│ agency.sessionsSend() │ │ +│ └──────────────>│ squadhub.sessionsSend() │ │ │ └─────────────────────────┘ │ └─────────────────────────────────────────────────────────┘ │ │ ▼ ▼ ┌───────────┐ ┌───────────────┐ - │ CONVEX │ │ AGENCY │ + │ CONVEX │ │ SQUADHUB │ │ (data) │ │ (delivery) │ └───────────┘ └───────────────┘ ``` diff --git a/apps/watcher/src/config.spec.ts b/apps/watcher/src/config.spec.ts index 89e9d1b..f31c5eb 100644 --- a/apps/watcher/src/config.spec.ts +++ b/apps/watcher/src/config.spec.ts @@ -15,8 +15,8 @@ describe("config", () => { describe("validateEnv", () => { it("exits when CONVEX_URL is missing", async () => { delete process.env.CONVEX_URL; - process.env.AGENCY_URL = "http://localhost:18789"; - process.env.AGENCY_TOKEN = "test-token"; + process.env.SQUADHUB_URL = "http://localhost:18789"; + process.env.SQUADHUB_TOKEN = "test-token"; const mockExit = vi .spyOn(process, "exit") @@ -35,10 +35,10 @@ describe("config", () => { mockError.mockRestore(); }); - it("exits when AGENCY_URL is missing", async () => { + it("exits when SQUADHUB_URL is missing", async () => { process.env.CONVEX_URL = "https://test.convex.cloud"; - delete process.env.AGENCY_URL; - process.env.AGENCY_TOKEN = "test-token"; + delete process.env.SQUADHUB_URL; + process.env.SQUADHUB_TOKEN = "test-token"; const mockExit = vi .spyOn(process, "exit") @@ -49,7 +49,7 @@ describe("config", () => { validateEnv(); expect(mockError).toHaveBeenCalledWith( - expect.stringContaining("AGENCY_URL"), + expect.stringContaining("SQUADHUB_URL"), ); expect(mockExit).toHaveBeenCalledWith(1); @@ -59,8 +59,8 @@ describe("config", () => { it("does not exit when all required vars are set", async () => { process.env.CONVEX_URL = "https://test.convex.cloud"; - process.env.AGENCY_URL = "http://localhost:18789"; - process.env.AGENCY_TOKEN = "test-token"; + process.env.SQUADHUB_URL = "http://localhost:18789"; + process.env.SQUADHUB_TOKEN = "test-token"; const mockExit = vi .spyOn(process, "exit") @@ -78,14 +78,14 @@ describe("config", () => { describe("config object", () => { it("has correct default values", async () => { process.env.CONVEX_URL = "https://test.convex.cloud"; - process.env.AGENCY_URL = "http://custom:8080"; - process.env.AGENCY_TOKEN = "my-token"; + process.env.SQUADHUB_URL = "http://custom:8080"; + process.env.SQUADHUB_TOKEN = "my-token"; const { config } = await import("./config.js"); expect(config.convexUrl).toBe("https://test.convex.cloud"); - expect(config.agencyUrl).toBe("http://custom:8080"); - expect(config.agencyToken).toBe("my-token"); + expect(config.squadhubUrl).toBe("http://custom:8080"); + expect(config.squadhubToken).toBe("my-token"); }); }); diff --git a/apps/watcher/src/config.ts b/apps/watcher/src/config.ts index 871275c..89079c7 100644 --- a/apps/watcher/src/config.ts +++ b/apps/watcher/src/config.ts @@ -4,7 +4,7 @@ export const POLL_INTERVAL_MS = 2000; // Check every 2 seconds // Environment validation export function validateEnv(): void { - const required = ["CONVEX_URL", "AGENCY_URL", "AGENCY_TOKEN"]; + const required = ["CONVEX_URL", "SQUADHUB_URL", "SQUADHUB_TOKEN"]; const missing = required.filter((key) => !process.env[key]); if (missing.length > 0) { @@ -17,7 +17,7 @@ export function validateEnv(): void { export const config = { convexUrl: process.env.CONVEX_URL || "", - agencyUrl: process.env.AGENCY_URL || "http://localhost:18789", - agencyToken: process.env.AGENCY_TOKEN || "", + squadhubUrl: process.env.SQUADHUB_URL || "http://localhost:18789", + squadhubToken: process.env.SQUADHUB_TOKEN || "", pollIntervalMs: POLL_INTERVAL_MS, }; diff --git a/apps/watcher/src/index.ts b/apps/watcher/src/index.ts index 2323f9e..7eb5035 100644 --- a/apps/watcher/src/index.ts +++ b/apps/watcher/src/index.ts @@ -1,25 +1,27 @@ /** * Clawe Notification Watcher * - * 1. On startup: ensures heartbeat crons are configured for all agents - * 2. Continuously: polls Convex for undelivered notifications and delivers them + * Continuously polls Convex for undelivered notifications and delivers them. + * Also checks for due routines and triggers them. + * + * Setup logic (agent registration, cron setup, routine seeding) has been + * moved to the provisioning API route (POST /api/tenant/provision). + * + * Multi-tenant ready: iterates over active tenants each loop iteration. + * Currently falls back to single-tenant mode using SQUADHUB_URL/SQUADHUB_TOKEN env vars. * * Environment variables: * CONVEX_URL - Convex deployment URL - * AGENCY_URL - Agency gateway URL - * AGENCY_TOKEN - Agency authentication token + * SQUADHUB_URL - Squadhub gateway URL (single-tenant fallback) + * SQUADHUB_TOKEN - Squadhub authentication token (single-tenant fallback) */ import { ConvexHttpClient } from "convex/browser"; import { api } from "@clawe/backend"; -import type { Doc } from "@clawe/backend/dataModel"; import { sessionsSend, - cronList, - cronAdd, - type CronAddJob, - type CronJob, -} from "@clawe/shared/agency"; + type SquadhubConnection, +} from "@clawe/shared/squadhub"; import { getTimeInZone, DEFAULT_TIMEZONE } from "@clawe/shared/timezone"; import { validateEnv, config, POLL_INTERVAL_MS } from "./config.js"; @@ -28,262 +30,41 @@ validateEnv(); const convex = new ConvexHttpClient(config.convexUrl); -// Agent configuration -const AGENTS = [ - { - id: "main", - name: "Clawe", - emoji: "🦞", - role: "Squad Lead", - cron: "0,15,30,45 * * * *", - }, - { - id: "inky", - name: "Inky", - emoji: "✍️", - role: "Writer", - cron: "3,18,33,48 * * * *", - }, - { - id: "pixel", - name: "Pixel", - emoji: "🎨", - role: "Designer", - cron: "7,22,37,52 * * * *", - }, - { - id: "scout", - name: "Scout", - emoji: "🔍", - role: "SEO", - cron: "11,26,41,56 * * * *", - }, -]; - -const HEARTBEAT_MESSAGE = - "Read HEARTBEAT.md and follow it strictly. Check for notifications with 'clawe check'. If nothing needs attention, reply HEARTBEAT_OK."; - -// Input type for creating a routine (fields required by routines.create mutation) -type RoutineInput = Pick< - Doc<"routines">, - "title" | "description" | "priority" | "schedule" | "color" ->; - -// Routine seed data (hardcoded for initial setup) -const SEED_ROUTINES: RoutineInput[] = [ - { - title: "Weekly Performance Review", - description: - "Review last week's content performance, engagement metrics, and campaign results. Identify top-performing pieces and areas for improvement.", - priority: "normal", - schedule: { - type: "weekly", - daysOfWeek: [1], - hour: 9, - minute: 0, - }, - color: "emerald", - }, - { - title: "Morning Brief", - description: "Prepare daily morning brief for the team", - priority: "high", - schedule: { - type: "weekly", - daysOfWeek: [0, 1, 2, 3, 4, 5, 6], - hour: 8, - minute: 0, - }, - color: "amber", - }, - { - title: "Competitor Scan", - description: "Scan competitor activities and updates", - priority: "normal", - schedule: { - type: "weekly", - daysOfWeek: [1, 4], - hour: 10, - minute: 0, - }, - color: "rose", - }, -]; - -const RETRY_BASE_DELAY_MS = 3000; -const RETRY_MAX_DELAY_MS = 30000; -const ROUTINE_CHECK_INTERVAL_MS = 10_000; // Check routines every 10 seconds - -/** - * Sleep helper - */ -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** - * Retry a function indefinitely with exponential backoff (capped) - */ -async function withRetry( - fn: () => Promise, - label: string, - baseDelayMs = RETRY_BASE_DELAY_MS, -): Promise { - let attempt = 0; - - while (true) { - attempt++; - try { - return await fn(); - } catch (err) { - const error = err instanceof Error ? err : new Error(String(err)); - const delayMs = Math.min(baseDelayMs * attempt, RETRY_MAX_DELAY_MS); - - console.log( - `[watcher] ${label} failed (attempt ${attempt}), retrying in ${delayMs / 1000}s... (${error.message})`, - ); - await sleep(delayMs); - } - } -} - /** - * Register all agents in Convex (upsert - creates or updates) + * Represents an active tenant for the watcher to service. */ -async function registerAgents(): Promise { - console.log("[watcher] Registering agents in Convex..."); - console.log("[watcher] CONVEX_URL:", config.convexUrl); - - // Try to register first agent with retry (waits for Convex to be ready) - const firstAgent = AGENTS[0]; - if (firstAgent) { - await withRetry(async () => { - const sessionKey = `agent:${firstAgent.id}:main`; - await convex.mutation(api.agents.upsert, { - name: firstAgent.name, - role: firstAgent.role, - sessionKey, - emoji: firstAgent.emoji, - }); - console.log( - `[watcher] ✓ ${firstAgent.name} ${firstAgent.emoji} registered (${sessionKey})`, - ); - }, "Convex connection"); - } - - // Register remaining agents (Convex is now ready) - for (const agent of AGENTS.slice(1)) { - const sessionKey = `agent:${agent.id}:main`; - - try { - await convex.mutation(api.agents.upsert, { - name: agent.name, - role: agent.role, - sessionKey, - emoji: agent.emoji, - }); - console.log( - `[watcher] ✓ ${agent.name} ${agent.emoji} registered (${sessionKey})`, - ); - } catch (err) { - console.error( - `[watcher] Failed to register ${agent.name}:`, - err instanceof Error ? err.message : err, - ); - } - } - - console.log("[watcher] Agent registration complete.\n"); -} +type TenantInfo = { + id: string; + connection: SquadhubConnection; +}; /** - * Setup heartbeat crons for all agents (if not already configured) + * Get the list of active tenants to service. + * + * TODO (Phase 2): Query Convex `tenants.listActive` with WATCHER_TOKEN + * to get all active tenants with their squadhubUrl + squadhubToken. + * + * For now, falls back to single-tenant mode using env vars. */ -async function setupCrons(): Promise { - console.log("[watcher] Checking heartbeat crons..."); - - // Retry getting cron list (waits for agency to be ready) - const result = await withRetry(async () => { - const res = await cronList(); - if (!res.ok) { - throw new Error(res.error?.message ?? "Failed to list crons"); - } - return res; - }, "Agency connection"); - - const existingNames = new Set( - result.result.details.jobs.map((j: CronJob) => j.name), - ); - - for (const agent of AGENTS) { - const cronName = `${agent.id}-heartbeat`; - - if (existingNames.has(cronName)) { - console.log(`[watcher] ✓ ${agent.name} ${agent.emoji} heartbeat exists`); - continue; - } - - console.log(`[watcher] Adding ${agent.name} ${agent.emoji} heartbeat...`); - - const job: CronAddJob = { - name: cronName, - agentId: agent.id, - enabled: true, - schedule: { kind: "cron", expr: agent.cron }, - sessionTarget: "isolated", - payload: { - kind: "agentTurn", - message: HEARTBEAT_MESSAGE, - model: "anthropic/claude-sonnet-4-20250514", - timeoutSeconds: 600, +async function getActiveTenants(): Promise { + return [ + { + id: "default", + connection: { + squadhubUrl: config.squadhubUrl, + squadhubToken: config.squadhubToken, }, - delivery: { mode: "none" }, - }; - - const addResult = await cronAdd(job); - if (addResult.ok) { - console.log( - `[watcher] ✓ ${agent.name} ${agent.emoji} heartbeat: ${agent.cron}`, - ); - } else { - console.error( - `[watcher] Failed to add ${cronName}:`, - addResult.error?.message, - ); - } - } - - console.log("[watcher] Cron setup complete.\n"); + }, + ]; } +const ROUTINE_CHECK_INTERVAL_MS = 10_000; // Check routines every 10 seconds + /** - * Seed initial routines if none exist + * Sleep helper */ -async function seedRoutines(): Promise { - console.log("[watcher] Checking routines..."); - - const existing = await convex.query(api.routines.list, {}); - - if (existing.length > 0) { - console.log(`[watcher] ✓ ${existing.length} routine(s) already exist`); - return; - } - - console.log("[watcher] Seeding initial routines..."); - - for (const routine of SEED_ROUTINES) { - try { - await convex.mutation(api.routines.create, routine); - console.log(`[watcher] ✓ Created routine: ${routine.title}`); - } catch (err) { - console.error( - `[watcher] Failed to create routine "${routine.title}":`, - err instanceof Error ? err.message : err, - ); - } - } - - console.log("[watcher] Routine seeding complete.\n"); +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); } /** @@ -291,12 +72,15 @@ async function seedRoutines(): Promise { * * Uses a 1-hour window for crash tolerance: if a routine is scheduled * for 6:00 AM, it can trigger anytime between 6:00 AM and 6:59 AM. + * */ async function checkRoutines(): Promise { try { - // Get user's timezone from settings + // Get tenant's timezone from tenant settings const timezone = - (await convex.query(api.settings.getTimezone)) ?? DEFAULT_TIMEZONE; + (await convex.query(api.tenants.getTimezone, { + machineToken: config.squadhubToken, + })) ?? DEFAULT_TIMEZONE; // Get current timestamp and time in user's timezone const now = new Date(); @@ -305,6 +89,7 @@ async function checkRoutines(): Promise { // Query for due routines (with 1-hour window tolerance) const dueRoutines = await convex.query(api.routines.getDueRoutines, { + machineToken: config.squadhubToken, currentTimestamp, dayOfWeek, hour, @@ -315,6 +100,7 @@ async function checkRoutines(): Promise { for (const routine of dueRoutines) { try { const taskId = await convex.mutation(api.routines.trigger, { + machineToken: config.squadhubToken, routineId: routine._id, }); console.log( @@ -363,12 +149,16 @@ function formatNotification(notification: { } /** - * Deliver notifications to a single agent + * Deliver notifications to a single agent via the tenant's squadhub */ -async function deliverToAgent(sessionKey: string): Promise { +async function deliverToAgent( + connection: SquadhubConnection, + sessionKey: string, +): Promise { try { // Get undelivered notifications for this agent const notifications = await convex.query(api.notifications.getUndelivered, { + machineToken: config.squadhubToken, sessionKey, }); @@ -385,12 +175,13 @@ async function deliverToAgent(sessionKey: string): Promise { // Format the notification message const message = formatNotification(notification); - // Try to deliver to agent session - const result = await sessionsSend(sessionKey, message, 10); + // Try to deliver to agent session via tenant's squadhub + const result = await sessionsSend(connection, sessionKey, message, 10); if (result.ok) { // Mark as delivered in Convex await convex.mutation(api.notifications.markDelivered, { + machineToken: config.squadhubToken, notificationIds: [notification._id], }); @@ -419,15 +210,21 @@ async function deliverToAgent(sessionKey: string): Promise { } /** - * Main delivery loop + * Main delivery loop — iterates over all active tenants */ async function deliveryLoop(): Promise { - // Get all registered agents from Convex - const agents = await convex.query(api.agents.list, {}); + const tenants = await getActiveTenants(); + + for (const tenant of tenants) { + // Get all registered agents for this tenant from Convex + const agents = await convex.query(api.agents.list, { + machineToken: tenant.connection.squadhubToken, + }); - for (const agent of agents) { - if (agent.sessionKey) { - await deliverToAgent(agent.sessionKey); + for (const agent of agents) { + if (agent.sessionKey) { + await deliverToAgent(tenant.connection, agent.sessionKey); + } } } } @@ -476,21 +273,12 @@ async function startDeliveryLoop(): Promise { async function main(): Promise { console.log("[watcher] 🦞 Clawe Watcher starting..."); console.log(`[watcher] Convex: ${config.convexUrl}`); - console.log(`[watcher] Agency: ${config.agencyUrl}`); + console.log(`[watcher] Squadhub: ${config.squadhubUrl}`); console.log(`[watcher] Notification poll interval: ${POLL_INTERVAL_MS}ms`); console.log( `[watcher] Routine check interval: ${ROUTINE_CHECK_INTERVAL_MS}ms\n`, ); - // Register agents in Convex - await registerAgents(); - - // Setup crons on startup - await setupCrons(); - - // Seed routines if needed - await seedRoutines(); - console.log("[watcher] Starting loops...\n"); // Start routine check loop (every 10 seconds) diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md index ae7a8d8..7890e51 100644 --- a/apps/web/CLAUDE.md +++ b/apps/web/CLAUDE.md @@ -76,9 +76,9 @@ Or infer from query results (preferred when using the data directly). **Environment variables:** - `NEXT_PUBLIC_CONVEX_URL` → Convex deployment URL (required) -- `ANTHROPIC_API_KEY` → Anthropic API key (passed to agency container) -- `AGENCY_URL` → Agency gateway URL -- `AGENCY_TOKEN` → Agency authentication token (from root `.env`) +- `ANTHROPIC_API_KEY` → Anthropic API key (passed to squadhub container) +- `SQUADHUB_URL` → SquadHub gateway URL +- `SQUADHUB_TOKEN` → SquadHub authentication token (from root `.env`) ## Adding Routes @@ -173,7 +173,7 @@ Dark: text-pink-400, hover bg-pink-400/5 ``` src/ ├── lib/ -│ └── agency/ +│ └── squadhub/ │ ├── client.ts │ ├── client.spec.ts # Unit tests for client │ ├── actions.ts diff --git a/apps/web/src/app/(dashboard)/@header/default.tsx b/apps/web/src/app/(dashboard)/@header/default.tsx index 8bbd672..742cc61 100644 --- a/apps/web/src/app/(dashboard)/@header/default.tsx +++ b/apps/web/src/app/(dashboard)/@header/default.tsx @@ -6,7 +6,7 @@ import { Separator } from "@clawe/ui/components/separator"; import { SidebarToggle } from "@dashboard/sidebar-toggle"; import { ChatPanelToggle } from "@dashboard/chat-panel-toggle"; import { isLockedSidebarRoute } from "@dashboard/sidebar-config"; -import { AgencyStatus } from "@/components/agency-status"; +import { SquadhubStatus } from "@/components/squadhub-status"; const DefaultHeaderContent = () => { const pathname = usePathname(); @@ -37,7 +37,7 @@ const DefaultHeaderContent = () => {
- + { }; const AgentsPage = () => { - const agents = useQuery(api.agents.squad); + const agents = useQuery(api.agents.squad, {}); return ( <> diff --git a/apps/web/src/app/(dashboard)/board/_components/agents-panel/agents-panel.tsx b/apps/web/src/app/(dashboard)/board/_components/agents-panel/agents-panel.tsx index 43d6910..0e7aa41 100644 --- a/apps/web/src/app/(dashboard)/board/_components/agents-panel/agents-panel.tsx +++ b/apps/web/src/app/(dashboard)/board/_components/agents-panel/agents-panel.tsx @@ -20,7 +20,7 @@ export const AgentsPanel = ({ selectedAgentIds = [], onSelectionChange, }: AgentsPanelProps) => { - const agents = useQuery(api.agents.squad); + const agents = useQuery(api.agents.squad, {}); const total = agents?.length ?? 0; diff --git a/apps/web/src/app/(dashboard)/settings/business/_components/business-settings-form.tsx b/apps/web/src/app/(dashboard)/settings/business/_components/business-settings-form.tsx index c71e513..3df5e21 100644 --- a/apps/web/src/app/(dashboard)/settings/business/_components/business-settings-form.tsx +++ b/apps/web/src/app/(dashboard)/settings/business/_components/business-settings-form.tsx @@ -11,7 +11,7 @@ import { Spinner } from "@clawe/ui/components/spinner"; import { Globe, Building2, Users, Palette } from "lucide-react"; export const BusinessSettingsForm = () => { - const businessContext = useQuery(api.businessContext.get); + const businessContext = useQuery(api.businessContext.get, {}); const saveBusinessContext = useMutation(api.businessContext.save); const [url, setUrl] = useState(""); diff --git a/apps/web/src/app/(dashboard)/settings/general/_components/timezone-settings.tsx b/apps/web/src/app/(dashboard)/settings/general/_components/timezone-settings.tsx index 61b353a..412856f 100644 --- a/apps/web/src/app/(dashboard)/settings/general/_components/timezone-settings.tsx +++ b/apps/web/src/app/(dashboard)/settings/general/_components/timezone-settings.tsx @@ -18,8 +18,8 @@ import { import { Skeleton } from "@clawe/ui/components/skeleton"; export const TimezoneSettings = () => { - const timezone = useQuery(api.settings.getTimezone); - const setTimezone = useMutation(api.settings.setTimezone); + const timezone = useQuery(api.tenants.getTimezone, {}); + const setTimezone = useMutation(api.tenants.setTimezone); const [search, setSearch] = useState(""); // Filter and group timezones diff --git a/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-integration-card.tsx b/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-integration-card.tsx index f8f50e2..d3abe9f 100644 --- a/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-integration-card.tsx +++ b/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-integration-card.tsx @@ -7,15 +7,15 @@ import { api } from "@clawe/backend"; import { Button } from "@clawe/ui/components/button"; import { Badge } from "@clawe/ui/components/badge"; import { Skeleton } from "@clawe/ui/components/skeleton"; -import { useAgencyStatus } from "@/hooks/use-agency-status"; +import { useSquadhubStatus } from "@/hooks/use-squadhub-status"; import { TelegramSetupDialog } from "./telegram-setup-dialog"; import { TelegramDisconnectDialog } from "./telegram-disconnect-dialog"; import { TelegramRemoveDialog } from "./telegram-remove-dialog"; export const TelegramIntegrationCard = () => { const channel = useQuery(api.channels.getByType, { type: "telegram" }); - const { status: agencyStatus, isLoading: isAgencyLoading } = - useAgencyStatus(); + const { status: squadhubStatus, isLoading: isSquadhubLoading } = + useSquadhubStatus(); const [setupOpen, setSetupOpen] = useState(false); const [disconnectOpen, setDisconnectOpen] = useState(false); @@ -23,7 +23,7 @@ export const TelegramIntegrationCard = () => { const isLoading = channel === undefined; const isConnected = channel?.status === "connected"; - const isOffline = !isAgencyLoading && agencyStatus === "down"; + const isOffline = !isSquadhubLoading && squadhubStatus === "down"; if (isLoading) { return ; @@ -94,7 +94,7 @@ export const TelegramIntegrationCard = () => { )} {isOffline && (

- Agency is offline + Squadhub is offline

)}
diff --git a/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-remove-dialog.tsx b/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-remove-dialog.tsx index bd8d498..92ee5ca 100644 --- a/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-remove-dialog.tsx +++ b/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-remove-dialog.tsx @@ -14,7 +14,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@clawe/ui/components/alert-dialog"; -import { removeTelegramBot } from "@/lib/agency/actions"; +import { removeTelegramBot } from "@/lib/squadhub/actions"; export interface TelegramRemoveDialogProps { open: boolean; @@ -33,7 +33,7 @@ export const TelegramRemoveDialog = ({ const handleRemove = async () => { setIsRemoving(true); try { - // Remove token from agency config + // Remove token from squadhub config const result = await removeTelegramBot(); if (!result.ok) { throw new Error("Failed to remove bot token"); diff --git a/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-setup-dialog.tsx b/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-setup-dialog.tsx index 1dd61cd..fe799f3 100644 --- a/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-setup-dialog.tsx +++ b/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-setup-dialog.tsx @@ -19,12 +19,12 @@ import { DialogHeader, DialogTitle, } from "@clawe/ui/components/dialog"; -import { useAgencyStatus } from "@/hooks/use-agency-status"; +import { useSquadhubStatus } from "@/hooks/use-squadhub-status"; import { validateTelegramToken, saveTelegramBotToken, approvePairingCode, -} from "@/lib/agency/actions"; +} from "@/lib/squadhub/actions"; type Step = "token" | "pairing" | "success"; @@ -37,8 +37,8 @@ export const TelegramSetupDialog = ({ open, onOpenChange, }: TelegramSetupDialogProps) => { - const { status, isLoading: isAgencyLoading } = useAgencyStatus(); - const isOffline = !isAgencyLoading && status === "down"; + const { status, isLoading: isSquadhubLoading } = useSquadhubStatus(); + const isOffline = !isSquadhubLoading && status === "down"; const [step, setStep] = useState("token"); const [botToken, setBotToken] = useState(""); @@ -214,10 +214,10 @@ export const TelegramSetupDialog = ({

- Agency is offline + Squadhub is offline

- The agency service needs to be running to verify pairing. + The squadhub service needs to be running to verify pairing.

@@ -354,10 +354,10 @@ export const TelegramSetupDialog = ({

- Agency is offline + Squadhub is offline

- The agency service needs to be running to connect Telegram. + The squadhub service needs to be running to connect Telegram.

diff --git a/apps/web/src/app/api/agency/health/route.ts b/apps/web/src/app/api/agency/health/route.ts deleted file mode 100644 index 4872258..0000000 --- a/apps/web/src/app/api/agency/health/route.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { NextResponse } from "next/server"; -import { checkHealth } from "@clawe/shared/agency"; - -export async function POST() { - const result = await checkHealth(); - return NextResponse.json(result); -} diff --git a/apps/web/src/app/api/business/context/route.ts b/apps/web/src/app/api/business/context/route.ts index 217200c..7a45abf 100644 --- a/apps/web/src/app/api/business/context/route.ts +++ b/apps/web/src/app/api/business/context/route.ts @@ -5,8 +5,12 @@ import { api } from "@clawe/backend"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL; -const agencyToken = process.env.AGENCY_TOKEN; +function getEnvConfig() { + return { + convexUrl: process.env.NEXT_PUBLIC_CONVEX_URL, + squadhubToken: process.env.SQUADHUB_TOKEN, + }; +} /** * GET /api/business/context @@ -14,14 +18,16 @@ const agencyToken = process.env.AGENCY_TOKEN; * Returns the current business context. * Used by agents to understand what business they're working for. * - * Requires: Authorization header with AGENCY_TOKEN + * Requires: Authorization header with SQUADHUB_TOKEN */ export const GET = async (request: Request) => { + const { convexUrl, squadhubToken } = getEnvConfig(); + // Validate token const authHeader = request.headers.get("Authorization"); const token = authHeader?.replace("Bearer ", ""); - if (!token || token !== agencyToken) { + if (!token || token !== squadhubToken) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } @@ -63,14 +69,16 @@ export const GET = async (request: Request) => { * Saves or updates the business context. * Used by Clawe CLI during onboarding. * - * Requires: Authorization header with AGENCY_TOKEN + * Requires: Authorization header with SQUADHUB_TOKEN */ export const POST = async (request: Request) => { + const { convexUrl, squadhubToken } = getEnvConfig(); + // Validate token const authHeader = request.headers.get("Authorization"); const token = authHeader?.replace("Bearer ", ""); - if (!token || token !== agencyToken) { + if (!token || token !== squadhubToken) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/apps/web/src/app/api/chat/abort/route.spec.ts b/apps/web/src/app/api/chat/abort/route.spec.ts index e2399cf..b7b1b2c 100644 --- a/apps/web/src/app/api/chat/abort/route.spec.ts +++ b/apps/web/src/app/api/chat/abort/route.spec.ts @@ -5,8 +5,8 @@ import { POST } from "./route"; // Mock the shared client const mockRequest = vi.fn(); -vi.mock("@clawe/shared/agency", () => ({ - getSharedClient: vi.fn(async () => ({ +vi.mock("@clawe/shared/squadhub", () => ({ + getSharedClient: vi.fn(async (_connection: unknown) => ({ request: mockRequest, isConnected: vi.fn().mockReturnValue(true), })), @@ -95,7 +95,7 @@ describe("POST /api/chat/abort", () => { }); it("returns 500 when getSharedClient fails", async () => { - const { getSharedClient } = await import("@clawe/shared/agency"); + const { getSharedClient } = await import("@clawe/shared/squadhub"); vi.mocked(getSharedClient).mockRejectedValueOnce( new Error("Connection failed"), ); diff --git a/apps/web/src/app/api/chat/abort/route.ts b/apps/web/src/app/api/chat/abort/route.ts index fc75509..2e8c30b 100644 --- a/apps/web/src/app/api/chat/abort/route.ts +++ b/apps/web/src/app/api/chat/abort/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { getSharedClient } from "@clawe/shared/agency"; +import { getSharedClient } from "@clawe/shared/squadhub"; +import { getConnection } from "@/lib/squadhub/connection"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -32,7 +33,7 @@ export async function POST(request: NextRequest) { } try { - const client = await getSharedClient(); + const client = await getSharedClient(getConnection()); await client.request("chat.abort", { sessionKey, diff --git a/apps/web/src/app/api/chat/history/route.spec.ts b/apps/web/src/app/api/chat/history/route.spec.ts index 7b80f56..f5b62e0 100644 --- a/apps/web/src/app/api/chat/history/route.spec.ts +++ b/apps/web/src/app/api/chat/history/route.spec.ts @@ -5,8 +5,8 @@ import { GET } from "./route"; // Mock the shared client const mockRequest = vi.fn(); -vi.mock("@clawe/shared/agency", () => ({ - getSharedClient: vi.fn(async () => ({ +vi.mock("@clawe/shared/squadhub", () => ({ + getSharedClient: vi.fn(async (_connection: unknown) => ({ request: mockRequest, isConnected: vi.fn().mockReturnValue(true), })), @@ -87,7 +87,7 @@ describe("GET /api/chat/history", () => { }); it("returns 500 when getSharedClient fails", async () => { - const { getSharedClient } = await import("@clawe/shared/agency"); + const { getSharedClient } = await import("@clawe/shared/squadhub"); vi.mocked(getSharedClient).mockRejectedValueOnce( new Error("Connection failed"), ); diff --git a/apps/web/src/app/api/chat/history/route.ts b/apps/web/src/app/api/chat/history/route.ts index f8e1bc4..a6600a0 100644 --- a/apps/web/src/app/api/chat/history/route.ts +++ b/apps/web/src/app/api/chat/history/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; -import { getSharedClient } from "@clawe/shared/agency"; -import type { ChatHistoryResponse } from "@clawe/shared/agency"; +import { getSharedClient } from "@clawe/shared/squadhub"; +import type { ChatHistoryResponse } from "@clawe/shared/squadhub"; +import { getConnection } from "@/lib/squadhub/connection"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -29,7 +30,7 @@ export async function GET(request: NextRequest) { } try { - const client = await getSharedClient(); + const client = await getSharedClient(getConnection()); const response = await client.request("chat.history", { sessionKey, diff --git a/apps/web/src/app/api/chat/route.ts b/apps/web/src/app/api/chat/route.ts index 93ccbbb..2c15835 100644 --- a/apps/web/src/app/api/chat/route.ts +++ b/apps/web/src/app/api/chat/route.ts @@ -1,15 +1,13 @@ import { createOpenAI } from "@ai-sdk/openai"; import { streamText } from "ai"; +import { getConnection } from "@/lib/squadhub/connection"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -const agencyUrl = process.env.AGENCY_URL || "http://localhost:18789"; -const agencyToken = process.env.AGENCY_TOKEN || ""; - /** * POST /api/chat - * Proxy chat requests to the agency's OpenAI-compatible endpoint. + * Proxy chat requests to the squadhub's OpenAI-compatible endpoint. */ export async function POST(request: Request) { try { @@ -30,16 +28,18 @@ export async function POST(request: Request) { }); } - // Create OpenAI-compatible client pointing to agency gateway - const agency = createOpenAI({ - baseURL: `${agencyUrl}/v1`, - apiKey: agencyToken, + const { squadhubUrl, squadhubToken } = getConnection(); + + // Create OpenAI-compatible client pointing to squadhub gateway + const squadhub = createOpenAI({ + baseURL: `${squadhubUrl}/v1`, + apiKey: squadhubToken, }); // Stream response using Vercel AI SDK // Use .chat() to force Chat Completions API instead of Responses API const result = streamText({ - model: agency.chat("openclaw"), + model: squadhub.chat("openclaw"), messages, headers: { "X-OpenClaw-Session-Key": sessionKey, diff --git a/apps/web/src/app/api/squadhub/health/route.ts b/apps/web/src/app/api/squadhub/health/route.ts new file mode 100644 index 0000000..2c81c12 --- /dev/null +++ b/apps/web/src/app/api/squadhub/health/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; +import { checkHealth } from "@clawe/shared/squadhub"; +import { getConnection } from "@/lib/squadhub/connection"; + +export async function POST() { + const result = await checkHealth(getConnection()); + return NextResponse.json(result); +} diff --git a/apps/web/src/app/api/agency/pairing/route.ts b/apps/web/src/app/api/squadhub/pairing/route.ts similarity index 77% rename from apps/web/src/app/api/agency/pairing/route.ts rename to apps/web/src/app/api/squadhub/pairing/route.ts index 76c098f..f6dc601 100644 --- a/apps/web/src/app/api/agency/pairing/route.ts +++ b/apps/web/src/app/api/squadhub/pairing/route.ts @@ -2,9 +2,10 @@ import { NextResponse } from "next/server"; import { listChannelPairingRequests, approveChannelPairingCode, -} from "@clawe/shared/agency"; +} from "@clawe/shared/squadhub"; +import { getConnection } from "@/lib/squadhub/connection"; -// GET /api/agency/pairing?channel=telegram - List pending pairing requests +// GET /api/squadhub/pairing?channel=telegram - List pending pairing requests export async function GET(request: Request) { const { searchParams } = new URL(request.url); const channel = searchParams.get("channel") || "telegram"; @@ -18,7 +19,7 @@ export async function GET(request: Request) { return NextResponse.json(result.result); } -// POST /api/agency/pairing - Approve a pairing code +// POST /api/squadhub/pairing - Approve a pairing code export async function POST(request: Request) { try { const body = await request.json(); @@ -31,7 +32,11 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Code is required" }, { status: 400 }); } - const result = await approveChannelPairingCode(channel, code); + const result = await approveChannelPairingCode( + getConnection(), + channel, + code, + ); if (!result.ok) { const status = result.error.type === "not_found" ? 404 : 500; diff --git a/apps/web/src/app/api/tenant/provision/route.ts b/apps/web/src/app/api/tenant/provision/route.ts new file mode 100644 index 0000000..5619b90 --- /dev/null +++ b/apps/web/src/app/api/tenant/provision/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { provisionTenant } from "@/lib/squadhub/provision"; +import { getConnection } from "@/lib/squadhub/connection"; + +/** + * POST /api/tenant/provision + * + * Runs tenant setup: registers default agents, configures heartbeat crons, + * and seeds initial routines. Idempotent — safe to call multiple times. + * + * In Phase 7 this will also handle AWS infrastructure (ECS, EFS, CloudMap). + * For now it only runs the application-level setup. + */ +export async function POST() { + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL; + if (!convexUrl) { + return NextResponse.json( + { error: "NEXT_PUBLIC_CONVEX_URL not configured" }, + { status: 500 }, + ); + } + + try { + const result = await provisionTenant(getConnection(), convexUrl); + + return NextResponse.json({ + ok: result.errors.length === 0, + agents: result.agents, + crons: result.crons, + routines: result.routines, + errors: result.errors.length > 0 ? result.errors : undefined, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index a6f7295..da57bdd 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -7,7 +7,7 @@ import { api } from "@clawe/backend"; export default function Home() { const router = useRouter(); - const isOnboardingComplete = useQuery(api.settings.isOnboardingComplete); + const isOnboardingComplete = useQuery(api.tenants.isOnboardingComplete, {}); useEffect(() => { // Wait for query to load diff --git a/apps/web/src/app/setup/business/page.tsx b/apps/web/src/app/setup/business/page.tsx index 1d88cca..9f129ba 100644 --- a/apps/web/src/app/setup/business/page.tsx +++ b/apps/web/src/app/setup/business/page.tsx @@ -17,7 +17,7 @@ export default function BusinessPage() { const router = useRouter(); // Real-time subscription - auto-updates when CLI saves - const businessContext = useQuery(api.businessContext.get); + const businessContext = useQuery(api.businessContext.get, {}); const canContinue = businessContext?.approved === true; return ( diff --git a/apps/web/src/app/setup/complete/page.tsx b/apps/web/src/app/setup/complete/page.tsx index 2959b53..fd2a068 100644 --- a/apps/web/src/app/setup/complete/page.tsx +++ b/apps/web/src/app/setup/complete/page.tsx @@ -14,7 +14,7 @@ const CURRENT_STEP = 4; export default function CompletePage() { const router = useRouter(); - const completeOnboarding = useMutation(api.settings.completeOnboarding); + const completeOnboarding = useMutation(api.tenants.completeOnboarding); const [isCompleting, setIsCompleting] = useState(false); const handleFinish = async () => { diff --git a/apps/web/src/app/setup/layout.tsx b/apps/web/src/app/setup/layout.tsx index 5154cd7..279492c 100644 --- a/apps/web/src/app/setup/layout.tsx +++ b/apps/web/src/app/setup/layout.tsx @@ -3,7 +3,7 @@ import Image from "next/image"; import type { ReactNode } from "react"; -import { AgencyStatus } from "@/components/agency-status"; +import { SquadhubStatus } from "@/components/squadhub-status"; import { useRedirectIfOnboarded } from "@/hooks/use-onboarding-guard"; import { SetupUserMenu } from "./_components/setup-user-menu"; import { @@ -47,7 +47,7 @@ export default function SetupLayout({ children }: { children: ReactNode }) { {/* User menu and status - top right (on illustration side) */}
- +
@@ -58,7 +58,7 @@ export default function SetupLayout({ children }: { children: ReactNode }) { Clawe {/* User menu and status on mobile */}
- +
diff --git a/apps/web/src/app/setup/telegram/page.tsx b/apps/web/src/app/setup/telegram/page.tsx index f12228c..bc9763e 100644 --- a/apps/web/src/app/setup/telegram/page.tsx +++ b/apps/web/src/app/setup/telegram/page.tsx @@ -22,13 +22,13 @@ import { TooltipContent, TooltipTrigger, } from "@clawe/ui/components/tooltip"; -import { useAgencyStatus } from "@/hooks/use-agency-status"; +import { useSquadhubStatus } from "@/hooks/use-squadhub-status"; import { api } from "@clawe/backend"; import { validateTelegramToken, saveTelegramBotToken, approvePairingCode, -} from "@/lib/agency/actions"; +} from "@/lib/squadhub/actions"; import { SetupRightPanelContent } from "../_components/setup-right-panel"; import { DemoVideo } from "./_components/demo-video"; @@ -57,7 +57,7 @@ const DemoVideoPanel = () => { export default function TelegramPage() { const router = useRouter(); - const { status, isLoading } = useAgencyStatus(); + const { status, isLoading } = useSquadhubStatus(); const isOffline = !isLoading && status === "down"; const [step, setStep] = useState("token"); const [botToken, setBotToken] = useState(""); @@ -252,10 +252,10 @@ export default function TelegramPage() {

- Agency is offline + Squadhub is offline

- The agency service needs to be running to verify pairing. + The squadhub service needs to be running to verify pairing.

@@ -322,7 +322,7 @@ export default function TelegramPage() { {isOffline && ( -

Start agency to continue

+

Start squadhub to continue

)} @@ -424,10 +424,10 @@ export default function TelegramPage() {

- Agency is offline + Squadhub is offline

- The agency service needs to be running to connect Telegram. + The squadhub service needs to be running to connect Telegram.

@@ -488,7 +488,7 @@ export default function TelegramPage() { {isOffline && ( -

Start agency to continue

+

Start squadhub to continue

)} diff --git a/apps/web/src/app/setup/welcome/page.tsx b/apps/web/src/app/setup/welcome/page.tsx index 50aa048..005f078 100644 --- a/apps/web/src/app/setup/welcome/page.tsx +++ b/apps/web/src/app/setup/welcome/page.tsx @@ -9,14 +9,14 @@ import { TooltipContent, TooltipTrigger, } from "@clawe/ui/components/tooltip"; -import { useAgencyStatus } from "@/hooks/use-agency-status"; +import { useSquadhubStatus } from "@/hooks/use-squadhub-status"; const TOTAL_STEPS = 4; const CURRENT_STEP = 1; export default function WelcomePage() { const router = useRouter(); - const { status, isLoading } = useAgencyStatus(); + const { status, isLoading } = useSquadhubStatus(); const isOffline = !isLoading && status === "down"; @@ -71,14 +71,14 @@ export default function WelcomePage() {

- Agency service is offline + Squadhub service is offline

- The agency service needs to be running before you can + The squadhub service needs to be running before you can continue. Start it with:

-                  sudo docker compose up -d agency
+                  sudo docker compose up -d squadhub
                 

This status will update automatically once the service is @@ -107,7 +107,7 @@ export default function WelcomePage() { {isOffline && ( -

Start agency to continue

+

Start squadhub to continue

)} diff --git a/apps/web/src/components/chat/chat-message.tsx b/apps/web/src/components/chat/chat-message.tsx index cc81163..d566fd3 100644 --- a/apps/web/src/components/chat/chat-message.tsx +++ b/apps/web/src/components/chat/chat-message.tsx @@ -8,7 +8,7 @@ import remarkGfm from "remark-gfm"; import type { Message } from "@/hooks/use-chat"; /** - * Context message patterns - messages injected by agency for context. + * Context message patterns - messages injected by squadhub for context. */ const CONTEXT_MESSAGE_PATTERNS = [ /^\[Chat messages since your last reply/i, diff --git a/apps/web/src/components/agency-status.tsx b/apps/web/src/components/squadhub-status.tsx similarity index 85% rename from apps/web/src/components/agency-status.tsx rename to apps/web/src/components/squadhub-status.tsx index 4baadfd..ac910ff 100644 --- a/apps/web/src/components/agency-status.tsx +++ b/apps/web/src/components/squadhub-status.tsx @@ -6,9 +6,9 @@ import { TooltipContent, TooltipTrigger, } from "@clawe/ui/components/tooltip"; -import { useAgencyStatus } from "@/hooks/use-agency-status"; +import { useSquadhubStatus } from "@/hooks/use-squadhub-status"; -type AgencyStatusProps = { +type SquadhubStatusProps = { className?: string; }; @@ -30,8 +30,8 @@ const statusConfig = { }, }; -export const AgencyStatus = ({ className }: AgencyStatusProps) => { - const { status, isLoading } = useAgencyStatus(); +export const SquadhubStatus = ({ className }: SquadhubStatusProps) => { + const { status, isLoading } = useSquadhubStatus(); const config = isLoading ? { label: "Connecting", dot: "bg-yellow-500", ping: "bg-yellow-400" } @@ -40,8 +40,8 @@ export const AgencyStatus = ({ className }: AgencyStatusProps) => { const tooltipText = isLoading ? "Checking connection..." : status === "active" - ? "Agency service is online and ready" - : "Unable to connect to agency service"; + ? "Squadhub service is online and ready" + : "Unable to connect to squadhub service"; const shouldAnimate = isLoading || status === "active"; diff --git a/apps/web/src/hooks/use-onboarding-guard.ts b/apps/web/src/hooks/use-onboarding-guard.ts index 6c98b7c..0da57df 100644 --- a/apps/web/src/hooks/use-onboarding-guard.ts +++ b/apps/web/src/hooks/use-onboarding-guard.ts @@ -11,7 +11,7 @@ import { api } from "@clawe/backend"; */ export const useRequireOnboarding = () => { const router = useRouter(); - const isComplete = useQuery(api.settings.isOnboardingComplete); + const isComplete = useQuery(api.tenants.isOnboardingComplete, {}); useEffect(() => { if (isComplete === false) { @@ -28,7 +28,7 @@ export const useRequireOnboarding = () => { */ export const useRedirectIfOnboarded = () => { const router = useRouter(); - const isComplete = useQuery(api.settings.isOnboardingComplete); + const isComplete = useQuery(api.tenants.isOnboardingComplete, {}); useEffect(() => { if (isComplete === true) { diff --git a/apps/web/src/hooks/use-agency-status.ts b/apps/web/src/hooks/use-squadhub-status.ts similarity index 63% rename from apps/web/src/hooks/use-agency-status.ts rename to apps/web/src/hooks/use-squadhub-status.ts index 7dab5ea..5303ee4 100644 --- a/apps/web/src/hooks/use-agency-status.ts +++ b/apps/web/src/hooks/use-squadhub-status.ts @@ -3,12 +3,12 @@ import { useQuery } from "@tanstack/react-query"; import axios from "axios"; -type AgencyStatus = "active" | "down" | "idle"; +type SquadhubStatus = "active" | "down" | "idle"; -const checkAgencyHealth = async (): Promise => { +const checkSquadhubHealth = async (): Promise => { try { const { data } = await axios.post( - "/api/agency/health", + "/api/squadhub/health", {}, { timeout: 5000 }, ); @@ -18,16 +18,16 @@ const checkAgencyHealth = async (): Promise => { } }; -export const useAgencyStatus = () => { +export const useSquadhubStatus = () => { const { data: isHealthy, isLoading } = useQuery({ - queryKey: ["agency-health"], - queryFn: checkAgencyHealth, + queryKey: ["squadhub-health"], + queryFn: checkSquadhubHealth, refetchInterval: 30000, // Check every 30 seconds staleTime: 10000, retry: false, }); - const status: AgencyStatus = isLoading + const status: SquadhubStatus = isLoading ? "idle" : isHealthy ? "active" diff --git a/apps/web/src/lib/agency/actions.spec.ts b/apps/web/src/lib/squadhub/actions.spec.ts similarity index 84% rename from apps/web/src/lib/agency/actions.spec.ts rename to apps/web/src/lib/squadhub/actions.spec.ts index 55f332c..930b39d 100644 --- a/apps/web/src/lib/agency/actions.spec.ts +++ b/apps/web/src/lib/squadhub/actions.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; // Mock the shared package -vi.mock("@clawe/shared/agency", () => ({ +vi.mock("@clawe/shared/squadhub", () => ({ checkHealth: vi.fn(), getConfig: vi.fn(), saveTelegramBotToken: vi.fn(), @@ -12,15 +12,15 @@ vi.mock("@clawe/shared/agency", () => ({ import { saveTelegramBotToken, validateTelegramToken, - checkAgencyHealth, + checkSquadhubHealth, } from "./actions"; import { checkHealth, saveTelegramBotToken as saveTelegramBotTokenClient, probeTelegramToken, -} from "@clawe/shared/agency"; +} from "@clawe/shared/squadhub"; -describe("Agency Actions", () => { +describe("Squadhub Actions", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -55,7 +55,10 @@ describe("Agency Actions", () => { const result = await saveTelegramBotToken("123456:ABC-DEF"); expect(probeTelegramToken).toHaveBeenCalledWith("123456:ABC-DEF"); - expect(saveTelegramBotTokenClient).toHaveBeenCalledWith("123456:ABC-DEF"); + expect(saveTelegramBotTokenClient).toHaveBeenCalledWith( + expect.objectContaining({ squadhubUrl: expect.any(String), squadhubToken: expect.any(String) }), + "123456:ABC-DEF", + ); expect(result.ok).toBe(true); }); @@ -75,7 +78,7 @@ describe("Agency Actions", () => { }); }); - describe("checkAgencyHealth", () => { + describe("checkSquadhubHealth", () => { it("returns health status", async () => { vi.mocked(checkHealth).mockResolvedValueOnce({ ok: true, @@ -85,7 +88,7 @@ describe("Agency Actions", () => { }, }); - const result = await checkAgencyHealth(); + const result = await checkSquadhubHealth(); expect(result.ok).toBe(true); }); }); diff --git a/apps/web/src/lib/agency/actions.ts b/apps/web/src/lib/squadhub/actions.ts similarity index 62% rename from apps/web/src/lib/agency/actions.ts rename to apps/web/src/lib/squadhub/actions.ts index b3ffbfe..ffe25e0 100644 --- a/apps/web/src/lib/agency/actions.ts +++ b/apps/web/src/lib/squadhub/actions.ts @@ -6,15 +6,16 @@ import { saveTelegramBotToken as saveTelegramBotTokenClient, removeTelegramBotToken as removeTelegramBotTokenClient, probeTelegramToken, -} from "@clawe/shared/agency"; -import { approveChannelPairingCode } from "@clawe/shared/agency"; + approveChannelPairingCode, +} from "@clawe/shared/squadhub"; +import { getConnection } from "./connection"; -export async function checkAgencyHealth() { - return checkHealth(); +export async function checkSquadhubHealth() { + return checkHealth(getConnection()); } -export async function getAgencyConfig() { - return getConfig(); +export async function getSquadhubConfig() { + return getConfig(getConnection()); } export async function validateTelegramToken(botToken: string) { @@ -32,16 +33,16 @@ export async function saveTelegramBotToken(botToken: string) { }, }; } - return saveTelegramBotTokenClient(botToken); + return saveTelegramBotTokenClient(getConnection(), botToken); } export async function approvePairingCode( code: string, channel: string = "telegram", ) { - return approveChannelPairingCode(channel, code); + return approveChannelPairingCode(getConnection(), channel, code); } export async function removeTelegramBot() { - return removeTelegramBotTokenClient(); + return removeTelegramBotTokenClient(getConnection()); } diff --git a/apps/web/src/lib/squadhub/connection.ts b/apps/web/src/lib/squadhub/connection.ts new file mode 100644 index 0000000..4b7f642 --- /dev/null +++ b/apps/web/src/lib/squadhub/connection.ts @@ -0,0 +1,8 @@ +import type { SquadhubConnection } from "@clawe/shared/squadhub"; + +export function getConnection(): SquadhubConnection { + return { + squadhubUrl: process.env.SQUADHUB_URL || "http://localhost:18790", + squadhubToken: process.env.SQUADHUB_TOKEN || "", + }; +} diff --git a/apps/web/src/lib/squadhub/provision.ts b/apps/web/src/lib/squadhub/provision.ts new file mode 100644 index 0000000..6ce32d7 --- /dev/null +++ b/apps/web/src/lib/squadhub/provision.ts @@ -0,0 +1,215 @@ +import { ConvexHttpClient } from "convex/browser"; +import { api } from "@clawe/backend"; +import { + cronList, + cronAdd, + checkHealth, + type SquadhubConnection, + type CronAddJob, + type CronJob, +} from "@clawe/shared/squadhub"; + +/** + * Default agent definitions for new tenants. + */ +const DEFAULT_AGENTS = [ + { id: "main", name: "Clawe", emoji: "\u{1F99E}", role: "Squad Lead", cron: "0,15,30,45 * * * *" }, + { id: "inky", name: "Inky", emoji: "\u270D\uFE0F", role: "Writer", cron: "3,18,33,48 * * * *" }, + { id: "pixel", name: "Pixel", emoji: "\u{1F3A8}", role: "Designer", cron: "7,22,37,52 * * * *" }, + { id: "scout", name: "Scout", emoji: "\u{1F50D}", role: "SEO", cron: "11,26,41,56 * * * *" }, +]; + +const HEARTBEAT_MESSAGE = + "Read HEARTBEAT.md and follow it strictly. Check for notifications with 'clawe check'. If nothing needs attention, reply HEARTBEAT_OK."; + +/** + * Default routines seeded for new tenants. + */ +const SEED_ROUTINES = [ + { + title: "Weekly Performance Review", + description: + "Review last week's content performance, engagement metrics, and campaign results. Identify top-performing pieces and areas for improvement.", + priority: "normal" as const, + schedule: { type: "weekly" as const, daysOfWeek: [1], hour: 9, minute: 0 }, + color: "emerald", + }, + { + title: "Morning Brief", + description: "Prepare daily morning brief for the team", + priority: "high" as const, + schedule: { type: "weekly" as const, daysOfWeek: [0, 1, 2, 3, 4, 5, 6], hour: 8, minute: 0 }, + color: "amber", + }, + { + title: "Competitor Scan", + description: "Scan competitor activities and updates", + priority: "normal" as const, + schedule: { type: "weekly" as const, daysOfWeek: [1, 4], hour: 10, minute: 0 }, + color: "rose", + }, +]; + +type ProvisionResult = { + agents: number; + crons: number; + routines: number; + errors: string[]; +}; + +/** + * Register default agents in Convex. + */ +async function registerAgents(convex: ConvexHttpClient): Promise<{ + count: number; + errors: string[]; +}> { + const errors: string[] = []; + let count = 0; + + for (const agent of DEFAULT_AGENTS) { + const sessionKey = `agent:${agent.id}:main`; + try { + await convex.mutation(api.agents.upsert, { + name: agent.name, + role: agent.role, + sessionKey, + emoji: agent.emoji, + }); + count++; + } catch (err) { + errors.push( + `Failed to register ${agent.name}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + return { count, errors }; +} + +/** + * Setup heartbeat cron jobs on the squadhub gateway. + */ +async function setupCrons(connection: SquadhubConnection): Promise<{ + count: number; + errors: string[]; +}> { + const errors: string[] = []; + let count = 0; + + const result = await cronList(connection); + if (!result.ok) { + return { count: 0, errors: [`Failed to list crons: ${result.error?.message}`] }; + } + + const existingNames = new Set( + result.result.details.jobs.map((j: CronJob) => j.name), + ); + + for (const agent of DEFAULT_AGENTS) { + const cronName = `${agent.id}-heartbeat`; + + if (existingNames.has(cronName)) { + count++; + continue; + } + + const job: CronAddJob = { + name: cronName, + agentId: agent.id, + enabled: true, + schedule: { kind: "cron", expr: agent.cron }, + sessionTarget: "isolated", + payload: { + kind: "agentTurn", + message: HEARTBEAT_MESSAGE, + model: "anthropic/claude-sonnet-4-20250514", + timeoutSeconds: 600, + }, + delivery: { mode: "none" }, + }; + + const addResult = await cronAdd(connection, job); + if (addResult.ok) { + count++; + } else { + errors.push(`Failed to add ${cronName}: ${addResult.error?.message}`); + } + } + + return { count, errors }; +} + +/** + * Seed default routines if none exist. + */ +async function seedRoutines(convex: ConvexHttpClient): Promise<{ + count: number; + errors: string[]; +}> { + const errors: string[] = []; + let count = 0; + + const existing = await convex.query(api.routines.list, {}); + if (existing.length > 0) { + return { count: existing.length, errors: [] }; + } + + for (const routine of SEED_ROUTINES) { + try { + await convex.mutation(api.routines.create, routine); + count++; + } catch (err) { + errors.push( + `Failed to create routine "${routine.title}": ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + return { count, errors }; +} + +/** + * Run full tenant provisioning setup: + * 1. Wait for squadhub to be healthy + * 2. Register default agents in Convex + * 3. Setup heartbeat cron jobs on squadhub + * 4. Seed default routines in Convex + */ +export async function provisionTenant( + connection: SquadhubConnection, + convexUrl: string, +): Promise { + const convex = new ConvexHttpClient(convexUrl); + const allErrors: string[] = []; + + // Check squadhub is reachable + const health = await checkHealth(connection); + if (!health.ok) { + return { + agents: 0, + crons: 0, + routines: 0, + errors: [`Squadhub not reachable: ${health.error?.message}`], + }; + } + + // Register agents + const agentResult = await registerAgents(convex); + allErrors.push(...agentResult.errors); + + // Setup crons + const cronResult = await setupCrons(connection); + allErrors.push(...cronResult.errors); + + // Seed routines + const routineResult = await seedRoutines(convex); + allErrors.push(...routineResult.errors); + + return { + agents: agentResult.count, + crons: cronResult.count, + routines: routineResult.count, + errors: allErrors, + }; +} diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 59226b4..5c235b6 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -17,9 +17,9 @@ export default defineConfig({ __dirname, "../../packages/backend/convex/_generated/api", ), - "@clawe/shared/agency": path.resolve( + "@clawe/shared/squadhub": path.resolve( __dirname, - "../../packages/shared/src/agency/index.ts", + "../../packages/shared/src/squadhub/index.ts", ), }, }, diff --git a/docker-compose.override.yml b/docker-compose.override.yml index f8368ce..3d8d80e 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -1,10 +1,10 @@ services: - agency: + squadhub: ports: - "18790:18789" volumes: - - ./.agency:/data - - ./.agency/logs:/tmp/openclaw + - ./.squadhub:/data + - ./.squadhub/logs:/tmp/openclaw - .:/home/ubuntu/clawe extra_hosts: - "host.docker.internal:host-gateway" diff --git a/docker-compose.yml b/docker-compose.yml index dd87565..87e6400 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,28 +8,28 @@ services: environment: - NODE_ENV=production - NEXT_PUBLIC_CONVEX_URL=${CONVEX_URL} - - AGENCY_URL=http://agency:18789 - - AGENCY_TOKEN=${AGENCY_TOKEN} - - AGENCY_STATE_DIR=/agency-data/config + - SQUADHUB_URL=http://squadhub:18789 + - SQUADHUB_TOKEN=${SQUADHUB_TOKEN} + - SQUADHUB_STATE_DIR=/squadhub-data/config volumes: - - agency-data:/agency-data:ro + - squadhub-data:/squadhub-data:ro depends_on: - agency: + squadhub: condition: service_healthy restart: unless-stopped - agency: + squadhub: build: context: . - dockerfile: docker/agency/Dockerfile + dockerfile: docker/squadhub/Dockerfile user: "${UID:-1000}:${GID:-1000}" environment: - - AGENCY_TOKEN=${AGENCY_TOKEN} + - SQUADHUB_TOKEN=${SQUADHUB_TOKEN} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - OPENAI_API_KEY=${OPENAI_API_KEY:-} - CONVEX_URL=${CONVEX_URL} volumes: - - agency-data:/data + - squadhub-data:/data healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost:18789/health"] interval: 5s @@ -43,13 +43,13 @@ services: dockerfile: apps/watcher/Dockerfile environment: - CONVEX_URL=${CONVEX_URL} - - AGENCY_URL=http://agency:18789 - - AGENCY_TOKEN=${AGENCY_TOKEN} + - SQUADHUB_URL=http://squadhub:18789 + - SQUADHUB_TOKEN=${SQUADHUB_TOKEN} depends_on: - agency: + squadhub: condition: service_healthy restart: unless-stopped volumes: - agency-data: + squadhub-data: driver: local diff --git a/docker/agency/Dockerfile b/docker/squadhub/Dockerfile similarity index 84% rename from docker/agency/Dockerfile rename to docker/squadhub/Dockerfile index 7596759..3dbc35f 100644 --- a/docker/agency/Dockerfile +++ b/docker/squadhub/Dockerfile @@ -16,8 +16,8 @@ RUN npm install -g openclaw@latest RUN mkdir -p /data/config /data/workspace # Copy templates and scripts -COPY docker/agency/templates/ /opt/clawe/templates/ -COPY docker/agency/scripts/ /opt/clawe/scripts/ +COPY docker/squadhub/templates/ /opt/clawe/templates/ +COPY docker/squadhub/scripts/ /opt/clawe/scripts/ RUN chmod +x /opt/clawe/scripts/*.sh # Copy bundled CLI (single file with all deps included) @@ -28,7 +28,7 @@ ENV OPENCLAW_STATE_DIR=/data/config ENV OPENCLAW_PORT=18789 ENV OPENCLAW_SKIP_GMAIL_WATCHER=1 -COPY docker/agency/entrypoint.sh /entrypoint.sh +COPY docker/squadhub/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh HEALTHCHECK --interval=5s --timeout=3s --retries=10 \ diff --git a/docker/agency/entrypoint.sh b/docker/squadhub/entrypoint.sh similarity index 95% rename from docker/agency/entrypoint.sh rename to docker/squadhub/entrypoint.sh index 10032ed..13ab518 100644 --- a/docker/agency/entrypoint.sh +++ b/docker/squadhub/entrypoint.sh @@ -3,14 +3,14 @@ set -e CONFIG_FILE="${OPENCLAW_STATE_DIR}/openclaw.json" PORT="${OPENCLAW_PORT:-18789}" -TOKEN="${AGENCY_TOKEN:-}" +TOKEN="${SQUADHUB_TOKEN:-}" TEMPLATES_DIR="/opt/clawe/templates" # Map to OPENCLAW_TOKEN for the openclaw CLI export OPENCLAW_TOKEN="$TOKEN" if [ -z "$TOKEN" ]; then - echo "ERROR: AGENCY_TOKEN environment variable is required" + echo "ERROR: SQUADHUB_TOKEN environment variable is required" exit 1 fi diff --git a/docker/agency/scripts/init-agents.sh b/docker/squadhub/scripts/init-agents.sh similarity index 100% rename from docker/agency/scripts/init-agents.sh rename to docker/squadhub/scripts/init-agents.sh diff --git a/docker/agency/scripts/pair-device.js b/docker/squadhub/scripts/pair-device.js similarity index 100% rename from docker/agency/scripts/pair-device.js rename to docker/squadhub/scripts/pair-device.js diff --git a/docker/agency/templates/config.template.json b/docker/squadhub/templates/config.template.json similarity index 100% rename from docker/agency/templates/config.template.json rename to docker/squadhub/templates/config.template.json diff --git a/docker/agency/templates/shared/CLAWE-CLI.md b/docker/squadhub/templates/shared/CLAWE-CLI.md similarity index 100% rename from docker/agency/templates/shared/CLAWE-CLI.md rename to docker/squadhub/templates/shared/CLAWE-CLI.md diff --git a/docker/agency/templates/shared/WORKFLOW.md b/docker/squadhub/templates/shared/WORKFLOW.md similarity index 100% rename from docker/agency/templates/shared/WORKFLOW.md rename to docker/squadhub/templates/shared/WORKFLOW.md diff --git a/docker/agency/templates/shared/WORKING.md b/docker/squadhub/templates/shared/WORKING.md similarity index 100% rename from docker/agency/templates/shared/WORKING.md rename to docker/squadhub/templates/shared/WORKING.md diff --git a/docker/agency/templates/workspaces/clawe/AGENTS.md b/docker/squadhub/templates/workspaces/clawe/AGENTS.md similarity index 100% rename from docker/agency/templates/workspaces/clawe/AGENTS.md rename to docker/squadhub/templates/workspaces/clawe/AGENTS.md diff --git a/docker/agency/templates/workspaces/clawe/BOOTSTRAP.md b/docker/squadhub/templates/workspaces/clawe/BOOTSTRAP.md similarity index 100% rename from docker/agency/templates/workspaces/clawe/BOOTSTRAP.md rename to docker/squadhub/templates/workspaces/clawe/BOOTSTRAP.md diff --git a/docker/agency/templates/workspaces/clawe/HEARTBEAT.md b/docker/squadhub/templates/workspaces/clawe/HEARTBEAT.md similarity index 100% rename from docker/agency/templates/workspaces/clawe/HEARTBEAT.md rename to docker/squadhub/templates/workspaces/clawe/HEARTBEAT.md diff --git a/docker/agency/templates/workspaces/clawe/MEMORY.md b/docker/squadhub/templates/workspaces/clawe/MEMORY.md similarity index 100% rename from docker/agency/templates/workspaces/clawe/MEMORY.md rename to docker/squadhub/templates/workspaces/clawe/MEMORY.md diff --git a/docker/agency/templates/workspaces/clawe/SOUL.md b/docker/squadhub/templates/workspaces/clawe/SOUL.md similarity index 100% rename from docker/agency/templates/workspaces/clawe/SOUL.md rename to docker/squadhub/templates/workspaces/clawe/SOUL.md diff --git a/docker/agency/templates/workspaces/clawe/TOOLS.md b/docker/squadhub/templates/workspaces/clawe/TOOLS.md similarity index 100% rename from docker/agency/templates/workspaces/clawe/TOOLS.md rename to docker/squadhub/templates/workspaces/clawe/TOOLS.md diff --git a/docker/agency/templates/workspaces/clawe/USER.md b/docker/squadhub/templates/workspaces/clawe/USER.md similarity index 100% rename from docker/agency/templates/workspaces/clawe/USER.md rename to docker/squadhub/templates/workspaces/clawe/USER.md diff --git a/docker/agency/templates/workspaces/inky/AGENTS.md b/docker/squadhub/templates/workspaces/inky/AGENTS.md similarity index 100% rename from docker/agency/templates/workspaces/inky/AGENTS.md rename to docker/squadhub/templates/workspaces/inky/AGENTS.md diff --git a/docker/agency/templates/workspaces/inky/HEARTBEAT.md b/docker/squadhub/templates/workspaces/inky/HEARTBEAT.md similarity index 100% rename from docker/agency/templates/workspaces/inky/HEARTBEAT.md rename to docker/squadhub/templates/workspaces/inky/HEARTBEAT.md diff --git a/docker/agency/templates/workspaces/inky/MEMORY.md b/docker/squadhub/templates/workspaces/inky/MEMORY.md similarity index 100% rename from docker/agency/templates/workspaces/inky/MEMORY.md rename to docker/squadhub/templates/workspaces/inky/MEMORY.md diff --git a/docker/agency/templates/workspaces/inky/SOUL.md b/docker/squadhub/templates/workspaces/inky/SOUL.md similarity index 100% rename from docker/agency/templates/workspaces/inky/SOUL.md rename to docker/squadhub/templates/workspaces/inky/SOUL.md diff --git a/docker/agency/templates/workspaces/inky/TOOLS.md b/docker/squadhub/templates/workspaces/inky/TOOLS.md similarity index 100% rename from docker/agency/templates/workspaces/inky/TOOLS.md rename to docker/squadhub/templates/workspaces/inky/TOOLS.md diff --git a/docker/agency/templates/workspaces/inky/USER.md b/docker/squadhub/templates/workspaces/inky/USER.md similarity index 100% rename from docker/agency/templates/workspaces/inky/USER.md rename to docker/squadhub/templates/workspaces/inky/USER.md diff --git a/docker/agency/templates/workspaces/pixel/AGENTS.md b/docker/squadhub/templates/workspaces/pixel/AGENTS.md similarity index 100% rename from docker/agency/templates/workspaces/pixel/AGENTS.md rename to docker/squadhub/templates/workspaces/pixel/AGENTS.md diff --git a/docker/agency/templates/workspaces/pixel/HEARTBEAT.md b/docker/squadhub/templates/workspaces/pixel/HEARTBEAT.md similarity index 100% rename from docker/agency/templates/workspaces/pixel/HEARTBEAT.md rename to docker/squadhub/templates/workspaces/pixel/HEARTBEAT.md diff --git a/docker/agency/templates/workspaces/pixel/MEMORY.md b/docker/squadhub/templates/workspaces/pixel/MEMORY.md similarity index 100% rename from docker/agency/templates/workspaces/pixel/MEMORY.md rename to docker/squadhub/templates/workspaces/pixel/MEMORY.md diff --git a/docker/agency/templates/workspaces/pixel/SOUL.md b/docker/squadhub/templates/workspaces/pixel/SOUL.md similarity index 100% rename from docker/agency/templates/workspaces/pixel/SOUL.md rename to docker/squadhub/templates/workspaces/pixel/SOUL.md diff --git a/docker/agency/templates/workspaces/pixel/TOOLS.md b/docker/squadhub/templates/workspaces/pixel/TOOLS.md similarity index 100% rename from docker/agency/templates/workspaces/pixel/TOOLS.md rename to docker/squadhub/templates/workspaces/pixel/TOOLS.md diff --git a/docker/agency/templates/workspaces/pixel/USER.md b/docker/squadhub/templates/workspaces/pixel/USER.md similarity index 100% rename from docker/agency/templates/workspaces/pixel/USER.md rename to docker/squadhub/templates/workspaces/pixel/USER.md diff --git a/docker/agency/templates/workspaces/scout/AGENTS.md b/docker/squadhub/templates/workspaces/scout/AGENTS.md similarity index 100% rename from docker/agency/templates/workspaces/scout/AGENTS.md rename to docker/squadhub/templates/workspaces/scout/AGENTS.md diff --git a/docker/agency/templates/workspaces/scout/HEARTBEAT.md b/docker/squadhub/templates/workspaces/scout/HEARTBEAT.md similarity index 100% rename from docker/agency/templates/workspaces/scout/HEARTBEAT.md rename to docker/squadhub/templates/workspaces/scout/HEARTBEAT.md diff --git a/docker/agency/templates/workspaces/scout/MEMORY.md b/docker/squadhub/templates/workspaces/scout/MEMORY.md similarity index 100% rename from docker/agency/templates/workspaces/scout/MEMORY.md rename to docker/squadhub/templates/workspaces/scout/MEMORY.md diff --git a/docker/agency/templates/workspaces/scout/SOUL.md b/docker/squadhub/templates/workspaces/scout/SOUL.md similarity index 100% rename from docker/agency/templates/workspaces/scout/SOUL.md rename to docker/squadhub/templates/workspaces/scout/SOUL.md diff --git a/docker/agency/templates/workspaces/scout/TOOLS.md b/docker/squadhub/templates/workspaces/scout/TOOLS.md similarity index 100% rename from docker/agency/templates/workspaces/scout/TOOLS.md rename to docker/squadhub/templates/workspaces/scout/TOOLS.md diff --git a/docker/agency/templates/workspaces/scout/USER.md b/docker/squadhub/templates/workspaces/scout/USER.md similarity index 100% rename from docker/agency/templates/workspaces/scout/USER.md rename to docker/squadhub/templates/workspaces/scout/USER.md diff --git a/package.json b/package.json index 257ab7f..aafb354 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "scripts": { "build": "dotenv -e .env -- turbo run build", "dev": "dotenv -e .env -- turbo run dev", - "dev:docker": "pnpm --filter @clawe/cli build && docker compose up --build agency", - "build:docker": "pnpm --filter @clawe/cli build && docker compose build --no-cache agency", + "dev:docker": "pnpm --filter @clawe/cli build && docker compose up --build squadhub", + "build:docker": "pnpm --filter @clawe/cli build && docker compose build --no-cache squadhub", "debug": "dotenv -e .env -- turbo run debug", "dev:web": "dotenv -e .env -- turbo run dev --filter=web", "convex:dev": "dotenv -e .env -- turbo run dev --filter=@clawe/backend", diff --git a/packages/backend/convex/accounts.ts b/packages/backend/convex/accounts.ts new file mode 100644 index 0000000..d5335f9 --- /dev/null +++ b/packages/backend/convex/accounts.ts @@ -0,0 +1,64 @@ +import { query, mutation } from "./_generated/server"; +import { getUser } from "./lib/auth"; + +/** + * Get or create an account for the current authenticated user. + * Called during provisioning or first login. + * If the user already has an account membership, returns that account. + * Otherwise, creates a new account and membership. + */ +export const getOrCreateForUser = mutation({ + args: {}, + handler: async (ctx) => { + const user = await getUser(ctx); + + // Check for existing account membership + const membership = await ctx.db + .query("accountMembers") + .withIndex("by_user", (q) => q.eq("userId", user._id)) + .first(); + + if (membership) { + return (await ctx.db.get(membership.accountId))!; + } + + // Create new account + membership + const now = Date.now(); + const accountId = await ctx.db.insert("accounts", { + name: user.name ? `${user.name}'s Account` : undefined, + createdAt: now, + updatedAt: now, + }); + + await ctx.db.insert("accountMembers", { + userId: user._id, + accountId, + role: "owner", + createdAt: now, + }); + + return (await ctx.db.get(accountId))!; + }, +}); + +/** + * Get the account for the current authenticated user. + * Returns null if the user has no account membership. + */ +export const getForCurrentUser = query({ + args: {}, + handler: async (ctx) => { + const user = await getUser(ctx); + + const membership = await ctx.db + .query("accountMembers") + .withIndex("by_user", (q) => q.eq("userId", user._id)) + .first(); + + if (!membership) { + return null; + } + + return await ctx.db.get(membership.accountId); + }, +}); diff --git a/packages/backend/convex/activities.ts b/packages/backend/convex/activities.ts index cd2a9b9..000113f 100644 --- a/packages/backend/convex/activities.ts +++ b/packages/backend/convex/activities.ts @@ -1,5 +1,6 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; +import { resolveTenantId, resolveTenantIdMut } from "./lib/auth"; // Get activity feed (most recent first) export const feed = query({ @@ -7,33 +8,32 @@ export const feed = query({ limit: v.optional(v.number()), agentId: v.optional(v.id("agents")), taskId: v.optional(v.id("tasks")), + machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { - const limit = args.limit ?? 50; + const tenantId = await resolveTenantId(ctx, args); + const { machineToken: _, ...filters } = args; + const limit = filters.limit ?? 50; + + // Always query by tenant first, then filter in JS + const allActivities = await ctx.db + .query("activities") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .order("desc") + .collect(); let activities; - if (args.taskId) { - // Filter by task - activities = await ctx.db - .query("activities") - .withIndex("by_task", (q) => q.eq("taskId", args.taskId)) - .order("desc") - .take(limit); - } else if (args.agentId) { - // Filter by agent - activities = await ctx.db - .query("activities") - .withIndex("by_agent", (q) => q.eq("agentId", args.agentId)) - .order("desc") - .take(limit); + if (filters.taskId) { + activities = allActivities + .filter((a) => a.taskId === filters.taskId) + .slice(0, limit); + } else if (filters.agentId) { + activities = allActivities + .filter((a) => a.agentId === filters.agentId) + .slice(0, limit); } else { - // All activities - activities = await ctx.db - .query("activities") - .withIndex("by_createdAt") - .order("desc") - .take(limit); + activities = allActivities.slice(0, limit); } // Enrich with agent and task info @@ -77,15 +77,23 @@ export const byType = query({ v.literal("notification_sent"), ), limit: v.optional(v.number()), + machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { - const limit = args.limit ?? 50; + const tenantId = await resolveTenantId(ctx, args); + const { machineToken: _, ...filters } = args; + const limit = filters.limit ?? 50; - return await ctx.db + // Query by tenant first, then filter by type in JS + const allActivities = await ctx.db .query("activities") - .withIndex("by_type", (q) => q.eq("type", args.type)) + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .order("desc") - .take(limit); + .collect(); + + return allActivities + .filter((a) => a.type === filters.type) + .slice(0, limit); }, }); @@ -106,14 +114,19 @@ export const log = mutation({ taskId: v.optional(v.id("tasks")), message: v.string(), metadata: v.optional(v.any()), + machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); + const { machineToken: _, ...fields } = args; + return await ctx.db.insert("activities", { - type: args.type, - agentId: args.agentId, - taskId: args.taskId, - message: args.message, - metadata: args.metadata, + tenantId, + type: fields.type, + agentId: fields.agentId, + taskId: fields.taskId, + message: fields.message, + metadata: fields.metadata, createdAt: Date.now(), }); }, @@ -136,12 +149,16 @@ export const logBySession = mutation({ taskId: v.optional(v.id("tasks")), message: v.string(), metadata: v.optional(v.any()), + machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); + const { machineToken: _, ...fields } = args; + let agentId = undefined; - if (args.sessionKey) { - const sessionKey = args.sessionKey; + if (fields.sessionKey) { + const sessionKey = fields.sessionKey; const agent = await ctx.db .query("agents") .withIndex("by_sessionKey", (q) => q.eq("sessionKey", sessionKey)) @@ -152,11 +169,12 @@ export const logBySession = mutation({ } return await ctx.db.insert("activities", { - type: args.type, + tenantId, + type: fields.type, agentId, - taskId: args.taskId, - message: args.message, - metadata: args.metadata, + taskId: fields.taskId, + message: fields.message, + metadata: fields.metadata, createdAt: Date.now(), }); }, diff --git a/packages/backend/convex/agents.ts b/packages/backend/convex/agents.ts index 8500a97..fa62b97 100644 --- a/packages/backend/convex/agents.ts +++ b/packages/backend/convex/agents.ts @@ -1,51 +1,65 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; +import { resolveTenantId, resolveTenantIdMut } from "./lib/auth"; const agentStatusValidator = v.union(v.literal("online"), v.literal("offline")); // List all agents export const list = query({ - args: {}, - handler: async (ctx) => { - return await ctx.db.query("agents").collect(); + args: { machineToken: v.optional(v.string()) }, + handler: async (ctx, args) => { + const tenantId = await resolveTenantId(ctx, args); + return await ctx.db + .query("agents") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); }, }); // Get agent by ID export const get = query({ - args: { id: v.id("agents") }, + args: { id: v.id("agents"), machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { + await resolveTenantId(ctx, args); return await ctx.db.get(args.id); }, }); // Get agent by session key export const getBySessionKey = query({ - args: { sessionKey: v.string() }, + args: { sessionKey: v.string(), machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { - return await ctx.db + const tenantId = await resolveTenantId(ctx, args); + const agents = await ctx.db .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey)) - .first(); + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + return agents.find((a) => a.sessionKey === args.sessionKey) ?? null; }, }); // List agents by status export const listByStatus = query({ - args: { status: agentStatusValidator }, + args: { status: agentStatusValidator, machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { - return await ctx.db + const tenantId = await resolveTenantId(ctx, args); + const agents = await ctx.db .query("agents") - .withIndex("by_status", (q) => q.eq("status", args.status)) + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect(); + return agents.filter((a) => a.status === args.status); }, }); // Squad status - get all agents with their current state export const squad = query({ - args: {}, - handler: async (ctx) => { - const agents = await ctx.db.query("agents").collect(); + args: { machineToken: v.optional(v.string()) }, + handler: async (ctx, args) => { + const tenantId = await resolveTenantId(ctx, args); + const agents = await ctx.db + .query("agents") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); return Promise.all( agents.map(async (agent) => { @@ -76,32 +90,37 @@ export const upsert = mutation({ sessionKey: v.string(), emoji: v.optional(v.string()), config: v.optional(v.any()), + machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); + const { machineToken: _, ...rest } = args; const now = Date.now(); - const existing = await ctx.db + const agents = await ctx.db .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey)) - .first(); + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + const existing = agents.find((a) => a.sessionKey === rest.sessionKey); if (existing) { await ctx.db.patch(existing._id, { - name: args.name, - role: args.role, - emoji: args.emoji, - config: args.config, + name: rest.name, + role: rest.role, + emoji: rest.emoji, + config: rest.config, updatedAt: now, }); return existing._id; } else { return await ctx.db.insert("agents", { - name: args.name, - role: args.role, - sessionKey: args.sessionKey, - emoji: args.emoji, - config: args.config, + name: rest.name, + role: rest.role, + sessionKey: rest.sessionKey, + emoji: rest.emoji, + config: rest.config, status: "offline", + tenantId, createdAt: now, updatedAt: now, }); @@ -117,16 +136,20 @@ export const create = mutation({ sessionKey: v.string(), emoji: v.optional(v.string()), config: v.optional(v.any()), + machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); + const { machineToken: _, ...rest } = args; const now = Date.now(); return await ctx.db.insert("agents", { - name: args.name, - role: args.role, - sessionKey: args.sessionKey, - emoji: args.emoji, - config: args.config, + name: rest.name, + role: rest.role, + sessionKey: rest.sessionKey, + emoji: rest.emoji, + config: rest.config, status: "offline", + tenantId, createdAt: now, updatedAt: now, }); @@ -138,8 +161,10 @@ export const updateStatus = mutation({ args: { id: v.id("agents"), status: agentStatusValidator, + machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { + await resolveTenantIdMut(ctx, args); await ctx.db.patch(args.id, { status: args.status, updatedAt: Date.now(), @@ -149,14 +174,16 @@ export const updateStatus = mutation({ // Record agent heartbeat export const heartbeat = mutation({ - args: { sessionKey: v.string() }, + args: { sessionKey: v.string(), machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); const now = Date.now(); - const agent = await ctx.db + const agents = await ctx.db .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey)) - .first(); + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + const agent = agents.find((a) => a.sessionKey === args.sessionKey); if (!agent) { throw new Error(`Agent not found: ${args.sessionKey}`); @@ -178,6 +205,7 @@ export const heartbeat = mutation({ type: "agent_heartbeat", agentId: agent._id, message: `${agent.name} is online`, + tenantId: agent.tenantId, createdAt: now, }); } @@ -191,12 +219,15 @@ export const setCurrentTask = mutation({ args: { sessionKey: v.string(), taskId: v.optional(v.id("tasks")), + machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { - const agent = await ctx.db + const tenantId = await resolveTenantIdMut(ctx, args); + const agents = await ctx.db .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey)) - .first(); + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + const agent = agents.find((a) => a.sessionKey === args.sessionKey); if (!agent) { throw new Error(`Agent not found: ${args.sessionKey}`); @@ -214,12 +245,15 @@ export const setActivity = mutation({ args: { sessionKey: v.string(), activity: v.optional(v.string()), + machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { - const agent = await ctx.db + const tenantId = await resolveTenantIdMut(ctx, args); + const agents = await ctx.db .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey)) - .first(); + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + const agent = agents.find((a) => a.sessionKey === args.sessionKey); if (!agent) { throw new Error(`Agent not found: ${args.sessionKey}`); @@ -241,9 +275,11 @@ export const update = mutation({ role: v.optional(v.string()), emoji: v.optional(v.string()), config: v.optional(v.any()), + machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { - const { id, ...updates } = args; + await resolveTenantIdMut(ctx, args); + const { id, machineToken: _, ...updates } = args; const filteredUpdates = Object.fromEntries( Object.entries(updates).filter(([, value]) => value !== undefined), ); @@ -256,8 +292,9 @@ export const update = mutation({ // Remove agent export const remove = mutation({ - args: { id: v.id("agents") }, + args: { id: v.id("agents"), machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { + await resolveTenantIdMut(ctx, args); await ctx.db.delete(args.id); }, }); diff --git a/packages/backend/convex/businessContext.ts b/packages/backend/convex/businessContext.ts index db23d7a..9189873 100644 --- a/packages/backend/convex/businessContext.ts +++ b/packages/backend/convex/businessContext.ts @@ -1,15 +1,19 @@ import { query, mutation } from "./_generated/server"; import { v } from "convex/values"; +import { resolveTenantId, resolveTenantIdMut } from "./lib/auth"; /** * Get the current business context. * Returns null if not configured. */ export const get = query({ - args: {}, - handler: async (ctx) => { - // Only one business context should exist - get the first one - return await ctx.db.query("businessContext").first(); + args: { machineToken: v.optional(v.string()) }, + handler: async (ctx, args) => { + const tenantId = await resolveTenantId(ctx, args); + return await ctx.db + .query("businessContext") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .first(); }, }); @@ -17,9 +21,13 @@ export const get = query({ * Check if business context is configured and approved. */ export const isConfigured = query({ - args: {}, - handler: async (ctx) => { - const context = await ctx.db.query("businessContext").first(); + args: { machineToken: v.optional(v.string()) }, + handler: async (ctx, args) => { + const tenantId = await resolveTenantId(ctx, args); + const context = await ctx.db + .query("businessContext") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .first(); return context?.approved === true; }, }); @@ -30,6 +38,7 @@ export const isConfigured = query({ */ export const save = mutation({ args: { + machineToken: v.optional(v.string()), url: v.string(), name: v.optional(v.string()), description: v.optional(v.string()), @@ -47,17 +56,22 @@ export const save = mutation({ approved: v.optional(v.boolean()), }, handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); + const { machineToken: _, ...rest } = args; const now = Date.now(); - const existing = await ctx.db.query("businessContext").first(); + const existing = await ctx.db + .query("businessContext") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .first(); const data = { - url: args.url, - name: args.name, - description: args.description, - favicon: args.favicon, - metadata: args.metadata, - approved: args.approved ?? false, - approvedAt: args.approved ? now : undefined, + url: rest.url, + name: rest.name, + description: rest.description, + favicon: rest.favicon, + metadata: rest.metadata, + approved: rest.approved ?? false, + approvedAt: rest.approved ? now : undefined, updatedAt: now, }; @@ -68,6 +82,7 @@ export const save = mutation({ return await ctx.db.insert("businessContext", { ...data, + tenantId, createdAt: now, }); }, @@ -77,9 +92,13 @@ export const save = mutation({ * Mark the current business context as approved. */ export const approve = mutation({ - args: {}, - handler: async (ctx) => { - const existing = await ctx.db.query("businessContext").first(); + args: { machineToken: v.optional(v.string()) }, + handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); + const existing = await ctx.db + .query("businessContext") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .first(); if (!existing) { throw new Error("No business context to approve"); @@ -100,9 +119,13 @@ export const approve = mutation({ * Used for resetting onboarding. */ export const clear = mutation({ - args: {}, - handler: async (ctx) => { - const existing = await ctx.db.query("businessContext").first(); + args: { machineToken: v.optional(v.string()) }, + handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); + const existing = await ctx.db + .query("businessContext") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .first(); if (existing) { await ctx.db.delete(existing._id); diff --git a/packages/backend/convex/channels.ts b/packages/backend/convex/channels.ts index 575dca4..8416008 100644 --- a/packages/backend/convex/channels.ts +++ b/packages/backend/convex/channels.ts @@ -1,42 +1,59 @@ import { query, mutation } from "./_generated/server"; import { v } from "convex/values"; +import { resolveTenantId, resolveTenantIdMut } from "./lib/auth"; export const list = query({ - args: {}, - handler: async (ctx) => { - return await ctx.db.query("channels").collect(); + args: { machineToken: v.optional(v.string()) }, + handler: async (ctx, args) => { + const tenantId = await resolveTenantId(ctx, args); + return await ctx.db + .query("channels") + .withIndex("by_tenant_type", (q) => q.eq("tenantId", tenantId)) + .collect(); }, }); export const getByType = query({ - args: { type: v.string() }, + args: { + machineToken: v.optional(v.string()), + type: v.string(), + }, handler: async (ctx, args) => { + const tenantId = await resolveTenantId(ctx, args); return await ctx.db .query("channels") - .withIndex("by_type", (q) => q.eq("type", args.type)) + .withIndex("by_tenant_type", (q) => + q.eq("tenantId", tenantId).eq("type", args.type), + ) .first(); }, }); export const upsert = mutation({ args: { + machineToken: v.optional(v.string()), type: v.string(), status: v.union(v.literal("connected"), v.literal("disconnected")), accountId: v.optional(v.string()), metadata: v.optional(v.any()), }, handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); + const { machineToken: _, ...rest } = args; + const existing = await ctx.db .query("channels") - .withIndex("by_type", (q) => q.eq("type", args.type)) + .withIndex("by_tenant_type", (q) => + q.eq("tenantId", tenantId).eq("type", rest.type), + ) .first(); const data = { - type: args.type, - status: args.status, - accountId: args.accountId, - metadata: args.metadata, - connectedAt: args.status === "connected" ? Date.now() : undefined, + type: rest.type, + status: rest.status, + accountId: rest.accountId, + metadata: rest.metadata, + connectedAt: rest.status === "connected" ? Date.now() : undefined, }; if (existing) { @@ -44,16 +61,26 @@ export const upsert = mutation({ return existing._id; } - return await ctx.db.insert("channels", data); + return await ctx.db.insert("channels", { + ...data, + tenantId, + }); }, }); export const disconnect = mutation({ - args: { type: v.string() }, + args: { + machineToken: v.optional(v.string()), + type: v.string(), + }, handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); + const existing = await ctx.db .query("channels") - .withIndex("by_type", (q) => q.eq("type", args.type)) + .withIndex("by_tenant_type", (q) => + q.eq("tenantId", tenantId).eq("type", args.type), + ) .first(); if (existing) { diff --git a/packages/backend/convex/documents.ts b/packages/backend/convex/documents.ts index e166dd5..d74a434 100644 --- a/packages/backend/convex/documents.ts +++ b/packages/backend/convex/documents.ts @@ -1,9 +1,10 @@ import { v } from "convex/values"; import { action, mutation, query } from "./_generated/server"; +import { resolveTenantId, resolveTenantIdMut } from "./lib/auth"; // Generate upload URL for file storage export const generateUploadUrl = action({ - args: {}, + args: { machineToken: v.optional(v.string()) }, handler: async (ctx) => { return await ctx.storage.generateUploadUrl(); }, @@ -21,32 +22,38 @@ export const list = query({ ), ), limit: v.optional(v.number()), + machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { + const tenantId = await resolveTenantId(ctx, args); const limit = args.limit ?? 100; - if (args.type) { - const type = args.type; - return await ctx.db - .query("documents") - .withIndex("by_type", (q) => q.eq("type", type)) - .order("desc") - .take(limit); - } + const allDocs = await ctx.db + .query("documents") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .order("desc") + .collect(); - return await ctx.db.query("documents").order("desc").take(limit); + const filtered = args.type + ? allDocs.filter((doc) => doc.type === args.type) + : allDocs; + + return filtered.slice(0, limit); }, }); // Get documents for a task (deliverables) export const getForTask = query({ - args: { taskId: v.id("tasks") }, + args: { taskId: v.id("tasks"), machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { - const documents = await ctx.db + const tenantId = await resolveTenantId(ctx, args); + const allDocs = await ctx.db .query("documents") - .withIndex("by_task", (q) => q.eq("taskId", args.taskId)) + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect(); + const documents = allDocs.filter((doc) => doc.taskId === args.taskId); + // Enrich with creator info and file URL return Promise.all( documents.map(async (doc) => { @@ -69,8 +76,9 @@ export const getForTask = query({ // Get document by ID export const get = query({ - args: { id: v.id("documents") }, + args: { id: v.id("documents"), machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { + await resolveTenantId(ctx, args); return await ctx.db.get(args.id); }, }); @@ -89,29 +97,33 @@ export const create = mutation({ ), taskId: v.optional(v.id("tasks")), createdBySessionKey: v.string(), + machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); + const { machineToken: _, ...rest } = args; const now = Date.now(); // Find the creator agent const agent = await ctx.db .query("agents") .withIndex("by_sessionKey", (q) => - q.eq("sessionKey", args.createdBySessionKey), + q.eq("sessionKey", rest.createdBySessionKey), ) .first(); if (!agent) { - throw new Error(`Agent not found: ${args.createdBySessionKey}`); + throw new Error(`Agent not found: ${rest.createdBySessionKey}`); } const documentId = await ctx.db.insert("documents", { - title: args.title, - content: args.content, - path: args.path, - type: args.type, - taskId: args.taskId, + title: rest.title, + content: rest.content, + path: rest.path, + type: rest.type, + taskId: rest.taskId, createdBy: agent._id, + tenantId, createdAt: now, updatedAt: now, }); @@ -120,8 +132,9 @@ export const create = mutation({ await ctx.db.insert("activities", { type: "document_created", agentId: agent._id, - taskId: args.taskId, - message: `${agent.name} created ${args.type}: ${args.title}`, + taskId: rest.taskId, + message: `${agent.name} created ${rest.type}: ${rest.title}`, + tenantId, createdAt: now, }); @@ -137,28 +150,32 @@ export const registerDeliverable = mutation({ fileId: v.optional(v.id("_storage")), taskId: v.id("tasks"), createdBySessionKey: v.string(), + machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); + const { machineToken: _, ...rest } = args; const now = Date.now(); const agent = await ctx.db .query("agents") .withIndex("by_sessionKey", (q) => - q.eq("sessionKey", args.createdBySessionKey), + q.eq("sessionKey", rest.createdBySessionKey), ) .first(); if (!agent) { - throw new Error(`Agent not found: ${args.createdBySessionKey}`); + throw new Error(`Agent not found: ${rest.createdBySessionKey}`); } const documentId = await ctx.db.insert("documents", { - title: args.title, - path: args.path, - fileId: args.fileId, + title: rest.title, + path: rest.path, + fileId: rest.fileId, type: "deliverable", - taskId: args.taskId, + taskId: rest.taskId, createdBy: agent._id, + tenantId, createdAt: now, updatedAt: now, }); @@ -167,8 +184,9 @@ export const registerDeliverable = mutation({ await ctx.db.insert("activities", { type: "document_created", agentId: agent._id, - taskId: args.taskId, - message: `${agent.name} registered deliverable: ${args.title}`, + taskId: rest.taskId, + message: `${agent.name} registered deliverable: ${rest.title}`, + tenantId, createdAt: now, }); @@ -183,9 +201,11 @@ export const update = mutation({ title: v.optional(v.string()), content: v.optional(v.string()), path: v.optional(v.string()), + machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { - const { id, ...updates } = args; + await resolveTenantIdMut(ctx, args); + const { id, machineToken: _, ...updates } = args; const filteredUpdates = Object.fromEntries( Object.entries(updates).filter(([, value]) => value !== undefined), ); @@ -199,8 +219,9 @@ export const update = mutation({ // Delete a document export const remove = mutation({ - args: { id: v.id("documents") }, + args: { id: v.id("documents"), machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { + await resolveTenantIdMut(ctx, args); await ctx.db.delete(args.id); }, }); diff --git a/packages/backend/convex/lib/auth.ts b/packages/backend/convex/lib/auth.ts new file mode 100644 index 0000000..414f1d1 --- /dev/null +++ b/packages/backend/convex/lib/auth.ts @@ -0,0 +1,230 @@ +import type { Id } from "../_generated/dataModel"; +import type { QueryCtx, MutationCtx } from "../_generated/server"; + +type ReadCtx = { db: QueryCtx["db"]; auth: QueryCtx["auth"] }; + +const DEV_TENANT_EMAIL = "dev@clawe.local"; + +/** + * Browser path: get the current user from JWT identity. + * Looks up the `users` table by the email from the auth identity. + */ +export async function getUser(ctx: ReadCtx) { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + throw new Error("Not authenticated"); + } + + const email = identity.email; + if (!email) { + throw new Error("No email in auth identity"); + } + + const user = await ctx.db + .query("users") + .withIndex("by_email", (q) => q.eq("email", email)) + .first(); + + if (!user) { + throw new Error(`User not found for email: ${email}`); + } + + return user; +} + +/** + * Resolve tenantId from user via accountMembers → accounts → tenants. + * Used by both JWT and dev-mode paths. + */ +async function getTenantIdFromUser( + ctx: ReadCtx, + userId: Id<"users">, +): Promise | null> { + const membership = await ctx.db + .query("accountMembers") + .withIndex("by_user", (q) => q.eq("userId", userId)) + .first(); + + if (!membership) { + return null; + } + + const tenant = await ctx.db + .query("tenants") + .withIndex("by_account", (q) => q.eq("accountId", membership.accountId)) + .first(); + + if (!tenant) { + return null; + } + + return tenant._id; +} + +/** + * Browser path: resolve tenantId from the JWT identity. + * Gets the user, then looks up: accountMembers → account → tenants. + */ +export async function getTenantIdFromJwt( + ctx: ReadCtx, +): Promise> { + const user = await getUser(ctx); + + const tenantId = await getTenantIdFromUser(ctx, user._id); + + if (!tenantId) { + throw new Error("No tenant found for user"); + } + + return tenantId; +} + +/** + * Machine path (CLI): resolve tenantId from a per-tenant squadhub token. + * Queries the `tenants` table by the `by_squadhubToken` index. + */ +export async function getTenantIdFromToken( + ctx: ReadCtx, + machineToken: string, +): Promise> { + const tenant = await ctx.db + .query("tenants") + .withIndex("by_squadhubToken", (q) => q.eq("squadhubToken", machineToken)) + .first(); + + if (!tenant) { + throw new Error("Invalid machine token"); + } + + return tenant._id; +} + +/** + * Machine path (watcher): validate system-level watcher token. + * Compares against `WATCHER_TOKEN` Convex env var. + */ +export function validateWatcherToken( + _ctx: ReadCtx, + watcherToken: string, +): void { + const expected = process.env.WATCHER_TOKEN; + if (!expected || watcherToken !== expected) { + throw new Error("Invalid watcher token"); + } +} + +/** + * Dev-only: get or create a default dev tenant (used when AUTH_ENABLED=false). + */ +async function getOrCreateDevTenant( + ctx: MutationCtx, +): Promise> { + // Look for existing dev user + let user = await ctx.db + .query("users") + .withIndex("by_email", (q) => q.eq("email", DEV_TENANT_EMAIL)) + .first(); + + if (!user) { + const now = Date.now(); + const userId = await ctx.db.insert("users", { + email: DEV_TENANT_EMAIL, + name: "Dev User", + createdAt: now, + updatedAt: now, + }); + user = (await ctx.db.get(userId))!; + } + + // Look for existing tenant via accountMembers → account → tenants + const tenantId = await getTenantIdFromUser(ctx, user._id); + if (tenantId) { + return tenantId; + } + + // Create account + accountMembers + tenant + const now = Date.now(); + const accountId = await ctx.db.insert("accounts", { + name: "Dev Account", + createdAt: now, + updatedAt: now, + }); + + await ctx.db.insert("accountMembers", { + userId: user._id, + accountId, + role: "owner", + createdAt: now, + }); + + const newTenantId = await ctx.db.insert("tenants", { + accountId, + status: "active", + createdAt: now, + updatedAt: now, + }); + + return newTenantId; +} + +/** + * Unified tenant resolver for all tenant-scoped functions. + * + * Resolution order: + * 1. If `machineToken` provided → look up tenant by squadhub token + * 2. If `AUTH_ENABLED=true` → resolve from JWT identity + * 3. If `AUTH_ENABLED=false` → get or create default dev tenant + * + * Use this in queries (read-only ctx) when AUTH_ENABLED is true or machineToken is provided. + * Use `resolveTenantIdMut` in mutations when dev tenant auto-creation may be needed. + */ +export async function resolveTenantId( + ctx: ReadCtx, + args: { machineToken?: string }, +): Promise> { + if (args.machineToken) { + return getTenantIdFromToken(ctx, args.machineToken); + } + + const authEnabled = process.env.AUTH_ENABLED !== "false"; + + if (authEnabled) { + return getTenantIdFromJwt(ctx); + } + + // AUTH_ENABLED=false in a read-only context — try to find existing dev tenant + const user = await ctx.db + .query("users") + .withIndex("by_email", (q) => q.eq("email", DEV_TENANT_EMAIL)) + .first(); + + if (user) { + const tenantId = await getTenantIdFromUser(ctx, user._id); + if (tenantId) { + return tenantId; + } + } + + throw new Error("No dev tenant found. Run a mutation first to auto-create it."); +} + +/** + * Mutation variant of resolveTenantId that can auto-create a dev tenant. + * Use this in mutations instead of `resolveTenantId` when AUTH_ENABLED=false. + */ +export async function resolveTenantIdMut( + ctx: MutationCtx, + args: { machineToken?: string }, +): Promise> { + if (args.machineToken) { + return getTenantIdFromToken(ctx, args.machineToken); + } + + const authEnabled = process.env.AUTH_ENABLED !== "false"; + + if (authEnabled) { + return getTenantIdFromJwt(ctx); + } + + return getOrCreateDevTenant(ctx); +} diff --git a/packages/backend/convex/messages.ts b/packages/backend/convex/messages.ts index dbf8954..383e39d 100644 --- a/packages/backend/convex/messages.ts +++ b/packages/backend/convex/messages.ts @@ -1,15 +1,19 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; +import { resolveTenantId, resolveTenantIdMut } from "./lib/auth"; // List messages for a task export const listForTask = query({ - args: { taskId: v.id("tasks") }, + args: { taskId: v.id("tasks"), machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { - const messages = await ctx.db + const tenantId = await resolveTenantId(ctx, args); + const allMessages = await ctx.db .query("messages") - .withIndex("by_task", (q) => q.eq("taskId", args.taskId)) + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect(); + const messages = allMessages.filter((m) => m.taskId === args.taskId); + // Enrich with author info return Promise.all( messages.map(async (m) => { @@ -38,37 +42,49 @@ export const listByAgent = query({ args: { sessionKey: v.string(), limit: v.optional(v.number()), + machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { - const agent = await ctx.db + const tenantId = await resolveTenantId(ctx, args); + + const agents = await ctx.db .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey)) - .first(); + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + const agent = agents.find((a) => a.sessionKey === args.sessionKey); if (!agent) { return []; } - let query = ctx.db + const allMessages = await ctx.db .query("messages") - .withIndex("by_agent", (q) => q.eq("fromAgentId", agent._id)) - .order("desc"); + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); - return args.limit ? await query.take(args.limit) : await query.collect(); + const agentMessages = allMessages + .filter((m) => m.fromAgentId === agent._id) + .sort((a, b) => b.createdAt - a.createdAt); + + return args.limit ? agentMessages.slice(0, args.limit) : agentMessages; }, }); // Get recent messages export const recent = query({ - args: { limit: v.optional(v.number()) }, + args: { limit: v.optional(v.number()), machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { + const tenantId = await resolveTenantId(ctx, args); const limit = args.limit ?? 50; - const messages = await ctx.db + const allMessages = await ctx.db .query("messages") - .withIndex("by_created") - .order("desc") - .take(limit); + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + + const messages = allMessages + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, limit); // Enrich with author and task info return Promise.all( @@ -113,13 +129,16 @@ export const create = mutation({ ), fromSessionKey: v.optional(v.string()), humanAuthor: v.optional(v.string()), + machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); + const { machineToken: _, ...rest } = args; const now = Date.now(); let fromAgentId = undefined; - if (args.fromSessionKey) { - const sessionKey = args.fromSessionKey; + if (rest.fromSessionKey) { + const sessionKey = rest.fromSessionKey; const agent = await ctx.db .query("agents") .withIndex("by_sessionKey", (q) => q.eq("sessionKey", sessionKey)) @@ -130,17 +149,18 @@ export const create = mutation({ } const messageId = await ctx.db.insert("messages", { - taskId: args.taskId, + tenantId, + taskId: rest.taskId, fromAgentId, - humanAuthor: args.humanAuthor, - type: args.type ?? "comment", - content: args.content, + humanAuthor: rest.humanAuthor, + type: rest.type ?? "comment", + content: rest.content, createdAt: now, }); // Update task timestamp if linked to a task - if (args.taskId) { - await ctx.db.patch(args.taskId, { updatedAt: now }); + if (rest.taskId) { + await ctx.db.patch(rest.taskId, { updatedAt: now }); } return messageId; @@ -149,8 +169,9 @@ export const create = mutation({ // Delete a message export const remove = mutation({ - args: { id: v.id("messages") }, + args: { id: v.id("messages"), machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { + await resolveTenantIdMut(ctx, args); await ctx.db.delete(args.id); }, }); diff --git a/packages/backend/convex/notifications.ts b/packages/backend/convex/notifications.ts index 5b22f71..bc009ef 100644 --- a/packages/backend/convex/notifications.ts +++ b/packages/backend/convex/notifications.ts @@ -1,15 +1,19 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; +import { resolveTenantId, resolveTenantIdMut } from "./lib/auth"; // Get undelivered notifications for an agent (by session key) export const getUndelivered = query({ - args: { sessionKey: v.string() }, + args: { sessionKey: v.string(), machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { - // Find the agent - const agent = await ctx.db + const tenantId = await resolveTenantId(ctx, args); + + // Find the agent within this tenant + const agents = await ctx.db .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey)) - .first(); + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + const agent = agents.find((a) => a.sessionKey === args.sessionKey); if (!agent) { return []; @@ -59,12 +63,16 @@ export const getForAgent = query({ args: { sessionKey: v.string(), limit: v.optional(v.number()), + machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { - const agent = await ctx.db + const tenantId = await resolveTenantId(ctx, args); + + const agents = await ctx.db .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey)) - .first(); + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + const agent = agents.find((a) => a.sessionKey === args.sessionKey); if (!agent) { return []; @@ -87,8 +95,10 @@ export const getForAgent = query({ export const markDelivered = mutation({ args: { notificationIds: v.array(v.id("notifications")), + machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { + await resolveTenantIdMut(ctx, args); const now = Date.now(); for (const id of args.notificationIds) { @@ -116,16 +126,19 @@ export const send = mutation({ ), taskId: v.optional(v.id("tasks")), content: v.string(), + machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); const now = Date.now(); const targetKey = args.targetSessionKey; - // Find target agent - const targetAgent = await ctx.db + // Find target agent within this tenant + const agents = await ctx.db .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", targetKey)) - .first(); + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + const targetAgent = agents.find((a) => a.sessionKey === targetKey); if (!targetAgent) { throw new Error(`Target agent not found: ${args.targetSessionKey}`); @@ -134,11 +147,9 @@ export const send = mutation({ // Find source agent if provided let sourceAgentId = undefined; if (args.sourceSessionKey) { - const sourceKey = args.sourceSessionKey; - const sourceAgent = await ctx.db - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", sourceKey)) - .first(); + const sourceAgent = agents.find( + (a) => a.sessionKey === args.sourceSessionKey, + ); if (sourceAgent) { sourceAgentId = sourceAgent._id; } @@ -146,6 +157,7 @@ export const send = mutation({ // Create notification const notificationId = await ctx.db.insert("notifications", { + tenantId, targetAgentId: targetAgent._id, sourceAgentId, type: args.type, @@ -157,6 +169,7 @@ export const send = mutation({ // Log activity await ctx.db.insert("activities", { + tenantId, type: "notification_sent", agentId: sourceAgentId, taskId: args.taskId, @@ -184,32 +197,38 @@ export const sendToMany = mutation({ ), taskId: v.optional(v.id("tasks")), content: v.string(), + machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); const now = Date.now(); const notificationIds: string[] = []; + // Load all agents for this tenant once + const agents = await ctx.db + .query("agents") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + // Find source agent if provided let sourceAgentId = undefined; if (args.sourceSessionKey) { - const sourceKey = args.sourceSessionKey; - const sourceAgent = await ctx.db - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", sourceKey)) - .first(); + const sourceAgent = agents.find( + (a) => a.sessionKey === args.sourceSessionKey, + ); if (sourceAgent) { sourceAgentId = sourceAgent._id; } } for (const targetSessionKey of args.targetSessionKeys) { - const targetAgent = await ctx.db - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", targetSessionKey)) - .first(); + const targetAgent = agents.find( + (a) => a.sessionKey === targetSessionKey, + ); if (targetAgent) { const id = await ctx.db.insert("notifications", { + tenantId, targetAgentId: targetAgent._id, sourceAgentId, type: args.type, @@ -228,12 +247,15 @@ export const sendToMany = mutation({ // Clear all notifications for an agent (mark all as delivered) export const clearAll = mutation({ - args: { sessionKey: v.string() }, + args: { sessionKey: v.string(), machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { - const agent = await ctx.db + const tenantId = await resolveTenantIdMut(ctx, args); + + const agents = await ctx.db .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey)) - .first(); + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + const agent = agents.find((a) => a.sessionKey === args.sessionKey); if (!agent) { return 0; diff --git a/packages/backend/convex/routines.ts b/packages/backend/convex/routines.ts index 8351810..68f98b6 100644 --- a/packages/backend/convex/routines.ts +++ b/packages/backend/convex/routines.ts @@ -1,5 +1,6 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; +import { resolveTenantId, resolveTenantIdMut } from "./lib/auth"; // Schedule validator (reusable) const scheduleValidator = v.object({ @@ -21,22 +22,32 @@ const priorityValidator = v.optional( // List all routines (or only enabled ones) export const list = query({ - args: { enabledOnly: v.optional(v.boolean()) }, + args: { + machineToken: v.optional(v.string()), + enabledOnly: v.optional(v.boolean()), + }, handler: async (ctx, args) => { + const tenantId = await resolveTenantId(ctx, args); + const routines = await ctx.db + .query("routines") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + if (args.enabledOnly) { - return await ctx.db - .query("routines") - .withIndex("by_enabled", (q) => q.eq("enabled", true)) - .collect(); + return routines.filter((r) => r.enabled); } - return await ctx.db.query("routines").collect(); + return routines; }, }); // Get a single routine by ID export const get = query({ - args: { routineId: v.id("routines") }, + args: { + machineToken: v.optional(v.string()), + routineId: v.id("routines"), + }, handler: async (ctx, args) => { + await resolveTenantId(ctx, args); return await ctx.db.get(args.routineId); }, }); @@ -44,6 +55,7 @@ export const get = query({ // Create a new routine export const create = mutation({ args: { + machineToken: v.optional(v.string()), title: v.string(), description: v.optional(v.string()), priority: priorityValidator, @@ -51,13 +63,16 @@ export const create = mutation({ color: v.string(), }, handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); + const { machineToken: _, ...rest } = args; const now = Date.now(); return await ctx.db.insert("routines", { - title: args.title, - description: args.description, - priority: args.priority, - schedule: args.schedule, - color: args.color, + tenantId, + title: rest.title, + description: rest.description, + priority: rest.priority, + schedule: rest.schedule, + color: rest.color, enabled: true, createdAt: now, updatedAt: now, @@ -68,6 +83,7 @@ export const create = mutation({ // Update routine details export const update = mutation({ args: { + machineToken: v.optional(v.string()), routineId: v.id("routines"), title: v.optional(v.string()), description: v.optional(v.string()), @@ -77,7 +93,8 @@ export const update = mutation({ enabled: v.optional(v.boolean()), }, handler: async (ctx, args) => { - const { routineId, ...updates } = args; + await resolveTenantIdMut(ctx, args); + const { routineId, machineToken: _, ...updates } = args; // Filter out undefined values const filteredUpdates = Object.fromEntries( @@ -93,16 +110,24 @@ export const update = mutation({ // Delete a routine export const remove = mutation({ - args: { routineId: v.id("routines") }, + args: { + machineToken: v.optional(v.string()), + routineId: v.id("routines"), + }, handler: async (ctx, args) => { + await resolveTenantIdMut(ctx, args); await ctx.db.delete(args.routineId); }, }); // Trigger a routine - create a task from the routine template export const trigger = mutation({ - args: { routineId: v.id("routines") }, + args: { + machineToken: v.optional(v.string()), + routineId: v.id("routines"), + }, handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); const routine = await ctx.db.get(args.routineId); if (!routine) { throw new Error("Routine not found"); @@ -136,6 +161,7 @@ export const trigger = mutation({ // Create task from routine template const taskId = await ctx.db.insert("tasks", { + tenantId, title: routine.title, description: routine.description, priority: routine.priority ?? "normal", @@ -153,6 +179,7 @@ export const trigger = mutation({ // Log activity await ctx.db.insert("activities", { + tenantId, type: "task_created", agentId: clawe?._id, taskId, @@ -177,20 +204,24 @@ export const trigger = mutation({ */ export const getDueRoutines = query({ args: { + machineToken: v.optional(v.string()), currentTimestamp: v.number(), // Current UTC timestamp from watcher dayOfWeek: v.number(), // Current day in user's timezone (0-6) hour: v.number(), // Current hour in user's timezone (0-23) minute: v.number(), // Current minute in user's timezone (0-59) }, handler: async (ctx, args) => { + const tenantId = await resolveTenantId(ctx, args); const { currentTimestamp, dayOfWeek, hour, minute } = args; - // Get all enabled routines + // Get all enabled routines for this tenant const routines = await ctx.db .query("routines") - .withIndex("by_enabled", (q) => q.eq("enabled", true)) + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect(); + const enabledRoutines = routines.filter((r) => r.enabled); + // Current time as minutes since midnight (in user's timezone) const currentMinuteOfDay = hour * 60 + minute; @@ -200,7 +231,7 @@ export const getDueRoutines = query({ cycleStart: number; }> = []; - for (const routine of routines) { + for (const routine of enabledRoutines) { // Check if today is a scheduled day if (!routine.schedule.daysOfWeek.includes(dayOfWeek)) { continue; diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 34da711..4691edf 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -2,12 +2,66 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; export default defineSchema({ + // Users - Human users (created from Cognito JWT on first login) + users: defineTable({ + email: v.string(), + name: v.optional(v.string()), + createdAt: v.number(), + updatedAt: v.number(), + }).index("by_email", ["email"]), + + // Accounts - Billing/organizational unit (one auto-created per user on signup) + accounts: defineTable({ + name: v.optional(v.string()), + createdAt: v.number(), + updatedAt: v.number(), + }), + + // Account Members - join table linking users to accounts + accountMembers: defineTable({ + userId: v.id("users"), + accountId: v.id("accounts"), + role: v.union(v.literal("owner"), v.literal("member")), + createdAt: v.number(), + }) + .index("by_user", ["userId"]) + .index("by_account", ["accountId"]) + .index("by_user_account", ["userId", "accountId"]), + + // Tenants - One per account, owns a squadhub instance + tenants: defineTable({ + accountId: v.id("accounts"), + status: v.union( + v.literal("provisioning"), + v.literal("active"), + v.literal("stopped"), + v.literal("error"), + ), + squadhubUrl: v.optional(v.string()), + squadhubToken: v.optional(v.string()), + squadhubServiceArn: v.optional(v.string()), + efsAccessPointId: v.optional(v.string()), + anthropicApiKey: v.optional(v.string()), + settings: v.optional( + v.object({ + timezone: v.optional(v.string()), + onboardingComplete: v.optional(v.boolean()), + }), + ), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_account", ["accountId"]) + .index("by_status", ["status"]) + .index("by_squadhubToken", ["squadhubToken"]), + // Agents - AI agent profiles with coordination support agents: defineTable({ + tenantId: v.id("tenants"), name: v.string(), role: v.string(), // "Squad Lead", "Content Writer", etc. emoji: v.optional(v.string()), // "🦞", "✍️", etc. - sessionKey: v.string(), // "agent:main:main" - agency session key + sessionKey: v.string(), // "agent:main:main" - squadhub session key status: v.union(v.literal("online"), v.literal("offline")), currentTaskId: v.optional(v.id("tasks")), config: v.optional(v.any()), // Agent-specific configuration @@ -17,12 +71,14 @@ export default defineSchema({ createdAt: v.number(), updatedAt: v.number(), }) + .index("by_tenant", ["tenantId"]) .index("by_sessionKey", ["sessionKey"]) .index("by_status", ["status"]) .index("by_lastSeen", ["lastSeen"]), // Tasks - Mission queue with full workflow support tasks: defineTable({ + tenantId: v.id("tenants"), title: v.string(), description: v.optional(v.string()), status: v.union( @@ -70,11 +126,13 @@ export default defineSchema({ updatedAt: v.number(), completedAt: v.optional(v.number()), }) + .index("by_tenant", ["tenantId"]) .index("by_status", ["status"]) .index("by_createdAt", ["createdAt"]), // Messages - Task comments and agent communication messages: defineTable({ + tenantId: v.id("tenants"), taskId: v.optional(v.id("tasks")), fromAgentId: v.optional(v.id("agents")), // Optional for human users humanAuthor: v.optional(v.string()), // For human commenters @@ -88,12 +146,14 @@ export default defineSchema({ metadata: v.optional(v.any()), createdAt: v.number(), }) + .index("by_tenant", ["tenantId"]) .index("by_task", ["taskId"]) .index("by_agent", ["fromAgentId"]) .index("by_created", ["createdAt"]), // Notifications - Agent-to-agent coordination notifications: defineTable({ + tenantId: v.id("tenants"), targetAgentId: v.id("agents"), // Who receives this sourceAgentId: v.optional(v.id("agents")), // Who triggered it (optional for system) type: v.union( @@ -111,12 +171,14 @@ export default defineSchema({ deliveredAt: v.optional(v.number()), createdAt: v.number(), }) + .index("by_tenant", ["tenantId"]) .index("by_target_undelivered", ["targetAgentId", "delivered"]) .index("by_target", ["targetAgentId"]) .index("by_createdAt", ["createdAt"]), // Activities - Audit log / activity feed activities: defineTable({ + tenantId: v.id("tenants"), type: v.union( v.literal("task_created"), v.literal("task_assigned"), @@ -133,6 +195,7 @@ export default defineSchema({ metadata: v.optional(v.any()), createdAt: v.number(), }) + .index("by_tenant", ["tenantId"]) .index("by_type", ["type"]) .index("by_agent", ["agentId"]) .index("by_task", ["taskId"]) @@ -140,6 +203,7 @@ export default defineSchema({ // Documents - Deliverables and file references documents: defineTable({ + tenantId: v.id("tenants"), title: v.string(), content: v.optional(v.string()), // Markdown content (for text docs) path: v.optional(v.string()), // File path (for file deliverables) @@ -155,19 +219,14 @@ export default defineSchema({ createdAt: v.number(), updatedAt: v.number(), }) + .index("by_tenant", ["tenantId"]) .index("by_task", ["taskId"]) .index("by_type", ["type"]) .index("by_agent", ["createdBy"]), - // Settings - Key-value store for app configuration - settings: defineTable({ - key: v.string(), - value: v.any(), - updatedAt: v.number(), - }).index("by_key", ["key"]), - // Business Context - Website/business info for agent context businessContext: defineTable({ + tenantId: v.id("tenants"), url: v.string(), name: v.optional(v.string()), description: v.optional(v.string()), @@ -186,19 +245,23 @@ export default defineSchema({ approvedAt: v.optional(v.number()), createdAt: v.number(), updatedAt: v.number(), - }), + }).index("by_tenant", ["tenantId"]), // Channels - Connected messaging channels (Telegram, etc.) channels: defineTable({ + tenantId: v.id("tenants"), type: v.string(), status: v.union(v.literal("connected"), v.literal("disconnected")), accountId: v.optional(v.string()), connectedAt: v.optional(v.number()), metadata: v.optional(v.any()), - }).index("by_type", ["type"]), + }) + .index("by_tenant_type", ["tenantId", "type"]) + .index("by_type", ["type"]), // Routines - Recurring task templates with schedules routines: defineTable({ + tenantId: v.id("tenants"), // Template info (used to create tasks) title: v.string(), description: v.optional(v.string()), @@ -230,5 +293,7 @@ export default defineSchema({ // Metadata createdAt: v.number(), updatedAt: v.number(), - }).index("by_enabled", ["enabled"]), + }) + .index("by_tenant", ["tenantId"]) + .index("by_enabled", ["enabled"]), }); diff --git a/packages/backend/convex/settings.ts b/packages/backend/convex/settings.ts deleted file mode 100644 index 8bc9878..0000000 --- a/packages/backend/convex/settings.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { query, mutation } from "./_generated/server"; -import { v } from "convex/values"; - -export const get = query({ - args: { key: v.string() }, - handler: async (ctx, args) => { - return await ctx.db - .query("settings") - .withIndex("by_key", (q) => q.eq("key", args.key)) - .first(); - }, -}); - -export const set = mutation({ - args: { key: v.string(), value: v.any() }, - handler: async (ctx, args) => { - const existing = await ctx.db - .query("settings") - .withIndex("by_key", (q) => q.eq("key", args.key)) - .first(); - - if (existing) { - await ctx.db.patch(existing._id, { - value: args.value, - updatedAt: Date.now(), - }); - return existing._id; - } - - return await ctx.db.insert("settings", { - key: args.key, - value: args.value, - updatedAt: Date.now(), - }); - }, -}); - -export const isOnboardingComplete = query({ - args: {}, - handler: async (ctx) => { - const setting = await ctx.db - .query("settings") - .withIndex("by_key", (q) => q.eq("key", "onboarding_complete")) - .first(); - return setting?.value === true; - }, -}); - -export const completeOnboarding = mutation({ - args: {}, - handler: async (ctx) => { - const existing = await ctx.db - .query("settings") - .withIndex("by_key", (q) => q.eq("key", "onboarding_complete")) - .first(); - - if (existing) { - await ctx.db.patch(existing._id, { - value: true, - updatedAt: Date.now(), - }); - return existing._id; - } - - return await ctx.db.insert("settings", { - key: "onboarding_complete", - value: true, - updatedAt: Date.now(), - }); - }, -}); - -// Timezone settings -const DEFAULT_TIMEZONE = "America/New_York"; - -export const getTimezone = query({ - args: {}, - handler: async (ctx) => { - const setting = await ctx.db - .query("settings") - .withIndex("by_key", (q) => q.eq("key", "timezone")) - .first(); - return (setting?.value as string) ?? DEFAULT_TIMEZONE; - }, -}); - -export const setTimezone = mutation({ - args: { timezone: v.string() }, - handler: async (ctx, args) => { - const existing = await ctx.db - .query("settings") - .withIndex("by_key", (q) => q.eq("key", "timezone")) - .first(); - - if (existing) { - await ctx.db.patch(existing._id, { - value: args.timezone, - updatedAt: Date.now(), - }); - return existing._id; - } - - return await ctx.db.insert("settings", { - key: "timezone", - value: args.timezone, - updatedAt: Date.now(), - }); - }, -}); diff --git a/packages/backend/convex/tasks.ts b/packages/backend/convex/tasks.ts index 8abdffe..ee1f0a8 100644 --- a/packages/backend/convex/tasks.ts +++ b/packages/backend/convex/tasks.ts @@ -1,10 +1,12 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; import type { Id } from "./_generated/dataModel"; +import { resolveTenantId, resolveTenantIdMut } from "./lib/auth"; // List all tasks with optional filters export const list = query({ args: { + machineToken: v.optional(v.string()), status: v.optional( v.union( v.literal("inbox"), @@ -17,21 +19,16 @@ export const list = query({ limit: v.optional(v.number()), }, handler: async (ctx, args) => { - let tasks; + const tenantId = await resolveTenantId(ctx, args); + + let tasks = await ctx.db + .query("tasks") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .order("desc") + .collect(); if (args.status) { - const status = args.status; - tasks = await ctx.db - .query("tasks") - .withIndex("by_status", (q) => q.eq("status", status)) - .order("desc") - .collect(); - } else { - tasks = await ctx.db - .query("tasks") - .withIndex("by_createdAt") - .order("desc") - .collect(); + tasks = tasks.filter((t) => t.status === args.status); } if (args.limit) { @@ -74,8 +71,13 @@ export const list = query({ // Get tasks for a specific agent export const getForAgent = query({ - args: { sessionKey: v.string() }, + args: { + machineToken: v.optional(v.string()), + sessionKey: v.string(), + }, handler: async (ctx, args) => { + const tenantId = await resolveTenantId(ctx, args); + const agent = await ctx.db .query("agents") .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey)) @@ -85,10 +87,10 @@ export const getForAgent = query({ return []; } - // Get all non-done tasks and filter by assignee + // Get all tasks for this tenant and filter by assignee const allTasks = await ctx.db .query("tasks") - .withIndex("by_createdAt") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .order("desc") .collect(); @@ -100,8 +102,13 @@ export const getForAgent = query({ // Get task by ID with full details export const get = query({ - args: { taskId: v.id("tasks") }, + args: { + machineToken: v.optional(v.string()), + taskId: v.id("tasks"), + }, handler: async (ctx, args) => { + await resolveTenantId(ctx, args); + const task = await ctx.db.get(args.taskId); if (!task) return null; @@ -188,6 +195,7 @@ export const get = query({ // Create a new task export const create = mutation({ args: { + machineToken: v.optional(v.string()), title: v.string(), description: v.optional(v.string()), priority: v.optional( @@ -202,6 +210,7 @@ export const create = mutation({ createdBySessionKey: v.optional(v.string()), }, handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); const now = Date.now(); // Find assignee if provided @@ -232,6 +241,7 @@ export const create = mutation({ } const taskId = await ctx.db.insert("tasks", { + tenantId, title: args.title, description: args.description, status: assigneeIds.length > 0 ? "assigned" : "inbox", @@ -244,6 +254,7 @@ export const create = mutation({ // Log activity await ctx.db.insert("activities", { + tenantId, type: "task_created", agentId: createdBy, taskId, @@ -257,6 +268,7 @@ export const create = mutation({ const assignee = await ctx.db.get(firstAssigneeId); if (assignee) { await ctx.db.insert("notifications", { + tenantId, targetAgentId: assignee._id, sourceAgentId: createdBy, type: "task_assigned", @@ -275,6 +287,7 @@ export const create = mutation({ // Update task status export const updateStatus = mutation({ args: { + machineToken: v.optional(v.string()), taskId: v.id("tasks"), status: v.union( v.literal("inbox"), @@ -286,6 +299,7 @@ export const updateStatus = mutation({ bySessionKey: v.optional(v.string()), }, handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); const now = Date.now(); const task = await ctx.db.get(args.taskId); if (!task) throw new Error("Task not found"); @@ -337,6 +351,7 @@ export const updateStatus = mutation({ // Log activity await ctx.db.insert("activities", { + tenantId, type: "task_status_changed", agentId, taskId: args.taskId, @@ -348,6 +363,7 @@ export const updateStatus = mutation({ // Send notifications for review if (args.status === "review" && task.createdBy) { await ctx.db.insert("notifications", { + tenantId, targetAgentId: task.createdBy, sourceAgentId: agentId, type: "review_requested", @@ -363,10 +379,12 @@ export const updateStatus = mutation({ // Approve a task in review → done export const approve = mutation({ args: { + machineToken: v.optional(v.string()), taskId: v.id("tasks"), humanAuthor: v.optional(v.string()), }, handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); const now = Date.now(); const task = await ctx.db.get(args.taskId); if (!task) throw new Error("Task not found"); @@ -383,6 +401,7 @@ export const approve = mutation({ // Add comment await ctx.db.insert("messages", { + tenantId, taskId: args.taskId, humanAuthor: authorName, type: "comment", @@ -392,6 +411,7 @@ export const approve = mutation({ // Log activity await ctx.db.insert("activities", { + tenantId, type: "task_status_changed", taskId: args.taskId, message: `${authorName} approved "${task.title}"`, @@ -403,6 +423,7 @@ export const approve = mutation({ if (task.assigneeIds) { for (const assigneeId of task.assigneeIds) { await ctx.db.insert("notifications", { + tenantId, targetAgentId: assigneeId, type: "task_completed", taskId: args.taskId, @@ -418,11 +439,13 @@ export const approve = mutation({ // Request changes on a task in review → back to in_progress export const requestChanges = mutation({ args: { + machineToken: v.optional(v.string()), taskId: v.id("tasks"), feedback: v.string(), humanAuthor: v.optional(v.string()), }, handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); const now = Date.now(); const task = await ctx.db.get(args.taskId); if (!task) throw new Error("Task not found"); @@ -438,6 +461,7 @@ export const requestChanges = mutation({ // Add feedback as comment await ctx.db.insert("messages", { + tenantId, taskId: args.taskId, humanAuthor: authorName, type: "comment", @@ -447,6 +471,7 @@ export const requestChanges = mutation({ // Log activity await ctx.db.insert("activities", { + tenantId, type: "task_status_changed", taskId: args.taskId, message: `${authorName} requested changes on "${task.title}"`, @@ -458,6 +483,7 @@ export const requestChanges = mutation({ if (task.assigneeIds) { for (const assigneeId of task.assigneeIds) { await ctx.db.insert("notifications", { + tenantId, targetAgentId: assigneeId, type: "task_assigned", taskId: args.taskId, @@ -473,11 +499,13 @@ export const requestChanges = mutation({ // Assign task to agent(s) export const assign = mutation({ args: { + machineToken: v.optional(v.string()), taskId: v.id("tasks"), assigneeSessionKeys: v.array(v.string()), bySessionKey: v.optional(v.string()), }, handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); const now = Date.now(); const task = await ctx.db.get(args.taskId); if (!task) throw new Error("Task not found"); @@ -517,6 +545,7 @@ export const assign = mutation({ // Send notifications to assignees for (const assigneeId of assigneeIds) { await ctx.db.insert("notifications", { + tenantId, targetAgentId: assigneeId, sourceAgentId: assignerId, type: "task_assigned", @@ -529,6 +558,7 @@ export const assign = mutation({ // Log activity await ctx.db.insert("activities", { + tenantId, type: "task_assigned", agentId: assignerId, taskId: args.taskId, @@ -541,12 +571,14 @@ export const assign = mutation({ // Add a comment to a task export const addComment = mutation({ args: { + machineToken: v.optional(v.string()), taskId: v.id("tasks"), content: v.string(), bySessionKey: v.optional(v.string()), humanAuthor: v.optional(v.string()), }, handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); const now = Date.now(); let fromAgentId = undefined; @@ -565,6 +597,7 @@ export const addComment = mutation({ } const messageId = await ctx.db.insert("messages", { + tenantId, taskId: args.taskId, fromAgentId, humanAuthor: args.humanAuthor, @@ -578,6 +611,7 @@ export const addComment = mutation({ // Log activity await ctx.db.insert("activities", { + tenantId, type: "message_sent", agentId: fromAgentId, taskId: args.taskId, @@ -592,12 +626,15 @@ export const addComment = mutation({ // Add a subtask export const addSubtask = mutation({ args: { + machineToken: v.optional(v.string()), taskId: v.id("tasks"), title: v.string(), description: v.optional(v.string()), assigneeSessionKey: v.optional(v.string()), }, handler: async (ctx, args) => { + await resolveTenantIdMut(ctx, args); + const task = await ctx.db.get(args.taskId); if (!task) throw new Error("Task not found"); @@ -636,6 +673,7 @@ export const addSubtask = mutation({ // Update subtask status export const updateSubtask = mutation({ args: { + machineToken: v.optional(v.string()), taskId: v.id("tasks"), subtaskIndex: v.number(), done: v.optional(v.boolean()), @@ -651,6 +689,7 @@ export const updateSubtask = mutation({ bySessionKey: v.optional(v.string()), }, handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); const now = Date.now(); const task = await ctx.db.get(args.taskId); if (!task) throw new Error("Task not found"); @@ -705,6 +744,7 @@ export const updateSubtask = mutation({ // Log activity if (isDone) { await ctx.db.insert("activities", { + tenantId, type: "subtask_completed", agentId, taskId: args.taskId, @@ -713,6 +753,7 @@ export const updateSubtask = mutation({ }); } else if (newStatus === "blocked") { await ctx.db.insert("activities", { + tenantId, type: "subtask_blocked" as any, agentId, taskId: args.taskId, @@ -723,6 +764,7 @@ export const updateSubtask = mutation({ // Notify task creator about the blocked subtask if (task.createdBy) { await ctx.db.insert("notifications", { + tenantId, targetAgentId: task.createdBy, sourceAgentId: agentId, type: "review_requested", @@ -739,6 +781,7 @@ export const updateSubtask = mutation({ // Update task details export const update = mutation({ args: { + machineToken: v.optional(v.string()), taskId: v.id("tasks"), title: v.optional(v.string()), description: v.optional(v.string()), @@ -752,7 +795,8 @@ export const update = mutation({ ), }, handler: async (ctx, args) => { - const { taskId, ...updates } = args; + await resolveTenantIdMut(ctx, args); + const { machineToken, taskId, ...updates } = args; const filteredUpdates = Object.fromEntries( Object.entries(updates).filter(([, value]) => value !== undefined), ); @@ -767,6 +811,7 @@ export const update = mutation({ // Create a task from the dashboard (attributed to Clawe) export const createFromDashboard = mutation({ args: { + machineToken: v.optional(v.string()), title: v.string(), description: v.optional(v.string()), priority: v.optional( @@ -779,6 +824,7 @@ export const createFromDashboard = mutation({ ), }, handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); const now = Date.now(); // Find Clawe (main leader) to attribute the task creation @@ -788,6 +834,7 @@ export const createFromDashboard = mutation({ .first(); const taskId = await ctx.db.insert("tasks", { + tenantId, title: args.title, description: args.description, status: "inbox", @@ -799,6 +846,7 @@ export const createFromDashboard = mutation({ // Log activity await ctx.db.insert("activities", { + tenantId, type: "task_created", agentId: clawe?._id, taskId, @@ -813,6 +861,7 @@ export const createFromDashboard = mutation({ // Create a full task with description, subtasks, and assignments in one atomic operation export const createWithPlan = mutation({ args: { + machineToken: v.optional(v.string()), title: v.string(), description: v.string(), priority: v.optional( @@ -834,6 +883,7 @@ export const createWithPlan = mutation({ ), }, handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); const now = Date.now(); // Resolve creator @@ -893,6 +943,7 @@ export const createWithPlan = mutation({ // Create the task const taskId = await ctx.db.insert("tasks", { + tenantId, title: args.title, description: args.description, status: assigneeIds.length > 0 ? "assigned" : "inbox", @@ -906,6 +957,7 @@ export const createWithPlan = mutation({ // Log activity await ctx.db.insert("activities", { + tenantId, type: "task_created", agentId: createdBy, taskId, @@ -928,6 +980,7 @@ export const createWithPlan = mutation({ : `📋 New task assigned: "${args.title}"`; await ctx.db.insert("notifications", { + tenantId, targetAgentId: assigneeId, sourceAgentId: createdBy, type: "task_assigned", @@ -945,8 +998,13 @@ export const createWithPlan = mutation({ // Delete a task export const remove = mutation({ - args: { taskId: v.id("tasks") }, + args: { + machineToken: v.optional(v.string()), + taskId: v.id("tasks"), + }, handler: async (ctx, args) => { + await resolveTenantIdMut(ctx, args); + // Also delete related messages const messages = await ctx.db .query("messages") diff --git a/packages/backend/convex/tenants.ts b/packages/backend/convex/tenants.ts new file mode 100644 index 0000000..10f7642 --- /dev/null +++ b/packages/backend/convex/tenants.ts @@ -0,0 +1,68 @@ +import { v } from "convex/values"; +import { query, mutation } from "./_generated/server"; +import { resolveTenantId, resolveTenantIdMut } from "./lib/auth"; + +const DEFAULT_TIMEZONE = "America/New_York"; + +// Get timezone for the current tenant +export const getTimezone = query({ + args: { machineToken: v.optional(v.string()) }, + handler: async (ctx, args) => { + const tenantId = await resolveTenantId(ctx, args); + const tenant = await ctx.db.get(tenantId); + return tenant?.settings?.timezone ?? DEFAULT_TIMEZONE; + }, +}); + +// Set timezone for the current tenant +export const setTimezone = mutation({ + args: { + machineToken: v.optional(v.string()), + timezone: v.string(), + }, + handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); + const tenant = await ctx.db.get(tenantId); + if (!tenant) { + throw new Error("Tenant not found"); + } + + await ctx.db.patch(tenantId, { + settings: { + ...tenant.settings, + timezone: args.timezone, + }, + updatedAt: Date.now(), + }); + }, +}); + +// Check if onboarding is complete for the current tenant +export const isOnboardingComplete = query({ + args: { machineToken: v.optional(v.string()) }, + handler: async (ctx, args) => { + const tenantId = await resolveTenantId(ctx, args); + const tenant = await ctx.db.get(tenantId); + return tenant?.settings?.onboardingComplete === true; + }, +}); + +// Mark onboarding as complete for the current tenant +export const completeOnboarding = mutation({ + args: { machineToken: v.optional(v.string()) }, + handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); + const tenant = await ctx.db.get(tenantId); + if (!tenant) { + throw new Error("Tenant not found"); + } + + await ctx.db.patch(tenantId, { + settings: { + ...tenant.settings, + onboardingComplete: true, + }, + updatedAt: Date.now(), + }); + }, +}); diff --git a/packages/backend/convex/tsconfig.json b/packages/backend/convex/tsconfig.json index 7374127..62eda33 100644 --- a/packages/backend/convex/tsconfig.json +++ b/packages/backend/convex/tsconfig.json @@ -11,6 +11,7 @@ "jsx": "react-jsx", "skipLibCheck": true, "allowSyntheticDefaultImports": true, + "types": ["node"], /* These compiler options are required by Convex */ "target": "ESNext", diff --git a/packages/backend/convex/users.ts b/packages/backend/convex/users.ts new file mode 100644 index 0000000..f72c5e9 --- /dev/null +++ b/packages/backend/convex/users.ts @@ -0,0 +1,85 @@ +import { query, mutation } from "./_generated/server"; +import { v } from "convex/values"; + +export const getOrCreateFromAuth = mutation({ + args: {}, + handler: async (ctx) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + throw new Error("Not authenticated"); + } + + const email = identity.email; + if (!email) { + throw new Error("No email in auth identity"); + } + + const existing = await ctx.db + .query("users") + .withIndex("by_email", (q) => q.eq("email", email)) + .first(); + + if (existing) { + return existing; + } + + const now = Date.now(); + const userId = await ctx.db.insert("users", { + email, + name: identity.name ?? undefined, + createdAt: now, + updatedAt: now, + }); + + return (await ctx.db.get(userId))!; + }, +}); + +export const get = query({ + args: {}, + handler: async (ctx) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + return null; + } + + const email = identity.email; + if (!email) { + return null; + } + + return await ctx.db + .query("users") + .withIndex("by_email", (q) => q.eq("email", email)) + .first(); + }, +}); + +export const update = mutation({ + args: { name: v.string() }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + throw new Error("Not authenticated"); + } + + const email = identity.email; + if (!email) { + throw new Error("No email in auth identity"); + } + + const user = await ctx.db + .query("users") + .withIndex("by_email", (q) => q.eq("email", email)) + .first(); + + if (!user) { + throw new Error("User not found"); + } + + await ctx.db.patch(user._id, { + name: args.name, + updatedAt: Date.now(), + }); + }, +}); diff --git a/packages/backend/package.json b/packages/backend/package.json index bb92fe8..2811805 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -32,6 +32,7 @@ }, "devDependencies": { "@clawe/typescript-config": "workspace:*", + "@types/node": "^25.2.3", "typescript": "5.9.2" } } diff --git a/packages/cli/src/client.spec.ts b/packages/cli/src/client.spec.ts index 7269de2..9008811 100644 --- a/packages/cli/src/client.spec.ts +++ b/packages/cli/src/client.spec.ts @@ -12,12 +12,33 @@ describe("client", () => { process.env = originalEnv; }); - it("exports a ConvexHttpClient when CONVEX_URL is set", async () => { + it("exports query, mutation, action wrappers when CONVEX_URL is set", async () => { process.env.CONVEX_URL = "https://test.convex.cloud"; - const { client } = await import("./client.js"); + const mod = await import("./client.js"); - expect(client).toBeDefined(); + expect(mod.query).toBeTypeOf("function"); + expect(mod.mutation).toBeTypeOf("function"); + expect(mod.action).toBeTypeOf("function"); + expect(mod.uploadFile).toBeTypeOf("function"); + }); + + it("exports machineToken from SQUADHUB_TOKEN env var", async () => { + process.env.CONVEX_URL = "https://test.convex.cloud"; + process.env.SQUADHUB_TOKEN = "test-machine-token"; + + const { machineToken } = await import("./client.js"); + + expect(machineToken).toBe("test-machine-token"); + }); + + it("defaults machineToken to empty string when SQUADHUB_TOKEN is not set", async () => { + process.env.CONVEX_URL = "https://test.convex.cloud"; + delete process.env.SQUADHUB_TOKEN; + + const { machineToken } = await import("./client.js"); + + expect(machineToken).toBe(""); }); it("exits with error when CONVEX_URL is not set", async () => { diff --git a/packages/cli/src/client.ts b/packages/cli/src/client.ts index 87be529..07df7ad 100644 --- a/packages/cli/src/client.ts +++ b/packages/cli/src/client.ts @@ -1,10 +1,22 @@ import { ConvexHttpClient } from "convex/browser"; +import type { + FunctionReference, + FunctionReturnType, + OptionalRestArgs, +} from "convex/server"; import { api } from "@clawe/backend"; import * as fs from "fs"; import * as path from "path"; const CONVEX_URL = process.env.CONVEX_URL; +/** + * Machine token read from SQUADHUB_TOKEN env var. + * Used to identify which tenant this CLI session belongs to. + * TODO (Phase 2.6): Inject into all Convex calls for tenant scoping. + */ +export const machineToken = process.env.SQUADHUB_TOKEN || ""; + // Common MIME types by extension const MIME_TYPES: Record = { ".md": "text/markdown", @@ -33,7 +45,40 @@ if (!CONVEX_URL) { process.exit(1); } -export const client = new ConvexHttpClient(CONVEX_URL); +const client = new ConvexHttpClient(CONVEX_URL); + +/** + * Wrapper around ConvexHttpClient.query. + * TODO (Phase 2.6): Inject machineToken into args for tenant scoping. + */ +export function query>( + fn: F, + ...args: OptionalRestArgs +): Promise> { + return client.query(fn, ...args); +} + +/** + * Wrapper around ConvexHttpClient.mutation. + * TODO (Phase 2.6): Inject machineToken into args for tenant scoping. + */ +export function mutation>( + fn: F, + ...args: OptionalRestArgs +): Promise> { + return client.mutation(fn, ...args); +} + +/** + * Wrapper around ConvexHttpClient.action. + * TODO (Phase 2.6): Inject machineToken into args for tenant scoping. + */ +export function action>( + fn: F, + ...args: OptionalRestArgs +): Promise> { + return client.action(fn, ...args); +} /** * Upload a file to Convex storage @@ -44,7 +89,7 @@ export async function uploadFile(filePath: string): Promise { const fileBuffer = fs.readFileSync(filePath); // Get upload URL from Convex - let uploadUrl = await client.action(api.documents.generateUploadUrl, {}); + let uploadUrl = await action(api.documents.generateUploadUrl, {}); // When running in Docker, rewrite localhost/127.0.0.1 to host.docker.internal // so the container can reach the host's Convex dev server diff --git a/packages/cli/src/commands/agent-register.spec.ts b/packages/cli/src/commands/agent-register.spec.ts index 8ac91bb..45e6075 100644 --- a/packages/cli/src/commands/agent-register.spec.ts +++ b/packages/cli/src/commands/agent-register.spec.ts @@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { agentRegister } from "./agent-register.js"; vi.mock("../client.js", () => ({ - client: { - mutation: vi.fn(), - }, + mutation: vi.fn(), })); -import { client } from "../client.js"; +import { mutation } from "../client.js"; describe("agentRegister", () => { beforeEach(() => { @@ -16,11 +14,11 @@ describe("agentRegister", () => { }); it("registers an agent with basic info", async () => { - vi.mocked(client.mutation).mockResolvedValue("agent-123"); + vi.mocked(mutation).mockResolvedValue("agent-123"); await agentRegister("Scout", "SEO Analyst", "agent:scout:main", {}); - expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { + expect(mutation).toHaveBeenCalledWith(expect.anything(), { name: "Scout", role: "SEO Analyst", sessionKey: "agent:scout:main", @@ -32,13 +30,13 @@ describe("agentRegister", () => { }); it("registers an agent with emoji", async () => { - vi.mocked(client.mutation).mockResolvedValue("agent-456"); + vi.mocked(mutation).mockResolvedValue("agent-456"); await agentRegister("Pixel", "Designer", "agent:pixel:main", { emoji: "🎨", }); - expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { + expect(mutation).toHaveBeenCalledWith(expect.anything(), { name: "Pixel", role: "Designer", sessionKey: "agent:pixel:main", @@ -50,13 +48,13 @@ describe("agentRegister", () => { }); it("handles upsert (update existing agent)", async () => { - vi.mocked(client.mutation).mockResolvedValue("existing-agent-id"); + vi.mocked(mutation).mockResolvedValue("existing-agent-id"); await agentRegister("Clawe", "Squad Lead", "agent:main:main", { emoji: "🦞", }); - expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { + expect(mutation).toHaveBeenCalledWith(expect.anything(), { name: "Clawe", role: "Squad Lead", sessionKey: "agent:main:main", diff --git a/packages/cli/src/commands/agent-register.ts b/packages/cli/src/commands/agent-register.ts index 154056b..7e261fb 100644 --- a/packages/cli/src/commands/agent-register.ts +++ b/packages/cli/src/commands/agent-register.ts @@ -1,4 +1,4 @@ -import { client } from "../client.js"; +import { mutation } from "../client.js"; import { api } from "@clawe/backend"; interface AgentRegisterOptions { @@ -11,7 +11,7 @@ export async function agentRegister( sessionKey: string, options: AgentRegisterOptions, ): Promise { - const agentId = await client.mutation(api.agents.upsert, { + const agentId = await mutation(api.agents.upsert, { name, role, sessionKey, diff --git a/packages/cli/src/commands/business-get.ts b/packages/cli/src/commands/business-get.ts index cda333b..b93e575 100644 --- a/packages/cli/src/commands/business-get.ts +++ b/packages/cli/src/commands/business-get.ts @@ -1,4 +1,4 @@ -import { client } from "../client.js"; +import { query } from "../client.js"; import { api } from "@clawe/backend"; /** @@ -6,7 +6,7 @@ import { api } from "@clawe/backend"; * Any agent can use this to understand the business they're working for. */ export async function businessGet(): Promise { - const context = await client.query(api.businessContext.get, {}); + const context = await query(api.businessContext.get, {}); if (!context) { console.log("Business context not configured."); diff --git a/packages/cli/src/commands/business-set.ts b/packages/cli/src/commands/business-set.ts index 86a850c..9680f01 100644 --- a/packages/cli/src/commands/business-set.ts +++ b/packages/cli/src/commands/business-set.ts @@ -1,7 +1,7 @@ import { promises as fs } from "fs"; import path from "path"; import os from "os"; -import { client } from "../client.js"; +import { mutation } from "../client.js"; import { api } from "@clawe/backend"; export type BusinessSetOptions = { @@ -33,7 +33,7 @@ export async function businessSet( } // Save to Convex - const id = await client.mutation(api.businessContext.save, { + const id = await mutation(api.businessContext.save, { url, name: options.name, description: options.description, @@ -59,10 +59,10 @@ export async function businessSet( // Remove BOOTSTRAP.md if requested if (options.removeBootstrap) { - const agencyStateDir = - process.env.AGENCY_STATE_DIR || path.join(os.homedir(), ".agency"); + const squadhubStateDir = + process.env.SQUADHUB_STATE_DIR || path.join(os.homedir(), ".squadhub"); const bootstrapPath = path.join( - agencyStateDir, + squadhubStateDir, "workspaces", "clawe", "BOOTSTRAP.md", diff --git a/packages/cli/src/commands/check.spec.ts b/packages/cli/src/commands/check.spec.ts index f543092..2d80852 100644 --- a/packages/cli/src/commands/check.spec.ts +++ b/packages/cli/src/commands/check.spec.ts @@ -2,13 +2,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { check } from "./check.js"; vi.mock("../client.js", () => ({ - client: { - mutation: vi.fn(), - query: vi.fn(), - }, + mutation: vi.fn(), + query: vi.fn(), })); -import { client } from "../client.js"; +import { mutation, query } from "../client.js"; describe("check", () => { beforeEach(() => { @@ -17,19 +15,19 @@ describe("check", () => { }); it("outputs HEARTBEAT_OK when no notifications", async () => { - vi.mocked(client.mutation).mockResolvedValue("agent-id"); - vi.mocked(client.query).mockResolvedValue([]); + vi.mocked(mutation).mockResolvedValue("agent-id"); + vi.mocked(query).mockResolvedValue([]); await check("agent:main:main"); - expect(client.mutation).toHaveBeenCalled(); - expect(client.query).toHaveBeenCalled(); + expect(mutation).toHaveBeenCalled(); + expect(query).toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith("HEARTBEAT_OK"); }); it("displays notifications when present", async () => { - vi.mocked(client.mutation).mockResolvedValue("agent-id"); - vi.mocked(client.query).mockResolvedValue([ + vi.mocked(mutation).mockResolvedValue("agent-id"); + vi.mocked(query).mockResolvedValue([ { _id: "notif-1", type: "task_assigned", @@ -48,8 +46,8 @@ describe("check", () => { }); it("marks notifications as delivered", async () => { - vi.mocked(client.mutation).mockResolvedValue("agent-id"); - vi.mocked(client.query).mockResolvedValue([ + vi.mocked(mutation).mockResolvedValue("agent-id"); + vi.mocked(query).mockResolvedValue([ { _id: "notif-1", type: "custom", @@ -61,6 +59,6 @@ describe("check", () => { await check("agent:main:main"); - expect(client.mutation).toHaveBeenCalledTimes(2); + expect(mutation).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index b867efd..dde509d 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -1,12 +1,12 @@ -import { client } from "../client.js"; +import { query, mutation } from "../client.js"; import { api } from "@clawe/backend"; export async function check(sessionKey: string): Promise { // Record heartbeat - await client.mutation(api.agents.heartbeat, { sessionKey }); + await mutation(api.agents.heartbeat, { sessionKey }); // Get undelivered notifications - const notifications = await client.query(api.notifications.getUndelivered, { + const notifications = await query(api.notifications.getUndelivered, { sessionKey, }); @@ -16,7 +16,7 @@ export async function check(sessionKey: string): Promise { } // Mark as delivered - await client.mutation(api.notifications.markDelivered, { + await mutation(api.notifications.markDelivered, { notificationIds: notifications.map((n) => n._id), }); diff --git a/packages/cli/src/commands/deliver.spec.ts b/packages/cli/src/commands/deliver.spec.ts index a6670e6..527b24a 100644 --- a/packages/cli/src/commands/deliver.spec.ts +++ b/packages/cli/src/commands/deliver.spec.ts @@ -3,10 +3,8 @@ import { deliver, deliverables } from "./deliver.js"; import * as fs from "fs"; vi.mock("../client.js", () => ({ - client: { - mutation: vi.fn(), - query: vi.fn(), - }, + mutation: vi.fn(), + query: vi.fn(), uploadFile: vi.fn(), })); @@ -14,7 +12,7 @@ vi.mock("fs", () => ({ existsSync: vi.fn(), })); -import { client, uploadFile } from "../client.js"; +import { mutation, query, uploadFile } from "../client.js"; describe("deliver", () => { beforeEach(() => { @@ -25,7 +23,7 @@ describe("deliver", () => { it("registers a deliverable", async () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(uploadFile).mockResolvedValue("file-id"); - vi.mocked(client.mutation).mockResolvedValue("doc-id"); + vi.mocked(mutation).mockResolvedValue("doc-id"); await deliver("task-123", "/output/report.pdf", "Final Report", { by: "agent:inky:main", @@ -33,7 +31,7 @@ describe("deliver", () => { expect(fs.existsSync).toHaveBeenCalledWith("/output/report.pdf"); expect(uploadFile).toHaveBeenCalledWith("/output/report.pdf"); - expect(client.mutation).toHaveBeenCalledWith( + expect(mutation).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ taskId: "task-123", @@ -56,7 +54,7 @@ describe("deliverables", () => { }); it("shows message when no deliverables", async () => { - vi.mocked(client.query).mockResolvedValue([]); + vi.mocked(query).mockResolvedValue([]); await deliverables("task-123"); @@ -65,7 +63,7 @@ describe("deliverables", () => { it("lists deliverables with details", async () => { const now = Date.now(); - vi.mocked(client.query).mockResolvedValue([ + vi.mocked(query).mockResolvedValue([ { _id: "doc-1", title: "Logo Design", diff --git a/packages/cli/src/commands/deliver.ts b/packages/cli/src/commands/deliver.ts index 1d512e6..51a320a 100644 --- a/packages/cli/src/commands/deliver.ts +++ b/packages/cli/src/commands/deliver.ts @@ -1,4 +1,4 @@ -import { client, uploadFile } from "../client.js"; +import { query, mutation, uploadFile } from "../client.js"; import { api } from "@clawe/backend"; import type { Id } from "@clawe/backend/dataModel"; import * as fs from "fs"; @@ -26,7 +26,7 @@ export async function deliver( console.log(`✅ File uploaded`); // Register deliverable with fileId - await client.mutation(api.documents.registerDeliverable, { + await mutation(api.documents.registerDeliverable, { taskId: taskId as Id<"tasks">, path, fileId: fileId as Id<"_storage">, @@ -38,7 +38,7 @@ export async function deliver( } export async function deliverables(taskId: string): Promise { - const docs = await client.query(api.documents.getForTask, { + const docs = await query(api.documents.getForTask, { taskId: taskId as Id<"tasks">, }); diff --git a/packages/cli/src/commands/feed.spec.ts b/packages/cli/src/commands/feed.spec.ts index 8aae920..b042028 100644 --- a/packages/cli/src/commands/feed.spec.ts +++ b/packages/cli/src/commands/feed.spec.ts @@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { feed } from "./feed.js"; vi.mock("../client.js", () => ({ - client: { - query: vi.fn(), - }, + query: vi.fn(), })); -import { client } from "../client.js"; +import { query } from "../client.js"; describe("feed", () => { beforeEach(() => { @@ -16,7 +14,7 @@ describe("feed", () => { }); it("displays message when no activity", async () => { - vi.mocked(client.query).mockResolvedValue([]); + vi.mocked(query).mockResolvedValue([]); await feed({}); @@ -25,7 +23,7 @@ describe("feed", () => { it("displays activity feed with agent names", async () => { const now = Date.now(); - vi.mocked(client.query).mockResolvedValue([ + vi.mocked(query).mockResolvedValue([ { _id: "activity-1", type: "task_created", @@ -44,7 +42,7 @@ describe("feed", () => { }); it("shows System for activities without agent", async () => { - vi.mocked(client.query).mockResolvedValue([ + vi.mocked(query).mockResolvedValue([ { _id: "activity-2", type: "system", @@ -62,18 +60,18 @@ describe("feed", () => { }); it("uses default limit of 20", async () => { - vi.mocked(client.query).mockResolvedValue([]); + vi.mocked(query).mockResolvedValue([]); await feed({}); - expect(client.query).toHaveBeenCalledWith(expect.anything(), { limit: 20 }); + expect(query).toHaveBeenCalledWith(expect.anything(), { limit: 20 }); }); it("uses custom limit when provided", async () => { - vi.mocked(client.query).mockResolvedValue([]); + vi.mocked(query).mockResolvedValue([]); await feed({ limit: 50 }); - expect(client.query).toHaveBeenCalledWith(expect.anything(), { limit: 50 }); + expect(query).toHaveBeenCalledWith(expect.anything(), { limit: 50 }); }); }); diff --git a/packages/cli/src/commands/feed.ts b/packages/cli/src/commands/feed.ts index f6f233d..dfc602d 100644 --- a/packages/cli/src/commands/feed.ts +++ b/packages/cli/src/commands/feed.ts @@ -1,4 +1,4 @@ -import { client } from "../client.js"; +import { query } from "../client.js"; import { api } from "@clawe/backend"; interface FeedOptions { @@ -6,7 +6,7 @@ interface FeedOptions { } export async function feed(options: FeedOptions): Promise { - const activities = await client.query(api.activities.feed, { + const activities = await query(api.activities.feed, { limit: options.limit ?? 20, }); diff --git a/packages/cli/src/commands/notify.spec.ts b/packages/cli/src/commands/notify.spec.ts index c4a430f..1bca0dc 100644 --- a/packages/cli/src/commands/notify.spec.ts +++ b/packages/cli/src/commands/notify.spec.ts @@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { notify } from "./notify.js"; vi.mock("../client.js", () => ({ - client: { - mutation: vi.fn(), - }, + mutation: vi.fn(), })); -import { client } from "../client.js"; +import { mutation } from "../client.js"; describe("notify", () => { beforeEach(() => { @@ -16,11 +14,11 @@ describe("notify", () => { }); it("sends notification to target agent", async () => { - vi.mocked(client.mutation).mockResolvedValue("notif-id"); + vi.mocked(mutation).mockResolvedValue("notif-id"); await notify("agent:inky:main", "Please review the draft", {}); - expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { + expect(mutation).toHaveBeenCalledWith(expect.anything(), { targetSessionKey: "agent:inky:main", sourceSessionKey: undefined, type: "custom", @@ -29,13 +27,13 @@ describe("notify", () => { }); it("includes source agent when from option provided", async () => { - vi.mocked(client.mutation).mockResolvedValue("notif-id"); + vi.mocked(mutation).mockResolvedValue("notif-id"); await notify("agent:inky:main", "Task completed", { from: "agent:main:main", }); - expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { + expect(mutation).toHaveBeenCalledWith(expect.anything(), { targetSessionKey: "agent:inky:main", sourceSessionKey: "agent:main:main", type: "custom", @@ -44,7 +42,7 @@ describe("notify", () => { }); it("logs success message", async () => { - vi.mocked(client.mutation).mockResolvedValue("notif-id"); + vi.mocked(mutation).mockResolvedValue("notif-id"); await notify("agent:inky:main", "Hello", {}); diff --git a/packages/cli/src/commands/notify.ts b/packages/cli/src/commands/notify.ts index 2f7cd29..85544b1 100644 --- a/packages/cli/src/commands/notify.ts +++ b/packages/cli/src/commands/notify.ts @@ -1,4 +1,4 @@ -import { client } from "../client.js"; +import { mutation } from "../client.js"; import { api } from "@clawe/backend"; interface NotifyOptions { @@ -10,7 +10,7 @@ export async function notify( message: string, options: NotifyOptions, ): Promise { - await client.mutation(api.notifications.send, { + await mutation(api.notifications.send, { targetSessionKey, sourceSessionKey: options.from, type: "custom", diff --git a/packages/cli/src/commands/squad.spec.ts b/packages/cli/src/commands/squad.spec.ts index 1c54941..62348a9 100644 --- a/packages/cli/src/commands/squad.spec.ts +++ b/packages/cli/src/commands/squad.spec.ts @@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { squad } from "./squad.js"; vi.mock("../client.js", () => ({ - client: { - query: vi.fn(), - }, + query: vi.fn(), })); -import { client } from "../client.js"; +import { query } from "../client.js"; describe("squad", () => { beforeEach(() => { @@ -16,7 +14,7 @@ describe("squad", () => { }); it("displays squad status header", async () => { - vi.mocked(client.query).mockResolvedValue([]); + vi.mocked(query).mockResolvedValue([]); await squad(); @@ -24,7 +22,7 @@ describe("squad", () => { }); it("displays agent details with online status", async () => { - vi.mocked(client.query).mockResolvedValue([ + vi.mocked(query).mockResolvedValue([ { _id: "agent-1", name: "Clawe", @@ -46,7 +44,7 @@ describe("squad", () => { }); it("displays offline status when no heartbeat", async () => { - vi.mocked(client.query).mockResolvedValue([ + vi.mocked(query).mockResolvedValue([ { _id: "agent-2", name: "Inky", @@ -62,7 +60,7 @@ describe("squad", () => { }); it("displays offline status when heartbeat is stale", async () => { - vi.mocked(client.query).mockResolvedValue([ + vi.mocked(query).mockResolvedValue([ { _id: "agent-3", name: "Pixel", diff --git a/packages/cli/src/commands/squad.ts b/packages/cli/src/commands/squad.ts index 1d08c4b..d4e9c96 100644 --- a/packages/cli/src/commands/squad.ts +++ b/packages/cli/src/commands/squad.ts @@ -1,9 +1,9 @@ -import { client } from "../client.js"; +import { query } from "../client.js"; import { api } from "@clawe/backend"; import { deriveStatus } from "@clawe/shared/agents"; export async function squad(): Promise { - const agents = await client.query(api.agents.squad, {}); + const agents = await query(api.agents.squad, {}); console.log("🤖 Squad Status:\n"); for (const agent of agents) { diff --git a/packages/cli/src/commands/subtask-add.spec.ts b/packages/cli/src/commands/subtask-add.spec.ts index 15bcac3..8802ae8 100644 --- a/packages/cli/src/commands/subtask-add.spec.ts +++ b/packages/cli/src/commands/subtask-add.spec.ts @@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { subtaskAdd } from "./subtask-add.js"; vi.mock("../client.js", () => ({ - client: { - mutation: vi.fn(), - }, + mutation: vi.fn(), })); -import { client } from "../client.js"; +import { mutation } from "../client.js"; describe("subtaskAdd", () => { beforeEach(() => { @@ -16,11 +14,11 @@ describe("subtaskAdd", () => { }); it("adds a subtask with title only", async () => { - vi.mocked(client.mutation).mockResolvedValue(0); + vi.mocked(mutation).mockResolvedValue(0); await subtaskAdd("task-123", "Write unit tests", {}); - expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { + expect(mutation).toHaveBeenCalledWith(expect.anything(), { taskId: "task-123", title: "Write unit tests", description: undefined, @@ -32,13 +30,13 @@ describe("subtaskAdd", () => { }); it("adds a subtask with description", async () => { - vi.mocked(client.mutation).mockResolvedValue(1); + vi.mocked(mutation).mockResolvedValue(1); await subtaskAdd("task-456", "Review code", { description: "Check for edge cases", }); - expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { + expect(mutation).toHaveBeenCalledWith(expect.anything(), { taskId: "task-456", title: "Review code", description: "Check for edge cases", @@ -47,13 +45,13 @@ describe("subtaskAdd", () => { }); it("adds a subtask with assignee", async () => { - vi.mocked(client.mutation).mockResolvedValue(2); + vi.mocked(mutation).mockResolvedValue(2); await subtaskAdd("task-789", "Design mockup", { assign: "agent:pixel:main", }); - expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { + expect(mutation).toHaveBeenCalledWith(expect.anything(), { taskId: "task-789", title: "Design mockup", description: undefined, @@ -62,14 +60,14 @@ describe("subtaskAdd", () => { }); it("adds a subtask with all options", async () => { - vi.mocked(client.mutation).mockResolvedValue(3); + vi.mocked(mutation).mockResolvedValue(3); await subtaskAdd("task-full", "Complete task", { description: "Full details here", assign: "agent:inky:main", }); - expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { + expect(mutation).toHaveBeenCalledWith(expect.anything(), { taskId: "task-full", title: "Complete task", description: "Full details here", diff --git a/packages/cli/src/commands/subtask-add.ts b/packages/cli/src/commands/subtask-add.ts index 949a946..a0ce15f 100644 --- a/packages/cli/src/commands/subtask-add.ts +++ b/packages/cli/src/commands/subtask-add.ts @@ -1,4 +1,4 @@ -import { client } from "../client.js"; +import { mutation } from "../client.js"; import { api } from "@clawe/backend"; import type { Id } from "@clawe/backend/dataModel"; @@ -12,7 +12,7 @@ export async function subtaskAdd( title: string, options: SubtaskAddOptions, ): Promise { - const index = await client.mutation(api.tasks.addSubtask, { + const index = await mutation(api.tasks.addSubtask, { taskId: taskId as Id<"tasks">, title, description: options.description, diff --git a/packages/cli/src/commands/subtask-check.spec.ts b/packages/cli/src/commands/subtask-check.spec.ts index e1bd0a3..8048084 100644 --- a/packages/cli/src/commands/subtask-check.spec.ts +++ b/packages/cli/src/commands/subtask-check.spec.ts @@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { subtaskCheck, subtaskUncheck } from "./subtask-check.js"; vi.mock("../client.js", () => ({ - client: { - mutation: vi.fn(), - }, + mutation: vi.fn(), })); -import { client } from "../client.js"; +import { mutation } from "../client.js"; describe("subtaskCheck", () => { beforeEach(() => { @@ -16,11 +14,11 @@ describe("subtaskCheck", () => { }); it("marks subtask as done", async () => { - vi.mocked(client.mutation).mockResolvedValue(undefined); + vi.mocked(mutation).mockResolvedValue(undefined); await subtaskCheck("task-123", "0", {}); - expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { + expect(mutation).toHaveBeenCalledWith(expect.anything(), { taskId: "task-123", subtaskIndex: 0, done: true, @@ -31,11 +29,11 @@ describe("subtaskCheck", () => { }); it("marks subtask as done with agent attribution", async () => { - vi.mocked(client.mutation).mockResolvedValue(undefined); + vi.mocked(mutation).mockResolvedValue(undefined); await subtaskCheck("task-456", "2", { by: "agent:inky:main" }); - expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { + expect(mutation).toHaveBeenCalledWith(expect.anything(), { taskId: "task-456", subtaskIndex: 2, done: true, @@ -45,11 +43,11 @@ describe("subtaskCheck", () => { }); it("parses string index correctly", async () => { - vi.mocked(client.mutation).mockResolvedValue(undefined); + vi.mocked(mutation).mockResolvedValue(undefined); await subtaskCheck("task-789", "5", {}); - expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { + expect(mutation).toHaveBeenCalledWith(expect.anything(), { taskId: "task-789", subtaskIndex: 5, done: true, @@ -66,11 +64,11 @@ describe("subtaskUncheck", () => { }); it("marks subtask as not done", async () => { - vi.mocked(client.mutation).mockResolvedValue(undefined); + vi.mocked(mutation).mockResolvedValue(undefined); await subtaskUncheck("task-123", "1", {}); - expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { + expect(mutation).toHaveBeenCalledWith(expect.anything(), { taskId: "task-123", subtaskIndex: 1, done: false, @@ -81,11 +79,11 @@ describe("subtaskUncheck", () => { }); it("marks subtask as not done with agent attribution", async () => { - vi.mocked(client.mutation).mockResolvedValue(undefined); + vi.mocked(mutation).mockResolvedValue(undefined); await subtaskUncheck("task-456", "0", { by: "agent:main:main" }); - expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { + expect(mutation).toHaveBeenCalledWith(expect.anything(), { taskId: "task-456", subtaskIndex: 0, done: false, diff --git a/packages/cli/src/commands/subtask-check.ts b/packages/cli/src/commands/subtask-check.ts index ac6869e..73f109b 100644 --- a/packages/cli/src/commands/subtask-check.ts +++ b/packages/cli/src/commands/subtask-check.ts @@ -1,4 +1,4 @@ -import { client } from "../client.js"; +import { mutation } from "../client.js"; import { api } from "@clawe/backend"; import type { Id } from "@clawe/backend/dataModel"; @@ -11,7 +11,7 @@ export async function subtaskCheck( index: string, options: SubtaskCheckOptions, ): Promise { - await client.mutation(api.tasks.updateSubtask, { + await mutation(api.tasks.updateSubtask, { taskId: taskId as Id<"tasks">, subtaskIndex: parseInt(index, 10), done: true, @@ -27,7 +27,7 @@ export async function subtaskUncheck( index: string, options: SubtaskCheckOptions, ): Promise { - await client.mutation(api.tasks.updateSubtask, { + await mutation(api.tasks.updateSubtask, { taskId: taskId as Id<"tasks">, subtaskIndex: parseInt(index, 10), done: false, @@ -48,7 +48,7 @@ export async function subtaskBlock( index: string, options: SubtaskBlockOptions, ): Promise { - await client.mutation(api.tasks.updateSubtask, { + await mutation(api.tasks.updateSubtask, { taskId: taskId as Id<"tasks">, subtaskIndex: parseInt(index, 10), status: "blocked", @@ -70,7 +70,7 @@ export async function subtaskProgress( index: string, options: SubtaskProgressOptions, ): Promise { - await client.mutation(api.tasks.updateSubtask, { + await mutation(api.tasks.updateSubtask, { taskId: taskId as Id<"tasks">, subtaskIndex: parseInt(index, 10), status: "in_progress", diff --git a/packages/cli/src/commands/task-assign.spec.ts b/packages/cli/src/commands/task-assign.spec.ts index 13f129a..f24fbb6 100644 --- a/packages/cli/src/commands/task-assign.spec.ts +++ b/packages/cli/src/commands/task-assign.spec.ts @@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { taskAssign } from "./task-assign.js"; vi.mock("../client.js", () => ({ - client: { - mutation: vi.fn(), - }, + mutation: vi.fn(), })); -import { client } from "../client.js"; +import { mutation } from "../client.js"; describe("taskAssign", () => { beforeEach(() => { @@ -16,11 +14,11 @@ describe("taskAssign", () => { }); it("assigns task to an agent", async () => { - vi.mocked(client.mutation).mockResolvedValue(undefined); + vi.mocked(mutation).mockResolvedValue(undefined); await taskAssign("task-123", "agent:inky:main", {}); - expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { + expect(mutation).toHaveBeenCalledWith(expect.anything(), { taskId: "task-123", assigneeSessionKeys: ["agent:inky:main"], bySessionKey: undefined, @@ -31,11 +29,11 @@ describe("taskAssign", () => { }); it("assigns task with assigner attribution", async () => { - vi.mocked(client.mutation).mockResolvedValue(undefined); + vi.mocked(mutation).mockResolvedValue(undefined); await taskAssign("task-456", "agent:pixel:main", { by: "agent:main:main" }); - expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { + expect(mutation).toHaveBeenCalledWith(expect.anything(), { taskId: "task-456", assigneeSessionKeys: ["agent:pixel:main"], bySessionKey: "agent:main:main", diff --git a/packages/cli/src/commands/task-assign.ts b/packages/cli/src/commands/task-assign.ts index 831a601..552d44b 100644 --- a/packages/cli/src/commands/task-assign.ts +++ b/packages/cli/src/commands/task-assign.ts @@ -1,4 +1,4 @@ -import { client } from "../client.js"; +import { mutation } from "../client.js"; import { api } from "@clawe/backend"; import type { Id } from "@clawe/backend/dataModel"; @@ -11,7 +11,7 @@ export async function taskAssign( assigneeSessionKey: string, options: TaskAssignOptions, ): Promise { - await client.mutation(api.tasks.assign, { + await mutation(api.tasks.assign, { taskId: taskId as Id<"tasks">, assigneeSessionKeys: [assigneeSessionKey], bySessionKey: options.by, diff --git a/packages/cli/src/commands/task-comment.spec.ts b/packages/cli/src/commands/task-comment.spec.ts index 1049a97..5635422 100644 --- a/packages/cli/src/commands/task-comment.spec.ts +++ b/packages/cli/src/commands/task-comment.spec.ts @@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { taskComment } from "./task-comment.js"; vi.mock("../client.js", () => ({ - client: { - mutation: vi.fn(), - }, + mutation: vi.fn(), })); -import { client } from "../client.js"; +import { mutation } from "../client.js"; describe("taskComment", () => { beforeEach(() => { @@ -16,11 +14,11 @@ describe("taskComment", () => { }); it("adds a comment to a task", async () => { - vi.mocked(client.mutation).mockResolvedValue("message-id"); + vi.mocked(mutation).mockResolvedValue("message-id"); await taskComment("task-123", "Looking good!", {}); - expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { + expect(mutation).toHaveBeenCalledWith(expect.anything(), { taskId: "task-123", content: "Looking good!", bySessionKey: undefined, @@ -29,13 +27,13 @@ describe("taskComment", () => { }); it("adds a comment with agent attribution", async () => { - vi.mocked(client.mutation).mockResolvedValue("message-id"); + vi.mocked(mutation).mockResolvedValue("message-id"); await taskComment("task-456", "I'll review this", { by: "agent:main:main", }); - expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { + expect(mutation).toHaveBeenCalledWith(expect.anything(), { taskId: "task-456", content: "I'll review this", bySessionKey: "agent:main:main", diff --git a/packages/cli/src/commands/task-comment.ts b/packages/cli/src/commands/task-comment.ts index 48aa2c0..f55c006 100644 --- a/packages/cli/src/commands/task-comment.ts +++ b/packages/cli/src/commands/task-comment.ts @@ -1,4 +1,4 @@ -import { client } from "../client.js"; +import { mutation } from "../client.js"; import { api } from "@clawe/backend"; import type { Id } from "@clawe/backend/dataModel"; @@ -11,7 +11,7 @@ export async function taskComment( message: string, options: TaskCommentOptions, ): Promise { - await client.mutation(api.tasks.addComment, { + await mutation(api.tasks.addComment, { taskId: taskId as Id<"tasks">, content: message, bySessionKey: options.by, diff --git a/packages/cli/src/commands/task-create.spec.ts b/packages/cli/src/commands/task-create.spec.ts index 6b3d1c7..404ea18 100644 --- a/packages/cli/src/commands/task-create.spec.ts +++ b/packages/cli/src/commands/task-create.spec.ts @@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { taskCreate } from "./task-create.js"; vi.mock("../client.js", () => ({ - client: { - mutation: vi.fn(), - }, + mutation: vi.fn(), })); -import { client } from "../client.js"; +import { mutation } from "../client.js"; describe("taskCreate", () => { beforeEach(() => { @@ -16,11 +14,11 @@ describe("taskCreate", () => { }); it("creates a task with title only", async () => { - vi.mocked(client.mutation).mockResolvedValue("task-123"); + vi.mocked(mutation).mockResolvedValue("task-123"); await taskCreate("Write documentation", {}); - expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { + expect(mutation).toHaveBeenCalledWith(expect.anything(), { title: "Write documentation", assigneeSessionKey: undefined, createdBySessionKey: undefined, @@ -30,11 +28,11 @@ describe("taskCreate", () => { }); it("creates a task with assignee", async () => { - vi.mocked(client.mutation).mockResolvedValue("task-456"); + vi.mocked(mutation).mockResolvedValue("task-456"); await taskCreate("Design logo", { assign: "agent:pixel:main" }); - expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { + expect(mutation).toHaveBeenCalledWith(expect.anything(), { title: "Design logo", assigneeSessionKey: "agent:pixel:main", createdBySessionKey: undefined, @@ -43,14 +41,14 @@ describe("taskCreate", () => { }); it("creates a task with priority and creator", async () => { - vi.mocked(client.mutation).mockResolvedValue("task-789"); + vi.mocked(mutation).mockResolvedValue("task-789"); await taskCreate("Fix critical bug", { priority: "urgent", by: "agent:main:main", }); - expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { + expect(mutation).toHaveBeenCalledWith(expect.anything(), { title: "Fix critical bug", assigneeSessionKey: undefined, createdBySessionKey: "agent:main:main", @@ -59,13 +57,13 @@ describe("taskCreate", () => { }); it("creates a task with description", async () => { - vi.mocked(client.mutation).mockResolvedValue("task-desc"); + vi.mocked(mutation).mockResolvedValue("task-desc"); await taskCreate("Write blog post", { description: "2000 words, practical focus", }); - expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { + expect(mutation).toHaveBeenCalledWith(expect.anything(), { title: "Write blog post", description: "2000 words, practical focus", assigneeSessionKey: undefined, @@ -75,7 +73,7 @@ describe("taskCreate", () => { }); it("creates a task with all options", async () => { - vi.mocked(client.mutation).mockResolvedValue("task-full"); + vi.mocked(mutation).mockResolvedValue("task-full"); await taskCreate("Full featured task", { assign: "agent:inky:main", @@ -84,7 +82,7 @@ describe("taskCreate", () => { description: "Detailed task description", }); - expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { + expect(mutation).toHaveBeenCalledWith(expect.anything(), { title: "Full featured task", description: "Detailed task description", assigneeSessionKey: "agent:inky:main", diff --git a/packages/cli/src/commands/task-create.ts b/packages/cli/src/commands/task-create.ts index 730f32a..461bef8 100644 --- a/packages/cli/src/commands/task-create.ts +++ b/packages/cli/src/commands/task-create.ts @@ -1,4 +1,4 @@ -import { client } from "../client.js"; +import { mutation } from "../client.js"; import { api } from "@clawe/backend"; interface TaskCreateOptions { @@ -12,7 +12,7 @@ export async function taskCreate( title: string, options: TaskCreateOptions, ): Promise { - const taskId = await client.mutation(api.tasks.create, { + const taskId = await mutation(api.tasks.create, { title, description: options.description, assigneeSessionKey: options.assign, diff --git a/packages/cli/src/commands/task-plan.spec.ts b/packages/cli/src/commands/task-plan.spec.ts index 4080c16..5309279 100644 --- a/packages/cli/src/commands/task-plan.spec.ts +++ b/packages/cli/src/commands/task-plan.spec.ts @@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { taskPlan } from "./task-plan.js"; vi.mock("../client.js", () => ({ - client: { - mutation: vi.fn(), - }, + mutation: vi.fn(), })); -import { client } from "../client.js"; +import { mutation } from "../client.js"; describe("taskPlan", () => { beforeEach(() => { @@ -20,7 +18,7 @@ describe("taskPlan", () => { }); it("creates a task plan with all fields", async () => { - vi.mocked(client.mutation).mockResolvedValue("task-plan-1"); + vi.mocked(mutation).mockResolvedValue("task-plan-1"); const plan = JSON.stringify({ title: "Blog Post: AI Teams", @@ -37,7 +35,7 @@ describe("taskPlan", () => { await taskPlan(plan); - expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { + expect(mutation).toHaveBeenCalledWith(expect.anything(), { title: "Blog Post: AI Teams", description: "Write a 2000-word post", priority: "high", @@ -65,7 +63,7 @@ describe("taskPlan", () => { }); it("creates a minimal task plan", async () => { - vi.mocked(client.mutation).mockResolvedValue("task-plan-2"); + vi.mocked(mutation).mockResolvedValue("task-plan-2"); const plan = JSON.stringify({ title: "Quick task", @@ -75,7 +73,7 @@ describe("taskPlan", () => { await taskPlan(plan); - expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { + expect(mutation).toHaveBeenCalledWith(expect.anything(), { title: "Quick task", description: "Do something simple", priority: undefined, @@ -92,7 +90,7 @@ describe("taskPlan", () => { }); it("maps subtask descriptions correctly", async () => { - vi.mocked(client.mutation).mockResolvedValue("task-plan-3"); + vi.mocked(mutation).mockResolvedValue("task-plan-3"); const plan = JSON.stringify({ title: "Detailed task", @@ -105,7 +103,7 @@ describe("taskPlan", () => { await taskPlan(plan); - expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { + expect(mutation).toHaveBeenCalledWith(expect.anything(), { title: "Detailed task", description: "Task with subtask descriptions", priority: undefined, @@ -132,7 +130,7 @@ describe("taskPlan", () => { expect(console.error).toHaveBeenCalledWith( "Error: Invalid JSON. Expected a task plan object.", ); - expect(client.mutation).not.toHaveBeenCalled(); + expect(mutation).not.toHaveBeenCalled(); }); it("exits when title is missing", async () => { @@ -146,7 +144,7 @@ describe("taskPlan", () => { expect(console.error).toHaveBeenCalledWith( "Error: Plan must include 'title'", ); - expect(client.mutation).not.toHaveBeenCalled(); + expect(mutation).not.toHaveBeenCalled(); }); it("exits when description is missing", async () => { @@ -160,7 +158,7 @@ describe("taskPlan", () => { expect(console.error).toHaveBeenCalledWith( "Error: Plan must include 'description'", ); - expect(client.mutation).not.toHaveBeenCalled(); + expect(mutation).not.toHaveBeenCalled(); }); it("exits when subtasks are missing", async () => { @@ -174,7 +172,7 @@ describe("taskPlan", () => { expect(console.error).toHaveBeenCalledWith( "Error: Plan must include at least one subtask", ); - expect(client.mutation).not.toHaveBeenCalled(); + expect(mutation).not.toHaveBeenCalled(); }); it("exits when subtasks array is empty", async () => { @@ -189,11 +187,11 @@ describe("taskPlan", () => { expect(console.error).toHaveBeenCalledWith( "Error: Plan must include at least one subtask", ); - expect(client.mutation).not.toHaveBeenCalled(); + expect(mutation).not.toHaveBeenCalled(); }); it("handles mutation error", async () => { - vi.mocked(client.mutation).mockRejectedValue(new Error("Convex error")); + vi.mocked(mutation).mockRejectedValue(new Error("Convex error")); const plan = JSON.stringify({ title: "Failing task", @@ -207,7 +205,7 @@ describe("taskPlan", () => { }); it("prints subtask listing on success", async () => { - vi.mocked(client.mutation).mockResolvedValue("task-plan-list"); + vi.mocked(mutation).mockResolvedValue("task-plan-list"); const plan = JSON.stringify({ title: "Task with listing", diff --git a/packages/cli/src/commands/task-plan.ts b/packages/cli/src/commands/task-plan.ts index 8db0a91..e43472c 100644 --- a/packages/cli/src/commands/task-plan.ts +++ b/packages/cli/src/commands/task-plan.ts @@ -1,4 +1,4 @@ -import { client } from "../client.js"; +import { mutation } from "../client.js"; import { api } from "@clawe/backend"; interface TaskPlan { @@ -64,7 +64,7 @@ export async function taskPlan(planJson: string): Promise { console.log(""); try { - const taskId = await client.mutation(api.tasks.createWithPlan, { + const taskId = await mutation(api.tasks.createWithPlan, { title: plan.title, description: plan.description, priority: plan.priority, diff --git a/packages/cli/src/commands/task-status.spec.ts b/packages/cli/src/commands/task-status.spec.ts index b597852..1bd4875 100644 --- a/packages/cli/src/commands/task-status.spec.ts +++ b/packages/cli/src/commands/task-status.spec.ts @@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { taskStatus } from "./task-status.js"; vi.mock("../client.js", () => ({ - client: { - mutation: vi.fn(), - }, + mutation: vi.fn(), })); -import { client } from "../client.js"; +import { mutation } from "../client.js"; describe("taskStatus", () => { beforeEach(() => { @@ -16,11 +14,11 @@ describe("taskStatus", () => { }); it("updates task status", async () => { - vi.mocked(client.mutation).mockResolvedValue(undefined); + vi.mocked(mutation).mockResolvedValue(undefined); await taskStatus("task-123", "in_progress", {}); - expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { + expect(mutation).toHaveBeenCalledWith(expect.anything(), { taskId: "task-123", status: "in_progress", bySessionKey: undefined, @@ -31,11 +29,11 @@ describe("taskStatus", () => { }); it("updates task status with agent attribution", async () => { - vi.mocked(client.mutation).mockResolvedValue(undefined); + vi.mocked(mutation).mockResolvedValue(undefined); await taskStatus("task-456", "done", { by: "agent:main:main" }); - expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { + expect(mutation).toHaveBeenCalledWith(expect.anything(), { taskId: "task-456", status: "done", bySessionKey: "agent:main:main", diff --git a/packages/cli/src/commands/task-status.ts b/packages/cli/src/commands/task-status.ts index f56aecd..ad77d9b 100644 --- a/packages/cli/src/commands/task-status.ts +++ b/packages/cli/src/commands/task-status.ts @@ -1,4 +1,4 @@ -import { client } from "../client.js"; +import { mutation } from "../client.js"; import { api } from "@clawe/backend"; import type { Id } from "@clawe/backend/dataModel"; @@ -27,7 +27,7 @@ export async function taskStatus( process.exit(1); } - await client.mutation(api.tasks.updateStatus, { + await mutation(api.tasks.updateStatus, { taskId: taskId as Id<"tasks">, status: status as TaskStatus, bySessionKey: options.by, diff --git a/packages/cli/src/commands/task-view.spec.ts b/packages/cli/src/commands/task-view.spec.ts index db8192d..fd746d2 100644 --- a/packages/cli/src/commands/task-view.spec.ts +++ b/packages/cli/src/commands/task-view.spec.ts @@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { taskView } from "./task-view.js"; vi.mock("../client.js", () => ({ - client: { - query: vi.fn(), - }, + query: vi.fn(), })); -import { client } from "../client.js"; +import { query } from "../client.js"; describe("taskView", () => { beforeEach(() => { @@ -17,7 +15,7 @@ describe("taskView", () => { }); it("displays task not found error", async () => { - vi.mocked(client.query).mockResolvedValue(null); + vi.mocked(query).mockResolvedValue(null); const mockExit = vi.spyOn(process, "exit").mockImplementation((code) => { throw new Error(`process.exit(${code})`); }); @@ -33,7 +31,7 @@ describe("taskView", () => { }); it("displays basic task info", async () => { - vi.mocked(client.query).mockResolvedValue({ + vi.mocked(query).mockResolvedValue({ _id: "task-123", title: "Write documentation", status: "in_progress", @@ -49,7 +47,7 @@ describe("taskView", () => { }); it("displays description when present", async () => { - vi.mocked(client.query).mockResolvedValue({ + vi.mocked(query).mockResolvedValue({ _id: "task-456", title: "Task with desc", status: "assigned", @@ -63,7 +61,7 @@ describe("taskView", () => { }); it("displays subtasks with progress", async () => { - vi.mocked(client.query).mockResolvedValue({ + vi.mocked(query).mockResolvedValue({ _id: "task-789", title: "Task with subtasks", status: "in_progress", @@ -85,7 +83,7 @@ describe("taskView", () => { }); it("displays deliverables", async () => { - vi.mocked(client.query).mockResolvedValue({ + vi.mocked(query).mockResolvedValue({ _id: "task-del", title: "Task with deliverables", status: "done", @@ -102,7 +100,7 @@ describe("taskView", () => { it("displays comments", async () => { const now = Date.now(); - vi.mocked(client.query).mockResolvedValue({ + vi.mocked(query).mockResolvedValue({ _id: "task-comments", title: "Task with comments", status: "review", diff --git a/packages/cli/src/commands/task-view.ts b/packages/cli/src/commands/task-view.ts index 2e18074..c37edca 100644 --- a/packages/cli/src/commands/task-view.ts +++ b/packages/cli/src/commands/task-view.ts @@ -1,9 +1,9 @@ -import { client } from "../client.js"; +import { query } from "../client.js"; import { api } from "@clawe/backend"; import type { Id } from "@clawe/backend/dataModel"; export async function taskView(taskId: string): Promise { - const task = await client.query(api.tasks.get, { + const task = await query(api.tasks.get, { taskId: taskId as Id<"tasks">, }); diff --git a/packages/cli/src/commands/tasks.spec.ts b/packages/cli/src/commands/tasks.spec.ts index 159a991..d13f10f 100644 --- a/packages/cli/src/commands/tasks.spec.ts +++ b/packages/cli/src/commands/tasks.spec.ts @@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { tasks } from "./tasks.js"; vi.mock("../client.js", () => ({ - client: { - query: vi.fn(), - }, + query: vi.fn(), })); -import { client } from "../client.js"; +import { query } from "../client.js"; describe("tasks", () => { beforeEach(() => { @@ -16,7 +14,7 @@ describe("tasks", () => { }); it("displays message when no tasks", async () => { - vi.mocked(client.query).mockResolvedValue([]); + vi.mocked(query).mockResolvedValue([]); await tasks("agent:main:main"); @@ -24,7 +22,7 @@ describe("tasks", () => { }); it("lists active tasks with details", async () => { - vi.mocked(client.query).mockResolvedValue([ + vi.mocked(query).mockResolvedValue([ { _id: "task-1", title: "Write tests", @@ -47,7 +45,7 @@ describe("tasks", () => { }); it("handles tasks without priority", async () => { - vi.mocked(client.query).mockResolvedValue([ + vi.mocked(query).mockResolvedValue([ { _id: "task-2", title: "Simple task", diff --git a/packages/cli/src/commands/tasks.ts b/packages/cli/src/commands/tasks.ts index 436c80d..57c2824 100644 --- a/packages/cli/src/commands/tasks.ts +++ b/packages/cli/src/commands/tasks.ts @@ -1,8 +1,8 @@ -import { client } from "../client.js"; +import { query } from "../client.js"; import { api } from "@clawe/backend"; export async function tasks(sessionKey: string): Promise { - const taskList = await client.query(api.tasks.getForAgent, { sessionKey }); + const taskList = await query(api.tasks.getForAgent, { sessionKey }); if (taskList.length === 0) { console.log("No active tasks."); diff --git a/packages/shared/package.json b/packages/shared/package.json index 97f8415..d2af38f 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -4,9 +4,9 @@ "private": true, "type": "module", "exports": { - "./agency": { - "types": "./dist/agency/index.d.ts", - "default": "./dist/agency/index.js" + "./squadhub": { + "types": "./dist/squadhub/index.d.ts", + "default": "./dist/squadhub/index.js" }, "./agents": { "types": "./dist/agents/index.d.ts", diff --git a/packages/shared/src/agency/client.spec.ts b/packages/shared/src/squadhub/client.spec.ts similarity index 89% rename from packages/shared/src/agency/client.spec.ts rename to packages/shared/src/squadhub/client.spec.ts index 3a70df8..b750415 100644 --- a/packages/shared/src/agency/client.spec.ts +++ b/packages/shared/src/squadhub/client.spec.ts @@ -32,8 +32,14 @@ import { probeTelegramToken, patchConfig, } from "./client"; +import type { SquadhubConnection } from "./client"; -describe("Agency Client", () => { +const connection: SquadhubConnection = { + squadhubUrl: "http://localhost:18789", + squadhubToken: "test-token", +}; + +describe("Squadhub Client", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -47,7 +53,7 @@ describe("Agency Client", () => { }, }); - const result = await checkHealth(); + const result = await checkHealth(connection); expect(result.ok).toBe(true); if (result.ok) { expect(result.result.hash).toBe("abc123"); @@ -57,7 +63,7 @@ describe("Agency Client", () => { it("returns error when gateway is unreachable", async () => { mockPost.mockRejectedValueOnce(new Error("Network error")); - const result = await checkHealth(); + const result = await checkHealth(connection); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.type).toBe("unreachable"); @@ -72,7 +78,7 @@ describe("Agency Client", () => { }, }); - const result = await checkHealth(); + const result = await checkHealth(connection); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.type).toBe("unhealthy"); @@ -133,7 +139,7 @@ describe("Agency Client", () => { }, }); - const result = await saveTelegramBotToken("123456:ABC-DEF"); + const result = await saveTelegramBotToken(connection, "123456:ABC-DEF"); expect(result.ok).toBe(true); expect(mockPost).toHaveBeenCalledWith("/tools/invoke", { @@ -156,7 +162,7 @@ describe("Agency Client", () => { }, }); - const result = await patchConfig({ + const result = await patchConfig(connection, { models: { providers: { anthropic: { apiKey: "sk-test" } } }, }); @@ -182,7 +188,7 @@ describe("Agency Client", () => { mockPost.mockRejectedValueOnce(axiosError); - const result = await patchConfig({ test: true }); + const result = await patchConfig(connection, { test: true }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.type).toBe("http_error"); @@ -193,7 +199,7 @@ describe("Agency Client", () => { it("returns error on network error", async () => { mockPost.mockRejectedValueOnce(new Error("Network error")); - const result = await patchConfig({ test: true }); + const result = await patchConfig(connection, { test: true }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.type).toBe("network_error"); diff --git a/packages/shared/src/agency/client.ts b/packages/shared/src/squadhub/client.ts similarity index 72% rename from packages/shared/src/agency/client.ts rename to packages/shared/src/squadhub/client.ts index ae6a186..0d5b156 100644 --- a/packages/shared/src/agency/client.ts +++ b/packages/shared/src/squadhub/client.ts @@ -8,30 +8,30 @@ import type { TelegramProbeResult, } from "./types"; -let _agencyClient: ReturnType | null = null; +export type SquadhubConnection = { + squadhubUrl: string; + squadhubToken: string; +}; -function getAgencyClient() { - if (!_agencyClient) { - const url = process.env.AGENCY_URL || "http://localhost:18789"; - const token = process.env.AGENCY_TOKEN || ""; - _agencyClient = axios.create({ - baseURL: url, - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }); - } - return _agencyClient; +function createClient(connection: SquadhubConnection) { + return axios.create({ + baseURL: connection.squadhubUrl, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${connection.squadhubToken}`, + }, + }); } async function invokeTool( + connection: SquadhubConnection, tool: string, action?: string, args?: Record, ): Promise> { try { - const { data } = await getAgencyClient().post("/tools/invoke", { + const client = createClient(connection); + const { data } = await client.post("/tools/invoke", { tool, action, args, @@ -57,10 +57,12 @@ async function invokeTool( } } -// Health check - uses sessions_list to verify gateway connectivity -export async function checkHealth(): Promise> { +export async function checkHealth( + connection: SquadhubConnection, +): Promise> { try { - const { data } = await getAgencyClient().post("/tools/invoke", { + const client = createClient(connection); + const { data } = await client.post("/tools/invoke", { tool: "sessions_list", action: "json", }); @@ -83,9 +85,10 @@ export async function checkHealth(): Promise> { // Telegram Configuration export async function saveTelegramBotToken( + connection: SquadhubConnection, botToken: string, ): Promise> { - return patchConfig({ + return patchConfig(connection, { channels: { telegram: { enabled: true, @@ -96,10 +99,10 @@ export async function saveTelegramBotToken( }); } -export async function removeTelegramBotToken(): Promise< - ToolResult -> { - return patchConfig({ +export async function removeTelegramBotToken( + connection: SquadhubConnection, +): Promise> { + return patchConfig(connection, { channels: { telegram: { enabled: false, @@ -143,15 +146,18 @@ export async function probeTelegramToken( } // Configuration -export async function getConfig(): Promise> { - return invokeTool("gateway", "config.get"); +export async function getConfig( + connection: SquadhubConnection, +): Promise> { + return invokeTool(connection, "gateway", "config.get"); } export async function patchConfig( + connection: SquadhubConnection, config: Record, baseHash?: string, ): Promise> { - return invokeTool("gateway", "config.patch", { + return invokeTool(connection, "gateway", "config.patch", { raw: JSON.stringify(config), baseHash, }); @@ -159,34 +165,41 @@ export async function patchConfig( // Sessions export async function listSessions( + connection: SquadhubConnection, activeMinutes?: number, ): Promise> { - return invokeTool("sessions_list", "json", { activeMinutes }); + return invokeTool(connection, "sessions_list", "json", { activeMinutes }); } // Messages export async function sendMessage( + connection: SquadhubConnection, channel: string, target: string, message: string, ): Promise> { - return invokeTool("message", undefined, { channel, target, message }); + return invokeTool(connection, "message", undefined, { + channel, + target, + message, + }); } // Sessions - Send message to an agent session export async function sessionsSend( + connection: SquadhubConnection, sessionKey: string, message: string, timeoutSeconds?: number, ): Promise> { - return invokeTool("sessions_send", undefined, { + return invokeTool(connection, "sessions_send", undefined, { sessionKey, message, timeoutSeconds: timeoutSeconds ?? 10, }); } -// Cron types (matching agency src/cron/types.ts) +// Cron types (matching squadhub src/cron/types.ts) export type CronSchedule = | { kind: "at"; at: string } | { kind: "every"; everyMs: number; anchorMs?: number } @@ -263,13 +276,16 @@ export interface CronAddJob { } // Cron - List jobs -export async function cronList(): Promise> { - return invokeTool("cron", undefined, { action: "list" }); +export async function cronList( + connection: SquadhubConnection, +): Promise> { + return invokeTool(connection, "cron", undefined, { action: "list" }); } // Cron - Add job export async function cronAdd( + connection: SquadhubConnection, job: CronAddJob, ): Promise> { - return invokeTool("cron", undefined, { action: "add", job }); + return invokeTool(connection, "cron", undefined, { action: "add", job }); } diff --git a/packages/shared/src/agency/gateway-client.spec.ts b/packages/shared/src/squadhub/gateway-client.spec.ts similarity index 67% rename from packages/shared/src/agency/gateway-client.spec.ts rename to packages/shared/src/squadhub/gateway-client.spec.ts index 014f40d..b871298 100644 --- a/packages/shared/src/agency/gateway-client.spec.ts +++ b/packages/shared/src/squadhub/gateway-client.spec.ts @@ -13,6 +13,11 @@ vi.mock("ws", () => { }; }); +const connection = { + squadhubUrl: "http://localhost:18789", + squadhubToken: "test-token", +}; + describe("GatewayClient", () => { let client: GatewayClient; @@ -56,38 +61,15 @@ describe("GatewayClient", () => { }); describe("createGatewayClient", () => { - const originalEnv = process.env; - - beforeEach(() => { - vi.resetModules(); - process.env = { ...originalEnv }; - }); - - afterEach(() => { - process.env = originalEnv; - }); - - it("creates client with default URL when env not set", () => { - delete process.env.AGENCY_URL; - delete process.env.AGENCY_TOKEN; - - const client = createGatewayClient(); - expect(client).toBeInstanceOf(GatewayClient); - client.close(); - }); - - it("creates client with env URL and token", () => { - process.env.AGENCY_URL = "http://custom:8080"; - process.env.AGENCY_TOKEN = "custom-token"; - - const client = createGatewayClient(); + it("creates client with connection params", () => { + const client = createGatewayClient(connection); expect(client).toBeInstanceOf(GatewayClient); client.close(); }); - it("merges custom options with env config", () => { + it("merges custom options with connection", () => { const onEvent = vi.fn(); - const client = createGatewayClient({ onEvent }); + const client = createGatewayClient(connection, { onEvent }); expect(client).toBeInstanceOf(GatewayClient); client.close(); }); diff --git a/packages/shared/src/agency/gateway-client.ts b/packages/shared/src/squadhub/gateway-client.ts similarity index 94% rename from packages/shared/src/agency/gateway-client.ts rename to packages/shared/src/squadhub/gateway-client.ts index 7a148cb..f99db98 100644 --- a/packages/shared/src/agency/gateway-client.ts +++ b/packages/shared/src/squadhub/gateway-client.ts @@ -24,8 +24,8 @@ export type GatewayClientOptions = { const PROTOCOL_VERSION = 3; /** - * Server-side WebSocket client for agency gateway. - * Used by API routes to communicate with the agency. + * Server-side WebSocket client for squadhub gateway. + * Used by API routes to communicate with squadhub. */ export class GatewayClient { private ws: WebSocket | null = null; @@ -242,17 +242,15 @@ export class GatewayClient { } /** - * Create a gateway client with environment config. + * Create a gateway client with explicit connection params. */ export function createGatewayClient( - options?: Partial, + connection: { squadhubUrl: string; squadhubToken: string }, + options?: Partial>, ): GatewayClient { - const url = process.env.AGENCY_URL || "http://localhost:18789"; - const token = process.env.AGENCY_TOKEN || ""; - return new GatewayClient({ - url, - token, + url: connection.squadhubUrl, + token: connection.squadhubToken, ...options, }); } diff --git a/packages/shared/src/agency/gateway-types.ts b/packages/shared/src/squadhub/gateway-types.ts similarity index 100% rename from packages/shared/src/agency/gateway-types.ts rename to packages/shared/src/squadhub/gateway-types.ts diff --git a/packages/shared/src/agency/index.ts b/packages/shared/src/squadhub/index.ts similarity index 98% rename from packages/shared/src/agency/index.ts rename to packages/shared/src/squadhub/index.ts index c114810..10d4321 100644 --- a/packages/shared/src/agency/index.ts +++ b/packages/shared/src/squadhub/index.ts @@ -13,6 +13,7 @@ export { cronAdd, } from "./client"; export type { + SquadhubConnection, CronJob, CronListResult, CronAddJob, diff --git a/packages/shared/src/agency/pairing.ts b/packages/shared/src/squadhub/pairing.ts similarity index 91% rename from packages/shared/src/agency/pairing.ts rename to packages/shared/src/squadhub/pairing.ts index e5a7cbf..7591345 100644 --- a/packages/shared/src/agency/pairing.ts +++ b/packages/shared/src/squadhub/pairing.ts @@ -7,11 +7,11 @@ import type { PairingApproveResult, DirectResult, } from "./types"; -import { getConfig, patchConfig } from "./client"; +import { getConfig, patchConfig, type SquadhubConnection } from "./client"; -const AGENCY_STATE_DIR = - process.env.AGENCY_STATE_DIR || path.join(os.homedir(), ".agency"); -const CREDENTIALS_DIR = path.join(AGENCY_STATE_DIR, "credentials"); +const SQUADHUB_STATE_DIR = + process.env.SQUADHUB_STATE_DIR || path.join(os.homedir(), ".squadhub"); +const CREDENTIALS_DIR = path.join(SQUADHUB_STATE_DIR, "credentials"); type PairingStore = { version: 1; @@ -84,6 +84,7 @@ export async function listChannelPairingRequests( } export async function approveChannelPairingCode( + connection: SquadhubConnection, channel: string, code: string, ): Promise> { @@ -98,7 +99,7 @@ export async function approveChannelPairingCode( const normalizedCode = code.trim().toUpperCase(); const pairingPath = resolvePairingPath(channel); - // Read current pairing requests (file-based - agency writes these) + // Read current pairing requests (file-based - squadhub writes these) const store = await readJsonFile(pairingPath, { version: 1, requests: [], @@ -121,7 +122,7 @@ export async function approveChannelPairingCode( } // Get current config to read existing allowFrom list - const configResult = await getConfig(); + const configResult = await getConfig(connection); if (!configResult.ok) { return { ok: false, @@ -145,6 +146,7 @@ export async function approveChannelPairingCode( // Add user ID to allowFrom if not already present if (!existingAllowFrom.includes(entry.id)) { const patchResult = await patchConfig( + connection, { channels: { [channel]: { diff --git a/packages/shared/src/agency/shared-client.ts b/packages/shared/src/squadhub/shared-client.ts similarity index 86% rename from packages/shared/src/agency/shared-client.ts rename to packages/shared/src/squadhub/shared-client.ts index 8df9142..4bb0895 100644 --- a/packages/shared/src/agency/shared-client.ts +++ b/packages/shared/src/squadhub/shared-client.ts @@ -1,5 +1,6 @@ import { GatewayClient, createGatewayClient } from "./gateway-client"; import type { GatewayClientOptions } from "./gateway-client"; +import type { SquadhubConnection } from "./client"; let sharedClient: GatewayClient | null = null; let connectingPromise: Promise | null = null; @@ -9,7 +10,8 @@ let connectingPromise: Promise | null = null; * Reconnects automatically if the connection drops. */ export async function getSharedClient( - options?: Partial, + connection: SquadhubConnection, + options?: Partial>, ): Promise { if (sharedClient?.isConnected()) { return sharedClient; @@ -26,7 +28,7 @@ export async function getSharedClient( // Create new client connectingPromise = (async () => { sharedClient?.close(); - sharedClient = createGatewayClient({ + sharedClient = createGatewayClient(connection, { ...options, onClose: (_code, _reason) => { // Mark as disconnected; next call will reconnect diff --git a/packages/shared/src/agency/types.ts b/packages/shared/src/squadhub/types.ts similarity index 87% rename from packages/shared/src/agency/types.ts rename to packages/shared/src/squadhub/types.ts index 7091921..3ed726d 100644 --- a/packages/shared/src/agency/types.ts +++ b/packages/shared/src/squadhub/types.ts @@ -1,4 +1,4 @@ -// AgentToolResult matches agency's tool execution result structure +// AgentToolResult matches squadhub's tool execution result structure export type AgentToolResult = { content: Array<{ type: string; @@ -9,12 +9,12 @@ export type AgentToolResult = { details: T; }; -// ToolResult for agency tool invocations (result contains content + details) +// ToolResult for squadhub tool invocations (result contains content + details) export type ToolResult = | { ok: true; result: AgentToolResult } | { ok: false; error: { type: string; message: string } }; -// DirectResult for operations that don't go through agency tools +// DirectResult for operations that don't go through squadhub tools export type DirectResult = | { ok: true; result: T } | { ok: false; error: { type: string; message: string } }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 322a3f0..3ba1fdf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -206,6 +206,9 @@ importers: '@clawe/typescript-config': specifier: workspace:* version: link:../typescript-config + '@types/node': + specifier: ^25.2.3 + version: 25.2.3 typescript: specifier: 5.9.2 version: 5.9.2 @@ -2322,6 +2325,9 @@ packages: '@types/node@22.15.3': resolution: {integrity: sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==} + '@types/node@25.2.3': + resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} + '@types/react-dom@19.2.2': resolution: {integrity: sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==} peerDependencies: @@ -4553,6 +4559,9 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@7.21.0: resolution: {integrity: sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==} engines: {node: '>=20.18.1'} @@ -6615,6 +6624,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@25.2.3': + dependencies: + undici-types: 7.16.0 + '@types/react-dom@19.2.2(@types/react@19.2.2)': dependencies: '@types/react': 19.2.2 @@ -9421,6 +9434,8 @@ snapshots: undici-types@6.21.0: {} + undici-types@7.16.0: {} + undici@7.21.0: {} unified@11.0.5: diff --git a/scripts/start.sh b/scripts/start.sh index a19ba7c..e7bc598 100755 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -31,7 +31,7 @@ if [ ! -f .env ]; then sed -i "s/your-secure-token-here/$TOKEN/" .env fi - echo_info "Generated AGENCY_TOKEN: ${TOKEN:0:8}..." + echo_info "Generated SQUADHUB_TOKEN: ${TOKEN:0:8}..." echo_warn "Please edit .env and set your ANTHROPIC_API_KEY and CONVEX_URL" fi @@ -51,8 +51,8 @@ if [ -z "$CONVEX_URL" ] || [ "$CONVEX_URL" = "https://your-deployment.convex.clo MISSING_VARS+=("CONVEX_URL") fi -if [ -z "$AGENCY_TOKEN" ] || [ "$AGENCY_TOKEN" = "your-secure-token-here" ]; then - MISSING_VARS+=("AGENCY_TOKEN") +if [ -z "$SQUADHUB_TOKEN" ] || [ "$SQUADHUB_TOKEN" = "your-secure-token-here" ]; then + MISSING_VARS+=("SQUADHUB_TOKEN") fi if [ ${#MISSING_VARS[@]} -gt 0 ]; then diff --git a/turbo.json b/turbo.json index 45c727c..66b008e 100644 --- a/turbo.json +++ b/turbo.json @@ -6,10 +6,10 @@ "CONVEX_URL", "ANTHROPIC_API_KEY", "OPENAI_API_KEY", - "AGENCY_URL", - "AGENCY_TOKEN", + "SQUADHUB_URL", + "SQUADHUB_TOKEN", "CLAWE_DATA_DIR", - "AGENCY_STATE_DIR" + "SQUADHUB_STATE_DIR" ], "tasks": { "build": { From 73ba72f82fc9a001e9252f551a69d21c36f7952d Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Sun, 15 Feb 2026 21:44:52 +0200 Subject: [PATCH 2/8] fix: refactor codebase --- apps/watcher/README.md | 6 +-- apps/watcher/src/index.ts | 5 +- .../_components/telegram-setup-dialog.tsx | 6 ++- apps/web/src/app/api/chat/abort/route.spec.ts | 2 +- .../src/app/api/chat/history/route.spec.ts | 2 +- apps/web/src/lib/squadhub/actions.spec.ts | 5 +- apps/web/src/lib/squadhub/provision.ts | 51 ++++++++++++++++--- packages/backend/convex/activities.ts | 4 +- packages/backend/convex/lib/auth.ts | 12 ++--- packages/backend/convex/notifications.ts | 4 +- 10 files changed, 65 insertions(+), 32 deletions(-) diff --git a/apps/watcher/README.md b/apps/watcher/README.md index c1ade99..b522179 100644 --- a/apps/watcher/README.md +++ b/apps/watcher/README.md @@ -15,9 +15,9 @@ This enables: ## Environment Variables -| Variable | Required | Description | -| -------------- | -------- | --------------------------- | -| `CONVEX_URL` | Yes | Convex deployment URL | +| Variable | Required | Description | +| ---------------- | -------- | ----------------------------- | +| `CONVEX_URL` | Yes | Convex deployment URL | | `SQUADHUB_URL` | Yes | Squadhub gateway URL | | `SQUADHUB_TOKEN` | Yes | Squadhub authentication token | diff --git a/apps/watcher/src/index.ts b/apps/watcher/src/index.ts index 7eb5035..c4a58d4 100644 --- a/apps/watcher/src/index.ts +++ b/apps/watcher/src/index.ts @@ -18,10 +18,7 @@ import { ConvexHttpClient } from "convex/browser"; import { api } from "@clawe/backend"; -import { - sessionsSend, - type SquadhubConnection, -} from "@clawe/shared/squadhub"; +import { sessionsSend, type SquadhubConnection } from "@clawe/shared/squadhub"; import { getTimeInZone, DEFAULT_TIMEZONE } from "@clawe/shared/timezone"; import { validateEnv, config, POLL_INTERVAL_MS } from "./config.js"; diff --git a/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-setup-dialog.tsx b/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-setup-dialog.tsx index fe799f3..7967625 100644 --- a/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-setup-dialog.tsx +++ b/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-setup-dialog.tsx @@ -217,7 +217,8 @@ export const TelegramSetupDialog = ({ Squadhub is offline

- The squadhub service needs to be running to verify pairing. + The squadhub service needs to be running to verify + pairing.

@@ -357,7 +358,8 @@ export const TelegramSetupDialog = ({ Squadhub is offline

- The squadhub service needs to be running to connect Telegram. + The squadhub service needs to be running to connect + Telegram.

diff --git a/apps/web/src/app/api/chat/abort/route.spec.ts b/apps/web/src/app/api/chat/abort/route.spec.ts index b7b1b2c..f10ba65 100644 --- a/apps/web/src/app/api/chat/abort/route.spec.ts +++ b/apps/web/src/app/api/chat/abort/route.spec.ts @@ -6,7 +6,7 @@ import { POST } from "./route"; const mockRequest = vi.fn(); vi.mock("@clawe/shared/squadhub", () => ({ - getSharedClient: vi.fn(async (_connection: unknown) => ({ + getSharedClient: vi.fn(async () => ({ request: mockRequest, isConnected: vi.fn().mockReturnValue(true), })), diff --git a/apps/web/src/app/api/chat/history/route.spec.ts b/apps/web/src/app/api/chat/history/route.spec.ts index f5b62e0..6c76a70 100644 --- a/apps/web/src/app/api/chat/history/route.spec.ts +++ b/apps/web/src/app/api/chat/history/route.spec.ts @@ -6,7 +6,7 @@ import { GET } from "./route"; const mockRequest = vi.fn(); vi.mock("@clawe/shared/squadhub", () => ({ - getSharedClient: vi.fn(async (_connection: unknown) => ({ + getSharedClient: vi.fn(async () => ({ request: mockRequest, isConnected: vi.fn().mockReturnValue(true), })), diff --git a/apps/web/src/lib/squadhub/actions.spec.ts b/apps/web/src/lib/squadhub/actions.spec.ts index 930b39d..8f1b6d6 100644 --- a/apps/web/src/lib/squadhub/actions.spec.ts +++ b/apps/web/src/lib/squadhub/actions.spec.ts @@ -56,7 +56,10 @@ describe("Squadhub Actions", () => { expect(probeTelegramToken).toHaveBeenCalledWith("123456:ABC-DEF"); expect(saveTelegramBotTokenClient).toHaveBeenCalledWith( - expect.objectContaining({ squadhubUrl: expect.any(String), squadhubToken: expect.any(String) }), + expect.objectContaining({ + squadhubUrl: expect.any(String), + squadhubToken: expect.any(String), + }), "123456:ABC-DEF", ); expect(result.ok).toBe(true); diff --git a/apps/web/src/lib/squadhub/provision.ts b/apps/web/src/lib/squadhub/provision.ts index 6ce32d7..2b19c3c 100644 --- a/apps/web/src/lib/squadhub/provision.ts +++ b/apps/web/src/lib/squadhub/provision.ts @@ -13,10 +13,34 @@ import { * Default agent definitions for new tenants. */ const DEFAULT_AGENTS = [ - { id: "main", name: "Clawe", emoji: "\u{1F99E}", role: "Squad Lead", cron: "0,15,30,45 * * * *" }, - { id: "inky", name: "Inky", emoji: "\u270D\uFE0F", role: "Writer", cron: "3,18,33,48 * * * *" }, - { id: "pixel", name: "Pixel", emoji: "\u{1F3A8}", role: "Designer", cron: "7,22,37,52 * * * *" }, - { id: "scout", name: "Scout", emoji: "\u{1F50D}", role: "SEO", cron: "11,26,41,56 * * * *" }, + { + id: "main", + name: "Clawe", + emoji: "\u{1F99E}", + role: "Squad Lead", + cron: "0,15,30,45 * * * *", + }, + { + id: "inky", + name: "Inky", + emoji: "\u270D\uFE0F", + role: "Writer", + cron: "3,18,33,48 * * * *", + }, + { + id: "pixel", + name: "Pixel", + emoji: "\u{1F3A8}", + role: "Designer", + cron: "7,22,37,52 * * * *", + }, + { + id: "scout", + name: "Scout", + emoji: "\u{1F50D}", + role: "SEO", + cron: "11,26,41,56 * * * *", + }, ]; const HEARTBEAT_MESSAGE = @@ -38,14 +62,24 @@ const SEED_ROUTINES = [ title: "Morning Brief", description: "Prepare daily morning brief for the team", priority: "high" as const, - schedule: { type: "weekly" as const, daysOfWeek: [0, 1, 2, 3, 4, 5, 6], hour: 8, minute: 0 }, + schedule: { + type: "weekly" as const, + daysOfWeek: [0, 1, 2, 3, 4, 5, 6], + hour: 8, + minute: 0, + }, color: "amber", }, { title: "Competitor Scan", description: "Scan competitor activities and updates", priority: "normal" as const, - schedule: { type: "weekly" as const, daysOfWeek: [1, 4], hour: 10, minute: 0 }, + schedule: { + type: "weekly" as const, + daysOfWeek: [1, 4], + hour: 10, + minute: 0, + }, color: "rose", }, ]; @@ -99,7 +133,10 @@ async function setupCrons(connection: SquadhubConnection): Promise<{ const result = await cronList(connection); if (!result.ok) { - return { count: 0, errors: [`Failed to list crons: ${result.error?.message}`] }; + return { + count: 0, + errors: [`Failed to list crons: ${result.error?.message}`], + }; } const existingNames = new Set( diff --git a/packages/backend/convex/activities.ts b/packages/backend/convex/activities.ts index 000113f..c00fb8e 100644 --- a/packages/backend/convex/activities.ts +++ b/packages/backend/convex/activities.ts @@ -91,9 +91,7 @@ export const byType = query({ .order("desc") .collect(); - return allActivities - .filter((a) => a.type === filters.type) - .slice(0, limit); + return allActivities.filter((a) => a.type === filters.type).slice(0, limit); }, }); diff --git a/packages/backend/convex/lib/auth.ts b/packages/backend/convex/lib/auth.ts index 414f1d1..770d26b 100644 --- a/packages/backend/convex/lib/auth.ts +++ b/packages/backend/convex/lib/auth.ts @@ -65,9 +65,7 @@ async function getTenantIdFromUser( * Browser path: resolve tenantId from the JWT identity. * Gets the user, then looks up: accountMembers → account → tenants. */ -export async function getTenantIdFromJwt( - ctx: ReadCtx, -): Promise> { +export async function getTenantIdFromJwt(ctx: ReadCtx): Promise> { const user = await getUser(ctx); const tenantId = await getTenantIdFromUser(ctx, user._id); @@ -116,9 +114,7 @@ export function validateWatcherToken( /** * Dev-only: get or create a default dev tenant (used when AUTH_ENABLED=false). */ -async function getOrCreateDevTenant( - ctx: MutationCtx, -): Promise> { +async function getOrCreateDevTenant(ctx: MutationCtx): Promise> { // Look for existing dev user let user = await ctx.db .query("users") @@ -205,7 +201,9 @@ export async function resolveTenantId( } } - throw new Error("No dev tenant found. Run a mutation first to auto-create it."); + throw new Error( + "No dev tenant found. Run a mutation first to auto-create it.", + ); } /** diff --git a/packages/backend/convex/notifications.ts b/packages/backend/convex/notifications.ts index bc009ef..3130490 100644 --- a/packages/backend/convex/notifications.ts +++ b/packages/backend/convex/notifications.ts @@ -222,9 +222,7 @@ export const sendToMany = mutation({ } for (const targetSessionKey of args.targetSessionKeys) { - const targetAgent = agents.find( - (a) => a.sessionKey === targetSessionKey, - ); + const targetAgent = agents.find((a) => a.sessionKey === targetSessionKey); if (targetAgent) { const id = await ctx.db.insert("notifications", { From 1acaba761b736ced23ed14cfdf55d46639802282 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Sun, 15 Feb 2026 21:56:42 +0200 Subject: [PATCH 3/8] fix: refactor codebase --- apps/watcher/src/config.ts | 1 + apps/watcher/src/index.ts | 33 ++++++-- packages/backend/convex/tenants.ts | 131 ++++++++++++++++++++++++++++- 3 files changed, 159 insertions(+), 6 deletions(-) diff --git a/apps/watcher/src/config.ts b/apps/watcher/src/config.ts index 89079c7..9107b94 100644 --- a/apps/watcher/src/config.ts +++ b/apps/watcher/src/config.ts @@ -19,5 +19,6 @@ export const config = { convexUrl: process.env.CONVEX_URL || "", squadhubUrl: process.env.SQUADHUB_URL || "http://localhost:18789", squadhubToken: process.env.SQUADHUB_TOKEN || "", + watcherToken: process.env.WATCHER_TOKEN || "", pollIntervalMs: POLL_INTERVAL_MS, }; diff --git a/apps/watcher/src/index.ts b/apps/watcher/src/index.ts index c4a58d4..a1c6b98 100644 --- a/apps/watcher/src/index.ts +++ b/apps/watcher/src/index.ts @@ -8,10 +8,12 @@ * moved to the provisioning API route (POST /api/tenant/provision). * * Multi-tenant ready: iterates over active tenants each loop iteration. - * Currently falls back to single-tenant mode using SQUADHUB_URL/SQUADHUB_TOKEN env vars. + * When WATCHER_TOKEN is set, queries Convex for all active tenants. + * Falls back to single-tenant mode using SQUADHUB_URL/SQUADHUB_TOKEN env vars. * * Environment variables: * CONVEX_URL - Convex deployment URL + * WATCHER_TOKEN - System-level auth token for querying all tenants (optional) * SQUADHUB_URL - Squadhub gateway URL (single-tenant fallback) * SQUADHUB_TOKEN - Squadhub authentication token (single-tenant fallback) */ @@ -38,12 +40,28 @@ type TenantInfo = { /** * Get the list of active tenants to service. * - * TODO (Phase 2): Query Convex `tenants.listActive` with WATCHER_TOKEN - * to get all active tenants with their squadhubUrl + squadhubToken. + * When WATCHER_TOKEN is set, queries Convex `tenants.listActive` for all + * active tenants with their squadhub connection info. * - * For now, falls back to single-tenant mode using env vars. + * Falls back to single-tenant mode using SQUADHUB_URL/SQUADHUB_TOKEN env vars. */ async function getActiveTenants(): Promise { + if (config.watcherToken) { + const tenants = await convex.query(api.tenants.listActive, { + watcherToken: config.watcherToken, + }); + return tenants.map( + (t: { id: string; squadhubUrl: string; squadhubToken: string }) => ({ + id: t.id, + connection: { + squadhubUrl: t.squadhubUrl, + squadhubToken: t.squadhubToken, + }, + }), + ); + } + + // Fallback: single-tenant mode from env vars return [ { id: "default", @@ -270,7 +288,12 @@ async function startDeliveryLoop(): Promise { async function main(): Promise { console.log("[watcher] 🦞 Clawe Watcher starting..."); console.log(`[watcher] Convex: ${config.convexUrl}`); - console.log(`[watcher] Squadhub: ${config.squadhubUrl}`); + console.log( + `[watcher] Mode: ${config.watcherToken ? "multi-tenant (WATCHER_TOKEN)" : "single-tenant (env vars)"}`, + ); + if (!config.watcherToken) { + console.log(`[watcher] Squadhub: ${config.squadhubUrl}`); + } console.log(`[watcher] Notification poll interval: ${POLL_INTERVAL_MS}ms`); console.log( `[watcher] Routine check interval: ${ROUTINE_CHECK_INTERVAL_MS}ms\n`, diff --git a/packages/backend/convex/tenants.ts b/packages/backend/convex/tenants.ts index 10f7642..325276d 100644 --- a/packages/backend/convex/tenants.ts +++ b/packages/backend/convex/tenants.ts @@ -1,6 +1,11 @@ import { v } from "convex/values"; import { query, mutation } from "./_generated/server"; -import { resolveTenantId, resolveTenantIdMut } from "./lib/auth"; +import { + getUser, + resolveTenantId, + resolveTenantIdMut, + validateWatcherToken, +} from "./lib/auth"; const DEFAULT_TIMEZONE = "America/New_York"; @@ -66,3 +71,127 @@ export const completeOnboarding = mutation({ }); }, }); + +// Create a new tenant within an account +export const create = mutation({ + args: { + accountId: v.id("accounts"), + }, + handler: async (ctx, args) => { + const now = Date.now(); + const tenantId = await ctx.db.insert("tenants", { + accountId: args.accountId, + status: "provisioning", + createdAt: now, + updatedAt: now, + }); + return tenantId; + }, +}); + +// Get tenant for the current authenticated user +// Resolves: user → accountMembers → account → tenants +export const getForCurrentUser = query({ + args: {}, + handler: async (ctx) => { + const user = await getUser(ctx); + + const membership = await ctx.db + .query("accountMembers") + .withIndex("by_user", (q) => q.eq("userId", user._id)) + .first(); + + if (!membership) { + return null; + } + + return await ctx.db + .query("tenants") + .withIndex("by_account", (q) => q.eq("accountId", membership.accountId)) + .first(); + }, +}); + +// Update tenant provisioning status +export const updateStatus = mutation({ + args: { + tenantId: v.id("tenants"), + status: v.union( + v.literal("provisioning"), + v.literal("active"), + v.literal("stopped"), + v.literal("error"), + ), + squadhubUrl: v.optional(v.string()), + squadhubToken: v.optional(v.string()), + squadhubServiceArn: v.optional(v.string()), + efsAccessPointId: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const tenant = await ctx.db.get(args.tenantId); + if (!tenant) { + throw new Error("Tenant not found"); + } + + await ctx.db.patch(args.tenantId, { + status: args.status, + ...(args.squadhubUrl !== undefined && { + squadhubUrl: args.squadhubUrl, + }), + ...(args.squadhubToken !== undefined && { + squadhubToken: args.squadhubToken, + }), + ...(args.squadhubServiceArn !== undefined && { + squadhubServiceArn: args.squadhubServiceArn, + }), + ...(args.efsAccessPointId !== undefined && { + efsAccessPointId: args.efsAccessPointId, + }), + updatedAt: Date.now(), + }); + }, +}); + +// Store Anthropic API key in tenant record +export const setAnthropicKey = mutation({ + args: { + machineToken: v.optional(v.string()), + apiKey: v.string(), + }, + handler: async (ctx, args) => { + const tenantId = await resolveTenantIdMut(ctx, args); + const tenant = await ctx.db.get(tenantId); + if (!tenant) { + throw new Error("Tenant not found"); + } + + await ctx.db.patch(tenantId, { + anthropicApiKey: args.apiKey, + updatedAt: Date.now(), + }); + }, +}); + +// List all active tenants with their squadhub connection info +// Requires a valid WATCHER_TOKEN for system-level auth +export const listActive = query({ + args: { + watcherToken: v.string(), + }, + handler: async (ctx, args) => { + validateWatcherToken(ctx, args.watcherToken); + + const tenants = await ctx.db + .query("tenants") + .withIndex("by_status", (q) => q.eq("status", "active")) + .collect(); + + return tenants + .filter((t) => t.squadhubUrl && t.squadhubToken) + .map((t) => ({ + id: t._id, + squadhubUrl: t.squadhubUrl!, + squadhubToken: t.squadhubToken!, + })); + }, +}); From ad9cac03b46a62a6c4fd71f3a6c53d6ab88d0ec9 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Mon, 16 Feb 2026 14:39:43 +0200 Subject: [PATCH 4/8] fix: refactor codebase --- .env.example | 26 + .gitignore | 4 + apps/web/package.json | 3 + .../app/(dashboard)/_components/nav-user.tsx | 3 +- .../_components/business-settings-form.tsx | 1 - .../src/app/api/auth/[...nextauth]/route.ts | 3 + apps/web/src/app/api/auth/jwks/route.ts | 3 + apps/web/src/app/api/auth/token/route.ts | 11 + .../web/src/app/api/business/context/route.ts | 2 - apps/web/src/app/auth/login/page.tsx | 133 ++ apps/web/src/app/layout.tsx | 25 +- apps/web/src/app/page.tsx | 27 +- .../app/setup/_components/setup-user-menu.tsx | 3 +- apps/web/src/app/setup/business/page.tsx | 2 +- apps/web/src/app/setup/complete/page.tsx | 2 +- .../user-menu/user-menu-content.tsx | 4 +- apps/web/src/hooks/use-onboarding-guard.ts | 37 +- apps/web/src/hooks/use-user-menu.ts | 21 +- apps/web/src/lib/auth/nextauth-config.ts | 107 ++ apps/web/src/providers/auth-provider.tsx | 252 +++ apps/web/src/providers/convex-provider.tsx | 11 +- apps/web/src/proxy.ts | 28 + package.json | 2 +- packages/backend/convex/_generated/api.d.ts | 10 +- packages/backend/convex/activities.ts | 6 +- packages/backend/convex/agents.ts | 18 +- .../backend/convex/auth.config.cognito.ts | 10 + .../backend/convex/auth.config.nextauth.ts | 13 + packages/backend/convex/auth.config.ts | 16 + packages/backend/convex/businessContext.ts | 40 +- packages/backend/convex/channels.ts | 6 +- packages/backend/convex/dev-jwks/jwks.json | 12 + packages/backend/convex/dev-jwks/private.pem | 28 + packages/backend/convex/dev-jwks/public.pem | 9 + packages/backend/convex/documents.ts | 10 +- packages/backend/convex/lib/auth.ts | 107 +- packages/backend/convex/messages.ts | 6 +- packages/backend/convex/notifications.ts | 10 +- packages/backend/convex/routines.ts | 10 +- packages/backend/convex/schema.ts | 2 - packages/backend/convex/tasks.ts | 26 +- packages/backend/convex/tenants.ts | 27 +- packages/backend/convex/users.ts | 42 +- packages/backend/package.json | 4 +- packages/cli/src/commands/business-get.ts | 1 - packages/cli/src/commands/business-set.ts | 6 - packages/cli/src/index.ts | 4 +- pnpm-lock.yaml | 1622 ++++++++++++++++- scripts/convex-deploy.sh | 41 + turbo.json | 8 +- 50 files changed, 2498 insertions(+), 306 deletions(-) create mode 100644 apps/web/src/app/api/auth/[...nextauth]/route.ts create mode 100644 apps/web/src/app/api/auth/jwks/route.ts create mode 100644 apps/web/src/app/api/auth/token/route.ts create mode 100644 apps/web/src/app/auth/login/page.tsx create mode 100644 apps/web/src/lib/auth/nextauth-config.ts create mode 100644 apps/web/src/providers/auth-provider.tsx create mode 100644 apps/web/src/proxy.ts create mode 100644 packages/backend/convex/auth.config.cognito.ts create mode 100644 packages/backend/convex/auth.config.nextauth.ts create mode 100644 packages/backend/convex/auth.config.ts create mode 100644 packages/backend/convex/dev-jwks/jwks.json create mode 100644 packages/backend/convex/dev-jwks/private.pem create mode 100644 packages/backend/convex/dev-jwks/public.pem create mode 100755 scripts/convex-deploy.sh diff --git a/.env.example b/.env.example index c3204c3..8376d0b 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,32 @@ ENVIRONMENT=dev # Production: http://squadhub:18789 (Docker internal network) SQUADHUB_URL=http://localhost:18790 +# ============================================================================= +# AUTHENTICATION +# ============================================================================= + +# Authentication provider: "nextauth" (local/self-hosted) or "cognito" (cloud) +# NextAuth uses auto-login with committed dev keys — zero config for local dev. +AUTH_PROVIDER=nextauth + +# NextAuth settings (only when AUTH_PROVIDER=nextauth) +NEXTAUTH_SECRET=clawe-dev-secret-change-in-production +NEXTAUTH_URL=http://localhost:3000 + +# Google OAuth credentials (for NextAuth Google provider) +# Required for self-hosted deployments. Get from Google Cloud Console. +# GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com +# GOOGLE_CLIENT_SECRET=your-client-secret + +# Auto-login email for local dev (skips Google OAuth, uses Credentials provider) +# Remove or leave empty to require Google sign-in +AUTO_LOGIN_EMAIL=dev@clawe.local + +# AWS Cognito settings (only when AUTH_PROVIDER=cognito) +# COGNITO_USER_POOL_ID=us-east-1_xxxxxxxxx +# COGNITO_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxx +# COGNITO_DOMAIN=your-domain.auth.us-east-1.amazoncognito.com + # ============================================================================= # ADVANCED (usually don't need to change) # ============================================================================= diff --git a/.gitignore b/.gitignore index 89b056f..54db792 100644 --- a/.gitignore +++ b/.gitignore @@ -40,12 +40,16 @@ yarn-error.log* # Misc .DS_Store *.pem +!packages/backend/convex/dev-jwks/*.pem # Claude Code local settings (personal permissions) .claude/settings.local.json convex/_generated +# auth.config.ts is committed as the NextAuth version (default for local dev). +# scripts/convex-deploy.sh overwrites it for cloud deployments. + # Local data directory (Clawe config) .data/ diff --git a/apps/web/package.json b/apps/web/package.json index 8b031cc..e32f630 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,11 +36,14 @@ "@tiptap/suggestion": "^3.15.3", "@xyflow/react": "^12.10.0", "ai": "^6.0.77", + "aws-amplify": "^6.16.2", "axios": "^1.13.4", "convex": "^1.21.0", "framer-motion": "^12.29.0", + "jose": "^6.1.3", "lucide-react": "^0.562.0", "next": "16.1.0", + "next-auth": "5.0.0-beta.30", "next-themes": "^0.4.6", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/apps/web/src/app/(dashboard)/_components/nav-user.tsx b/apps/web/src/app/(dashboard)/_components/nav-user.tsx index de1cd63..4a691c3 100644 --- a/apps/web/src/app/(dashboard)/_components/nav-user.tsx +++ b/apps/web/src/app/(dashboard)/_components/nav-user.tsx @@ -17,7 +17,7 @@ import { useUserMenu } from "@/hooks/use-user-menu"; export const NavUser = () => { const { isMobile } = useSidebar(); - const { guestMode, user, displayName, initials } = useUserMenu(); + const { guestMode, user, displayName, initials, signOut } = useUserMenu(); return ( @@ -58,6 +58,7 @@ export const NavUser = () => { align="end" sideOffset={4} className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg" + onSignOut={signOut} /> diff --git a/apps/web/src/app/(dashboard)/settings/business/_components/business-settings-form.tsx b/apps/web/src/app/(dashboard)/settings/business/_components/business-settings-form.tsx index 3df5e21..f86e8c7 100644 --- a/apps/web/src/app/(dashboard)/settings/business/_components/business-settings-form.tsx +++ b/apps/web/src/app/(dashboard)/settings/business/_components/business-settings-form.tsx @@ -60,7 +60,6 @@ export const BusinessSettingsForm = () => { targetAudience: targetAudience || undefined, tone: tone || undefined, }, - approved: true, }); setIsDirty(false); } finally { diff --git a/apps/web/src/app/api/auth/[...nextauth]/route.ts b/apps/web/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..982fb19 --- /dev/null +++ b/apps/web/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/lib/auth/nextauth-config"; + +export const { GET, POST } = handlers; diff --git a/apps/web/src/app/api/auth/jwks/route.ts b/apps/web/src/app/api/auth/jwks/route.ts new file mode 100644 index 0000000..ede5038 --- /dev/null +++ b/apps/web/src/app/api/auth/jwks/route.ts @@ -0,0 +1,3 @@ +import jwks from "@clawe/backend/dev-jwks/jwks.json"; + +export const GET = () => Response.json(jwks); diff --git a/apps/web/src/app/api/auth/token/route.ts b/apps/web/src/app/api/auth/token/route.ts new file mode 100644 index 0000000..f6d88a8 --- /dev/null +++ b/apps/web/src/app/api/auth/token/route.ts @@ -0,0 +1,11 @@ +import type { NextRequest } from "next/server"; + +export function GET(request: NextRequest) { + const cookieName = + request.nextUrl.protocol === "https:" + ? "__Secure-authjs.session-token" + : "authjs.session-token"; + const token = request.cookies.get(cookieName)?.value ?? null; + + return Response.json({ token }); +} diff --git a/apps/web/src/app/api/business/context/route.ts b/apps/web/src/app/api/business/context/route.ts index 7a45abf..57da4d3 100644 --- a/apps/web/src/app/api/business/context/route.ts +++ b/apps/web/src/app/api/business/context/route.ts @@ -55,7 +55,6 @@ export const GET = async (request: Request) => { description: context.description, favicon: context.favicon, metadata: context.metadata, - approved: context.approved, }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; @@ -103,7 +102,6 @@ export const POST = async (request: Request) => { description: body.description, favicon: body.favicon, metadata: body.metadata, - approved: body.approved, }); return NextResponse.json({ diff --git a/apps/web/src/app/auth/login/page.tsx b/apps/web/src/app/auth/login/page.tsx new file mode 100644 index 0000000..53294d1 --- /dev/null +++ b/apps/web/src/app/auth/login/page.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import Image from "next/image"; +import { useMutation } from "convex/react"; +import { api } from "@clawe/backend"; +import { Button } from "@clawe/ui/components/button"; +import { Spinner } from "@clawe/ui/components/spinner"; +import { useAuth } from "@/providers/auth-provider"; + +const AUTO_LOGIN_EMAIL = process.env.NEXT_PUBLIC_AUTO_LOGIN_EMAIL; + +export default function LoginPage() { + const router = useRouter(); + const { isAuthenticated, isLoading, signIn } = useAuth(); + const getOrCreateUser = useMutation(api.users.getOrCreateFromAuth); + const [autoLoginAttempted, setAutoLoginAttempted] = useState(false); + + // Auto-login when AUTO_LOGIN_EMAIL is set (local dev convenience) + useEffect(() => { + if (!AUTO_LOGIN_EMAIL) return; + if (isLoading || isAuthenticated || autoLoginAttempted) return; + setAutoLoginAttempted(true); + signIn(AUTO_LOGIN_EMAIL); + }, [isLoading, isAuthenticated, autoLoginAttempted, signIn]); + + // After authentication, create/fetch user and redirect + useEffect(() => { + if (!isAuthenticated) return; + + const ensureUser = async () => { + try { + await getOrCreateUser(); + } catch { + // User creation may fail if Convex auth isn't ready yet — that's ok, + // the onboarding guard will handle it on the next page load. + } + router.replace("/"); + }; + + ensureUser(); + }, [isAuthenticated, getOrCreateUser, router]); + + return ( +
+ {/* Left side - Login content */} +
+ {/* Logo */} +
+ + Clawe + +
+ + {/* Centered content */} +
+
+ {isLoading || isAuthenticated ? ( +
+ +

+ {isAuthenticated ? "Signing you in..." : "Loading..."} +

+
+ ) : ( + <> +

+ Welcome to Clawe +

+ + + + )} +
+
+
+ + {/* Right side - Illustration */} +
+
+ Clawe illustration +
+
+
+ ); +} + +const GoogleIcon = () => ( + + + + + + +); diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 6b4764a..c8ddeb9 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -3,6 +3,7 @@ import localFont from "next/font/local"; import { Montserrat, Space_Grotesk } from "next/font/google"; import "@clawe/ui/globals.css"; import "./globals.css"; +import { AuthProvider } from "@/providers/auth-provider"; import { ConvexClientProvider } from "@/providers/convex-provider"; import { QueryProvider } from "@/providers/query-provider"; import { ThemeProvider } from "@/providers/theme-provider"; @@ -41,17 +42,19 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} ${montserrat.variable} ${spaceGrotesk.variable}`} > - - - {children} - - - + + + + {children} + + + + diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index da57bdd..252f9ed 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,24 +1,39 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; -import { useQuery } from "convex/react"; +import { useMutation, useQuery } from "convex/react"; import { api } from "@clawe/backend"; +import { useAuth } from "@/providers/auth-provider"; export default function Home() { const router = useRouter(); - const isOnboardingComplete = useQuery(api.tenants.isOnboardingComplete, {}); + const { isAuthenticated } = useAuth(); + const getOrCreateUser = useMutation(api.users.getOrCreateFromAuth); + const [userReady, setUserReady] = useState(false); + // Ensure user record exists before querying tenant data useEffect(() => { - // Wait for query to load - if (isOnboardingComplete === undefined) return; + if (!isAuthenticated || userReady) return; + getOrCreateUser() + .then(() => setUserReady(true)) + .catch(() => setUserReady(true)); + }, [isAuthenticated, userReady, getOrCreateUser]); + + const isOnboardingComplete = useQuery( + api.tenants.isOnboardingComplete, + isAuthenticated && userReady ? {} : "skip", + ); + + useEffect(() => { + if (!userReady || isOnboardingComplete === undefined) return; if (isOnboardingComplete) { router.replace("/board"); } else { router.replace("/setup"); } - }, [isOnboardingComplete, router]); + }, [isOnboardingComplete, userReady, router]); return (
diff --git a/apps/web/src/app/setup/_components/setup-user-menu.tsx b/apps/web/src/app/setup/_components/setup-user-menu.tsx index d5db322..1f86d96 100644 --- a/apps/web/src/app/setup/_components/setup-user-menu.tsx +++ b/apps/web/src/app/setup/_components/setup-user-menu.tsx @@ -8,7 +8,7 @@ import { import { useUserMenu } from "@/hooks/use-user-menu"; export const SetupUserMenu = () => { - const { guestMode, user, displayName, initials } = useUserMenu(); + const { guestMode, user, displayName, initials, signOut } = useUserMenu(); return ( @@ -30,6 +30,7 @@ export const SetupUserMenu = () => { align="end" sideOffset={8} className="w-64" + onSignOut={signOut} /> ); diff --git a/apps/web/src/app/setup/business/page.tsx b/apps/web/src/app/setup/business/page.tsx index 9f129ba..a774ebb 100644 --- a/apps/web/src/app/setup/business/page.tsx +++ b/apps/web/src/app/setup/business/page.tsx @@ -18,7 +18,7 @@ export default function BusinessPage() { // Real-time subscription - auto-updates when CLI saves const businessContext = useQuery(api.businessContext.get, {}); - const canContinue = businessContext?.approved === true; + const canContinue = businessContext !== null && businessContext !== undefined; return (
diff --git a/apps/web/src/app/setup/complete/page.tsx b/apps/web/src/app/setup/complete/page.tsx index fd2a068..a27781d 100644 --- a/apps/web/src/app/setup/complete/page.tsx +++ b/apps/web/src/app/setup/complete/page.tsx @@ -20,7 +20,7 @@ export default function CompletePage() { const handleFinish = async () => { setIsCompleting(true); try { - await completeOnboarding(); + await completeOnboarding({}); router.push("/board"); } catch (error) { console.error("Failed to complete onboarding:", error); diff --git a/apps/web/src/components/user-menu/user-menu-content.tsx b/apps/web/src/components/user-menu/user-menu-content.tsx index b92f127..ba7e993 100644 --- a/apps/web/src/components/user-menu/user-menu-content.tsx +++ b/apps/web/src/components/user-menu/user-menu-content.tsx @@ -25,6 +25,7 @@ export interface UserMenuContentProps { side?: "top" | "bottom" | "left" | "right"; sideOffset?: number; className?: string; + onSignOut?: () => void; } export const UserMenuContent = ({ @@ -36,6 +37,7 @@ export const UserMenuContent = ({ side, sideOffset = 4, className, + onSignOut, }: UserMenuContentProps) => { const { theme, setTheme } = useTheme(); @@ -108,7 +110,7 @@ export const UserMenuContent = ({ - + Log out diff --git a/apps/web/src/hooks/use-onboarding-guard.ts b/apps/web/src/hooks/use-onboarding-guard.ts index 0da57df..e53624d 100644 --- a/apps/web/src/hooks/use-onboarding-guard.ts +++ b/apps/web/src/hooks/use-onboarding-guard.ts @@ -4,37 +4,62 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; import { useQuery } from "convex/react"; import { api } from "@clawe/backend"; +import { useAuth } from "@/providers/auth-provider"; /** * Redirects to /setup if onboarding is not complete. + * Redirects to /auth/login if not authenticated. * Use in dashboard/protected routes. */ export const useRequireOnboarding = () => { const router = useRouter(); - const isComplete = useQuery(api.tenants.isOnboardingComplete, {}); + const { isAuthenticated, isLoading: authLoading } = useAuth(); + const isComplete = useQuery( + api.tenants.isOnboardingComplete, + isAuthenticated ? {} : "skip", + ); useEffect(() => { + if (!authLoading && !isAuthenticated) { + router.replace("/auth/login"); + return; + } if (isComplete === false) { router.replace("/setup"); } - }, [isComplete, router]); + }, [isComplete, isAuthenticated, authLoading, router]); - return { isLoading: isComplete === undefined, isComplete }; + return { + isLoading: isComplete === undefined || authLoading, + isComplete, + }; }; /** * Redirects to /board if onboarding is already complete. + * Redirects to /auth/login if not authenticated. * Use in setup routes. */ export const useRedirectIfOnboarded = () => { const router = useRouter(); - const isComplete = useQuery(api.tenants.isOnboardingComplete, {}); + const { isAuthenticated, isLoading: authLoading } = useAuth(); + const isComplete = useQuery( + api.tenants.isOnboardingComplete, + isAuthenticated ? {} : "skip", + ); useEffect(() => { + if (!authLoading && !isAuthenticated) { + router.replace("/auth/login"); + return; + } if (isComplete === true) { router.replace("/board"); } - }, [isComplete, router]); + }, [isComplete, isAuthenticated, authLoading, router]); - return { isLoading: isComplete === undefined, isComplete }; + return { + isLoading: isComplete === undefined || authLoading, + isComplete, + }; }; diff --git a/apps/web/src/hooks/use-user-menu.ts b/apps/web/src/hooks/use-user-menu.ts index d98d1ff..42737d4 100644 --- a/apps/web/src/hooks/use-user-menu.ts +++ b/apps/web/src/hooks/use-user-menu.ts @@ -1,22 +1,21 @@ -// TODO: Replace with actual auth check when authentication is implemented -const GUEST_MODE = true; +"use client"; -// Mock user for authenticated mode -const mockUser = { - name: "User", - email: "user@example.com", -}; +import { useAuth } from "@/providers/auth-provider"; export const useUserMenu = () => { - const guestMode = GUEST_MODE; - const user = mockUser; + const { isAuthenticated, user: authUser, signOut } = useAuth(); + + const user = authUser + ? { name: authUser.name ?? authUser.email, email: authUser.email } + : { name: "User", email: "" }; const displayName = user.name; - const initials = user.name.slice(0, 2).toUpperCase(); + const initials = displayName.slice(0, 2).toUpperCase(); return { - guestMode, + guestMode: !isAuthenticated, user, displayName, initials, + signOut: isAuthenticated ? signOut : undefined, }; }; diff --git a/apps/web/src/lib/auth/nextauth-config.ts b/apps/web/src/lib/auth/nextauth-config.ts new file mode 100644 index 0000000..6f17fce --- /dev/null +++ b/apps/web/src/lib/auth/nextauth-config.ts @@ -0,0 +1,107 @@ +import NextAuth from "next-auth"; +import type { NextAuthResult } from "next-auth"; +import type { Provider } from "next-auth/providers"; +import Credentials from "next-auth/providers/credentials"; +import Google from "next-auth/providers/google"; +import { importPKCS8, importSPKI, SignJWT, jwtVerify } from "jose"; +import fs from "node:fs"; +import path from "node:path"; + +const DEV_JWKS_DIR = path.resolve( + process.cwd(), + "../../packages/backend/convex/dev-jwks", +); +const privatePem = fs.readFileSync( + path.join(DEV_JWKS_DIR, "private.pem"), + "utf8", +); +const publicPem = fs.readFileSync( + path.join(DEV_JWKS_DIR, "public.pem"), + "utf8", +); + +// Cache parsed keys to avoid re-importing on every encode/decode +let privateKeyCache: Awaited> | undefined; +let publicKeyCache: Awaited> | undefined; + +const getPrivateKey = async () => { + privateKeyCache ??= await importPKCS8(privatePem, "RS256"); + return privateKeyCache; +}; +const getPublicKey = async () => { + publicKeyCache ??= await importSPKI(publicPem, "RS256"); + return publicKeyCache; +}; + +const ISSUER = process.env.NEXTAUTH_URL ?? "http://localhost:3000"; +const AUDIENCE = "convex"; + +const providers: Provider[] = [Google]; + +if (process.env.AUTO_LOGIN_EMAIL) { + providers.push( + Credentials({ + credentials: { + email: { label: "Email", type: "email" }, + }, + authorize: async (credentials) => { + const email = credentials.email as string; + if (!email) return null; + return { id: email, email, name: email.split("@")[0] }; + }, + }), + ); +} + +const nextAuth = NextAuth({ + providers, + session: { strategy: "jwt" }, + jwt: { + async encode({ token }) { + if (!token) return ""; + const privateKey = await getPrivateKey(); + return new SignJWT({ + sub: token.email as string, + email: token.email as string, + name: token.name as string, + }) + .setProtectedHeader({ alg: "RS256", kid: "clawe-dev-key" }) + .setIssuer(ISSUER) + .setAudience(AUDIENCE) + .setIssuedAt() + .setExpirationTime("30d") + .sign(privateKey); + }, + async decode({ token }) { + if (!token) return null; + const publicKey = await getPublicKey(); + const { payload } = await jwtVerify(token, publicKey, { + issuer: ISSUER, + audience: AUDIENCE, + }); + return payload; + }, + }, + callbacks: { + jwt({ token, user }) { + if (user) { + token.email = user.email; + token.name = user.name; + } + return token; + }, + session({ session, token }) { + if (token.email) session.user.email = token.email as string; + if (token.name) session.user.name = token.name as string; + return session; + }, + }, + pages: { + signIn: "/auth/login", + }, +}); + +export const handlers: NextAuthResult["handlers"] = nextAuth.handlers; +export const signIn: NextAuthResult["signIn"] = nextAuth.signIn; +export const signOut: NextAuthResult["signOut"] = nextAuth.signOut; +export const auth: NextAuthResult["auth"] = nextAuth.auth; diff --git a/apps/web/src/providers/auth-provider.tsx b/apps/web/src/providers/auth-provider.tsx new file mode 100644 index 0000000..e5eb227 --- /dev/null +++ b/apps/web/src/providers/auth-provider.tsx @@ -0,0 +1,252 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import type { ReactNode } from "react"; + +const AUTH_PROVIDER = process.env.NEXT_PUBLIC_AUTH_PROVIDER ?? "nextauth"; + +interface AuthUser { + email: string; + name?: string; +} + +interface AuthContextValue { + isAuthenticated: boolean; + isLoading: boolean; + user: AuthUser | null; + signIn: (email?: string) => Promise; + signOut: () => Promise; +} + +const AuthContext = createContext(null); + +// --------------------------------------------------------------------------- +// NextAuth provider (local / self-hosted) +// --------------------------------------------------------------------------- + +const NextAuthProvider = ({ children }: { children: ReactNode }) => { + const [isLoading, setIsLoading] = useState(true); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [user, setUser] = useState(null); + + // Check session on mount + useEffect(() => { + const checkSession = async () => { + try { + const res = await fetch("/api/auth/session"); + if (!res.ok) { + setIsLoading(false); + return; + } + const session = await res.json(); + if (session?.user?.email) { + setUser({ + email: session.user.email, + name: session.user.name ?? undefined, + }); + setIsAuthenticated(true); + } + } catch { + // Session check failed — not authenticated + } finally { + setIsLoading(false); + } + }; + checkSession(); + }, []); + + const signIn = useCallback(async (email?: string) => { + const { signIn: nextAuthSignIn } = await import("next-auth/react"); + if (email) { + // Credentials auto-login (for local dev with AUTO_LOGIN_EMAIL) + const result = await nextAuthSignIn("credentials", { + redirect: false, + email, + }); + if (result?.ok) { + const res = await fetch("/api/auth/session"); + if (res.ok) { + const session = await res.json(); + if (session?.user?.email) { + setUser({ + email: session.user.email, + name: session.user.name ?? undefined, + }); + setIsAuthenticated(true); + } + } + } + } else { + // Google OAuth (redirect-based flow) + await nextAuthSignIn("google"); + } + }, []); + + const signOut = useCallback(async () => { + const { signOut: nextAuthSignOut } = await import("next-auth/react"); + await nextAuthSignOut({ redirect: false }); + setUser(null); + setIsAuthenticated(false); + }, []); + + const value = useMemo( + () => ({ isAuthenticated, isLoading, user, signIn, signOut }), + [isAuthenticated, isLoading, user, signIn, signOut], + ); + + return {children}; +}; + +// --------------------------------------------------------------------------- +// Cognito provider (cloud) +// --------------------------------------------------------------------------- + +const CognitoProvider = ({ children }: { children: ReactNode }) => { + const [isLoading, setIsLoading] = useState(true); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [user, setUser] = useState(null); + + const checkAuthState = useCallback(async () => { + try { + const { getCurrentUser, fetchUserAttributes } = + await import("aws-amplify/auth"); + await getCurrentUser(); + const attributes = await fetchUserAttributes(); + setUser({ + email: attributes.email ?? "", + name: attributes.name, + }); + setIsAuthenticated(true); + } catch { + setUser(null); + setIsAuthenticated(false); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + const configure = async () => { + const { Amplify } = await import("aws-amplify"); + Amplify.configure({ + Auth: { + Cognito: { + userPoolId: process.env.NEXT_PUBLIC_COGNITO_USER_POOL_ID!, + userPoolClientId: process.env.NEXT_PUBLIC_COGNITO_CLIENT_ID!, + loginWith: { + oauth: { + domain: process.env.NEXT_PUBLIC_COGNITO_DOMAIN!, + scopes: ["openid", "email", "profile"], + redirectSignIn: [window.location.origin], + redirectSignOut: [window.location.origin], + responseType: "code", + }, + }, + }, + }, + }); + + const { Hub } = await import("aws-amplify/utils"); + Hub.listen("auth", ({ payload }) => { + switch (payload.event) { + case "signedIn": + case "signInWithRedirect": + checkAuthState(); + break; + case "signedOut": + setUser(null); + setIsAuthenticated(false); + setIsLoading(false); + break; + } + }); + + checkAuthState(); + }; + + configure(); + }, [checkAuthState]); + + const signIn = useCallback(async () => { + const { signInWithRedirect } = await import("aws-amplify/auth"); + await signInWithRedirect({ provider: "Google" }); + }, []); + + const signOut = useCallback(async () => { + const { signOut: amplifySignOut } = await import("aws-amplify/auth"); + await amplifySignOut(); + setUser(null); + setIsAuthenticated(false); + }, []); + + const value = useMemo( + () => ({ isAuthenticated, isLoading, user, signIn, signOut }), + [isAuthenticated, isLoading, user, signIn, signOut], + ); + + return {children}; +}; + +// --------------------------------------------------------------------------- +// Exported provider — selects based on AUTH_PROVIDER env var +// --------------------------------------------------------------------------- + +export const AuthProvider = ({ children }: { children: ReactNode }) => { + if (AUTH_PROVIDER === "cognito") { + return {children}; + } + return {children}; +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +}; + +/** + * Hook for ConvexProviderWithAuth. + * Returns { isLoading, isAuthenticated, fetchAccessToken }. + */ +export const useConvexAuth = () => { + const { isLoading, isAuthenticated } = useAuth(); + + const fetchAccessToken = useCallback( + async ({ + forceRefreshToken, + }: { + forceRefreshToken: boolean; + }): Promise => { + if (!isAuthenticated) return null; + + if (AUTH_PROVIDER === "cognito") { + const { fetchAuthSession } = await import("aws-amplify/auth"); + const session = await fetchAuthSession({ + forceRefresh: forceRefreshToken, + }); + return session.tokens?.idToken?.toString() ?? null; + } + + // NextAuth: fetch the JWT via server endpoint (cookie is HttpOnly). + const res = await fetch("/api/auth/token"); + if (!res.ok) return null; + const data = await res.json(); + return data.token ?? null; + }, + [isAuthenticated], + ); + + return useMemo( + () => ({ isLoading, isAuthenticated, fetchAccessToken }), + [isLoading, isAuthenticated, fetchAccessToken], + ); +}; diff --git a/apps/web/src/providers/convex-provider.tsx b/apps/web/src/providers/convex-provider.tsx index 517abe9..cbd50ae 100644 --- a/apps/web/src/providers/convex-provider.tsx +++ b/apps/web/src/providers/convex-provider.tsx @@ -1,13 +1,16 @@ "use client"; -import { ConvexProvider, ConvexReactClient } from "convex/react"; +import { ConvexProviderWithAuth, ConvexReactClient } from "convex/react"; import type { ReactNode } from "react"; +import { useConvexAuth } from "@/providers/auth-provider"; // Fallback URL for build time - won't be called during static generation const convex = new ConvexReactClient( process.env.NEXT_PUBLIC_CONVEX_URL || "http://localhost:0", ); -export const ConvexClientProvider = ({ children }: { children: ReactNode }) => { - return {children}; -}; +export const ConvexClientProvider = ({ children }: { children: ReactNode }) => ( + + {children} + +); diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts new file mode 100644 index 0000000..4aa105a --- /dev/null +++ b/apps/web/src/proxy.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +const PUBLIC_PATHS = ["/auth/login", "/api/auth", "/api/health"]; + +export function proxy(request: NextRequest) { + const { pathname } = request.nextUrl; + + // Allow public paths + if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) { + return NextResponse.next(); + } + + // Check for NextAuth session cookie + const sessionCookie = + request.cookies.get("authjs.session-token") ?? + request.cookies.get("__Secure-authjs.session-token"); + + if (!sessionCookie) { + return NextResponse.redirect(new URL("/auth/login", request.url)); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], +}; diff --git a/package.json b/package.json index aafb354..412eba4 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "debug": "dotenv -e .env -- turbo run debug", "dev:web": "dotenv -e .env -- turbo run dev --filter=web", "convex:dev": "dotenv -e .env -- turbo run dev --filter=@clawe/backend", - "convex:deploy": "dotenv -e .env -- turbo run deploy --filter=@clawe/backend", + "convex:deploy": "dotenv -e .env -- ./scripts/convex-deploy.sh", "lint": "turbo run lint", "lint:fix": "turbo run lint:fix", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,md,css,json}\"", diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index 920685c..9b9ae44 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -8,17 +8,20 @@ * @module */ +import type * as accounts from "../accounts.js"; import type * as activities from "../activities.js"; import type * as agents from "../agents.js"; import type * as businessContext from "../businessContext.js"; import type * as channels from "../channels.js"; import type * as documents from "../documents.js"; +import type * as lib_auth from "../lib/auth.js"; import type * as messages from "../messages.js"; import type * as notifications from "../notifications.js"; import type * as routines from "../routines.js"; -import type * as settings from "../settings.js"; import type * as tasks from "../tasks.js"; +import type * as tenants from "../tenants.js"; import type * as types from "../types.js"; +import type * as users from "../users.js"; import type { ApiFromModules, @@ -27,17 +30,20 @@ import type { } from "convex/server"; declare const fullApi: ApiFromModules<{ + accounts: typeof accounts; activities: typeof activities; agents: typeof agents; businessContext: typeof businessContext; channels: typeof channels; documents: typeof documents; + "lib/auth": typeof lib_auth; messages: typeof messages; notifications: typeof notifications; routines: typeof routines; - settings: typeof settings; tasks: typeof tasks; + tenants: typeof tenants; types: typeof types; + users: typeof users; }>; /** diff --git a/packages/backend/convex/activities.ts b/packages/backend/convex/activities.ts index c00fb8e..8e8c320 100644 --- a/packages/backend/convex/activities.ts +++ b/packages/backend/convex/activities.ts @@ -1,6 +1,6 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; -import { resolveTenantId, resolveTenantIdMut } from "./lib/auth"; +import { resolveTenantId } from "./lib/auth"; // Get activity feed (most recent first) export const feed = query({ @@ -115,7 +115,7 @@ export const log = mutation({ machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const { machineToken: _, ...fields } = args; return await ctx.db.insert("activities", { @@ -150,7 +150,7 @@ export const logBySession = mutation({ machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const { machineToken: _, ...fields } = args; let agentId = undefined; diff --git a/packages/backend/convex/agents.ts b/packages/backend/convex/agents.ts index fa62b97..e74a608 100644 --- a/packages/backend/convex/agents.ts +++ b/packages/backend/convex/agents.ts @@ -1,6 +1,6 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; -import { resolveTenantId, resolveTenantIdMut } from "./lib/auth"; +import { resolveTenantId } from "./lib/auth"; const agentStatusValidator = v.union(v.literal("online"), v.literal("offline")); @@ -93,7 +93,7 @@ export const upsert = mutation({ machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const { machineToken: _, ...rest } = args; const now = Date.now(); @@ -139,7 +139,7 @@ export const create = mutation({ machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const { machineToken: _, ...rest } = args; const now = Date.now(); return await ctx.db.insert("agents", { @@ -164,7 +164,7 @@ export const updateStatus = mutation({ machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { - await resolveTenantIdMut(ctx, args); + await resolveTenantId(ctx, args); await ctx.db.patch(args.id, { status: args.status, updatedAt: Date.now(), @@ -176,7 +176,7 @@ export const updateStatus = mutation({ export const heartbeat = mutation({ args: { sessionKey: v.string(), machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const now = Date.now(); const agents = await ctx.db @@ -222,7 +222,7 @@ export const setCurrentTask = mutation({ machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const agents = await ctx.db .query("agents") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) @@ -248,7 +248,7 @@ export const setActivity = mutation({ machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const agents = await ctx.db .query("agents") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) @@ -278,7 +278,7 @@ export const update = mutation({ machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { - await resolveTenantIdMut(ctx, args); + await resolveTenantId(ctx, args); const { id, machineToken: _, ...updates } = args; const filteredUpdates = Object.fromEntries( Object.entries(updates).filter(([, value]) => value !== undefined), @@ -294,7 +294,7 @@ export const update = mutation({ export const remove = mutation({ args: { id: v.id("agents"), machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { - await resolveTenantIdMut(ctx, args); + await resolveTenantId(ctx, args); await ctx.db.delete(args.id); }, }); diff --git a/packages/backend/convex/auth.config.cognito.ts b/packages/backend/convex/auth.config.cognito.ts new file mode 100644 index 0000000..948d97d --- /dev/null +++ b/packages/backend/convex/auth.config.cognito.ts @@ -0,0 +1,10 @@ +import type { AuthConfig } from "convex/server"; + +export default { + providers: [ + { + domain: process.env.COGNITO_ISSUER_URL!, + applicationID: process.env.COGNITO_CLIENT_ID!, + }, + ], +} satisfies AuthConfig; diff --git a/packages/backend/convex/auth.config.nextauth.ts b/packages/backend/convex/auth.config.nextauth.ts new file mode 100644 index 0000000..e540a4a --- /dev/null +++ b/packages/backend/convex/auth.config.nextauth.ts @@ -0,0 +1,13 @@ +import type { AuthConfig } from "convex/server"; + +export default { + providers: [ + { + type: "customJwt", + issuer: process.env.NEXTAUTH_ISSUER_URL!, + jwks: process.env.NEXTAUTH_JWKS_URL!, + applicationID: "convex", + algorithm: "RS256", + }, + ], +} satisfies AuthConfig; diff --git a/packages/backend/convex/auth.config.ts b/packages/backend/convex/auth.config.ts new file mode 100644 index 0000000..4ac879e --- /dev/null +++ b/packages/backend/convex/auth.config.ts @@ -0,0 +1,16 @@ +// Default: NextAuth (local dev / self-hosted). +// Cloud deployments: scripts/convex-deploy.sh overwrites this with auth.config.cognito.ts. + +import type { AuthConfig } from "convex/server"; + +export default { + providers: [ + { + type: "customJwt", + issuer: process.env.NEXTAUTH_ISSUER_URL!, + jwks: process.env.NEXTAUTH_JWKS_URL!, + applicationID: "convex", + algorithm: "RS256", + }, + ], +} satisfies AuthConfig; diff --git a/packages/backend/convex/businessContext.ts b/packages/backend/convex/businessContext.ts index 9189873..a85c2a8 100644 --- a/packages/backend/convex/businessContext.ts +++ b/packages/backend/convex/businessContext.ts @@ -1,6 +1,6 @@ import { query, mutation } from "./_generated/server"; import { v } from "convex/values"; -import { resolveTenantId, resolveTenantIdMut } from "./lib/auth"; +import { resolveTenantId } from "./lib/auth"; /** * Get the current business context. @@ -18,7 +18,8 @@ export const get = query({ }); /** - * Check if business context is configured and approved. + * Check if business context is configured. + * Returns true if a businessContext record exists for the tenant. */ export const isConfigured = query({ args: { machineToken: v.optional(v.string()) }, @@ -28,7 +29,7 @@ export const isConfigured = query({ .query("businessContext") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .first(); - return context?.approved === true; + return context !== null; }, }); @@ -53,10 +54,9 @@ export const save = mutation({ tone: v.optional(v.string()), }), ), - approved: v.optional(v.boolean()), }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const { machineToken: _, ...rest } = args; const now = Date.now(); const existing = await ctx.db @@ -70,8 +70,6 @@ export const save = mutation({ description: rest.description, favicon: rest.favicon, metadata: rest.metadata, - approved: rest.approved ?? false, - approvedAt: rest.approved ? now : undefined, updatedAt: now, }; @@ -88,32 +86,6 @@ export const save = mutation({ }, }); -/** - * Mark the current business context as approved. - */ -export const approve = mutation({ - args: { machineToken: v.optional(v.string()) }, - handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); - const existing = await ctx.db - .query("businessContext") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .first(); - - if (!existing) { - throw new Error("No business context to approve"); - } - - await ctx.db.patch(existing._id, { - approved: true, - approvedAt: Date.now(), - updatedAt: Date.now(), - }); - - return existing._id; - }, -}); - /** * Clear the business context. * Used for resetting onboarding. @@ -121,7 +93,7 @@ export const approve = mutation({ export const clear = mutation({ args: { machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const existing = await ctx.db .query("businessContext") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) diff --git a/packages/backend/convex/channels.ts b/packages/backend/convex/channels.ts index 8416008..054d1a4 100644 --- a/packages/backend/convex/channels.ts +++ b/packages/backend/convex/channels.ts @@ -1,6 +1,6 @@ import { query, mutation } from "./_generated/server"; import { v } from "convex/values"; -import { resolveTenantId, resolveTenantIdMut } from "./lib/auth"; +import { resolveTenantId } from "./lib/auth"; export const list = query({ args: { machineToken: v.optional(v.string()) }, @@ -38,7 +38,7 @@ export const upsert = mutation({ metadata: v.optional(v.any()), }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const { machineToken: _, ...rest } = args; const existing = await ctx.db @@ -74,7 +74,7 @@ export const disconnect = mutation({ type: v.string(), }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const existing = await ctx.db .query("channels") diff --git a/packages/backend/convex/dev-jwks/jwks.json b/packages/backend/convex/dev-jwks/jwks.json new file mode 100644 index 0000000..bc962b7 --- /dev/null +++ b/packages/backend/convex/dev-jwks/jwks.json @@ -0,0 +1,12 @@ +{ + "keys": [ + { + "kty": "RSA", + "n": "vZCt9CZYsmRKdjm8K1jhkn_iOHTweYcA1yls5ZtgB_cCgkAleuVP3OWBmSrs4sf748LZ_OUxcmh9FKKheH8P1fODrzf6HZuHWCU-byiERyRVwDpm8B6iF7DDf0ZuBFnaRJD5CKfAerqNDf1lqrq7cvTuLjGItgVGZrOB_cCMViVkIH7OMvmjgkjBIS9uLU8FS6AtoIg3nyawoDYHiLedBRWQxEbcUZicfK3YeGxMqyglhfArmduI2yCf_JtiteyE0qNla_pGWyyTkNA-PjqYBanKCAMhWL3Xbx8Gnm7UP3R2HfODPBe-H3ShgzZmkPC7VBv12_lngD5CCKqL7wz_ww", + "e": "AQAB", + "kid": "clawe-dev-key", + "alg": "RS256", + "use": "sig" + } + ] +} diff --git a/packages/backend/convex/dev-jwks/private.pem b/packages/backend/convex/dev-jwks/private.pem new file mode 100644 index 0000000..c6eb9c3 --- /dev/null +++ b/packages/backend/convex/dev-jwks/private.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC9kK30JliyZEp2 +ObwrWOGSf+I4dPB5hwDXKWzlm2AH9wKCQCV65U/c5YGZKuzix/vjwtn85TFyaH0U +oqF4fw/V84OvN/odm4dYJT5vKIRHJFXAOmbwHqIXsMN/Rm4EWdpEkPkIp8B6uo0N +/WWqurty9O4uMYi2BUZms4H9wIxWJWQgfs4y+aOCSMEhL24tTwVLoC2giDefJrCg +NgeIt50FFZDERtxRmJx8rdh4bEyrKCWF8CuZ24jbIJ/8m2K17ITSo2Vr+kZbLJOQ +0D4+OpgFqcoIAyFYvddvHwaebtQ/dHYd84M8F74fdKGDNmaQ8LtUG/Xb+WeAPkII +qovvDP/DAgMBAAECggEAQJSknrPdrdC7CXH76CyclKNat28nac+TernTLpnzamM9 +iJA/9JFg1tmdgEf+cfg9mUeNqjmO0fJFAp2xMvLeuz3909jXLfUJc/8kOQxtnCsF +x7pdzVoyUK3YvGiLHJJb6NYW8VrtGSKq4WQ9mZ+KMsy8xCH9+Dzt0hk/pOpPJR17 +gVAOL7wIXjhFwiApvueWh4Od/dBf0YFkQ/HvO1f1dpPhuHN2bLWkYl+tTKffCxtp +wyCT6Xo3sT7Ebwf/Vc8s00e3GN1DIglK44l44dInhiedYuPZANq0yNDaAwT/NQdb +4Ahnodl6Vs2RBcpemJ2dAyrnFc721rKcfb6c6hVv8QKBgQDrpWeJHbCAvxfy9JKS +O2iHaEFS7ywfVFECUMGbOaAE/M54ampm92UZ7+WEJ26sRyLM1JkHPSuL2mncAuDZ +zGxFdLoPme+OqCx1E/pIoXd4qpzQ4+ZMSxC09iTnYSahbnJLglC2+b2dQJrsDWCN +0SXYZnhEloTi+oWAihIQidfjKQKBgQDN8FXMosjI73b6Jy3EZKLcVPkuFGMJN0tY +0/PvClcYLlRHDEfQvtBltoGiVph6O3jbFrIFRvsfZTI2JOeIz6ag0nTfEpRaz2tt +/pGYPepgvgvGSKnRkjnTa5+WxfS2YFZxMwGLdlZdi4dOHRt/cPBZlI/AE7/u5U7d +oLqBX2D1CwKBgQDrcO/ph8h6WnPLQ6HOiZz+7aOXAXDMPKpT7ewC86h2U0DX/zsg +db6GE7L2P4/Mgaa7kQ70tKF1slxifl26Pw1OuDnOrLc1icIhmDxRpUKBRbY43/uR +7s5agDSPGfpHANshpqqOpyhUneAsSZFXIMj3ViqEHP/Y6QXKUCmMbK1PQQKBgFuO +DZb8h+dNDsgHwwEc/IqX/G/QAHeIbacAE+Kh5jaJ4k3z17mmG2Ac02UouoEdD43X +eS1/cQV0J+6KWaUpLBszdWH3EJ2OuWQdWP0mCZ0Y4IM2qsjRCYRExJ5zQ2gRTFzn +IDiwU5UjAvRnXGI8A57PvVjXbuz2ZSmC22fIz4IhAoGAQT7NNketpl3xaCwYVScm +5tC7N+u6FXbPa1rKJye/McjrGugMIpUvCKWTAj5y4eyVhlSH4znaQVqfmxOaTXY0 +Ozvd0K0ObvE1pK0X7fFIv+XHOxx5jnRVkeZNcQR4m/v7vYbZXpgxiZWQHcudT3Pq +ldvYttkNkaMLGPZ44Ec6m6k= +-----END PRIVATE KEY----- diff --git a/packages/backend/convex/dev-jwks/public.pem b/packages/backend/convex/dev-jwks/public.pem new file mode 100644 index 0000000..4cd7a8d --- /dev/null +++ b/packages/backend/convex/dev-jwks/public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvZCt9CZYsmRKdjm8K1jh +kn/iOHTweYcA1yls5ZtgB/cCgkAleuVP3OWBmSrs4sf748LZ/OUxcmh9FKKheH8P +1fODrzf6HZuHWCU+byiERyRVwDpm8B6iF7DDf0ZuBFnaRJD5CKfAerqNDf1lqrq7 +cvTuLjGItgVGZrOB/cCMViVkIH7OMvmjgkjBIS9uLU8FS6AtoIg3nyawoDYHiLed +BRWQxEbcUZicfK3YeGxMqyglhfArmduI2yCf/JtiteyE0qNla/pGWyyTkNA+PjqY +BanKCAMhWL3Xbx8Gnm7UP3R2HfODPBe+H3ShgzZmkPC7VBv12/lngD5CCKqL7wz/ +wwIDAQAB +-----END PUBLIC KEY----- diff --git a/packages/backend/convex/documents.ts b/packages/backend/convex/documents.ts index d74a434..78fbeda 100644 --- a/packages/backend/convex/documents.ts +++ b/packages/backend/convex/documents.ts @@ -1,6 +1,6 @@ import { v } from "convex/values"; import { action, mutation, query } from "./_generated/server"; -import { resolveTenantId, resolveTenantIdMut } from "./lib/auth"; +import { resolveTenantId } from "./lib/auth"; // Generate upload URL for file storage export const generateUploadUrl = action({ @@ -100,7 +100,7 @@ export const create = mutation({ machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const { machineToken: _, ...rest } = args; const now = Date.now(); @@ -153,7 +153,7 @@ export const registerDeliverable = mutation({ machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const { machineToken: _, ...rest } = args; const now = Date.now(); @@ -204,7 +204,7 @@ export const update = mutation({ machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { - await resolveTenantIdMut(ctx, args); + await resolveTenantId(ctx, args); const { id, machineToken: _, ...updates } = args; const filteredUpdates = Object.fromEntries( Object.entries(updates).filter(([, value]) => value !== undefined), @@ -221,7 +221,7 @@ export const update = mutation({ export const remove = mutation({ args: { id: v.id("documents"), machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { - await resolveTenantIdMut(ctx, args); + await resolveTenantId(ctx, args); await ctx.db.delete(args.id); }, }); diff --git a/packages/backend/convex/lib/auth.ts b/packages/backend/convex/lib/auth.ts index 770d26b..4556e83 100644 --- a/packages/backend/convex/lib/auth.ts +++ b/packages/backend/convex/lib/auth.ts @@ -1,10 +1,8 @@ import type { Id } from "../_generated/dataModel"; -import type { QueryCtx, MutationCtx } from "../_generated/server"; +import type { QueryCtx } from "../_generated/server"; type ReadCtx = { db: QueryCtx["db"]; auth: QueryCtx["auth"] }; -const DEV_TENANT_EMAIL = "dev@clawe.local"; - /** * Browser path: get the current user from JWT identity. * Looks up the `users` table by the email from the auth identity. @@ -34,7 +32,6 @@ export async function getUser(ctx: ReadCtx) { /** * Resolve tenantId from user via accountMembers → accounts → tenants. - * Used by both JWT and dev-mode paths. */ async function getTenantIdFromUser( ctx: ReadCtx, @@ -111,68 +108,14 @@ export function validateWatcherToken( } } -/** - * Dev-only: get or create a default dev tenant (used when AUTH_ENABLED=false). - */ -async function getOrCreateDevTenant(ctx: MutationCtx): Promise> { - // Look for existing dev user - let user = await ctx.db - .query("users") - .withIndex("by_email", (q) => q.eq("email", DEV_TENANT_EMAIL)) - .first(); - - if (!user) { - const now = Date.now(); - const userId = await ctx.db.insert("users", { - email: DEV_TENANT_EMAIL, - name: "Dev User", - createdAt: now, - updatedAt: now, - }); - user = (await ctx.db.get(userId))!; - } - - // Look for existing tenant via accountMembers → account → tenants - const tenantId = await getTenantIdFromUser(ctx, user._id); - if (tenantId) { - return tenantId; - } - - // Create account + accountMembers + tenant - const now = Date.now(); - const accountId = await ctx.db.insert("accounts", { - name: "Dev Account", - createdAt: now, - updatedAt: now, - }); - - await ctx.db.insert("accountMembers", { - userId: user._id, - accountId, - role: "owner", - createdAt: now, - }); - - const newTenantId = await ctx.db.insert("tenants", { - accountId, - status: "active", - createdAt: now, - updatedAt: now, - }); - - return newTenantId; -} - /** * Unified tenant resolver for all tenant-scoped functions. * * Resolution order: * 1. If `machineToken` provided → look up tenant by squadhub token - * 2. If `AUTH_ENABLED=true` → resolve from JWT identity - * 3. If `AUTH_ENABLED=false` → get or create default dev tenant + * 2. Otherwise → resolve from JWT identity * - * Use this in queries (read-only ctx) when AUTH_ENABLED is true or machineToken is provided. - * Use `resolveTenantIdMut` in mutations when dev tenant auto-creation may be needed. + * Works for both queries and mutations (only needs read access). */ export async function resolveTenantId( ctx: ReadCtx, @@ -182,47 +125,5 @@ export async function resolveTenantId( return getTenantIdFromToken(ctx, args.machineToken); } - const authEnabled = process.env.AUTH_ENABLED !== "false"; - - if (authEnabled) { - return getTenantIdFromJwt(ctx); - } - - // AUTH_ENABLED=false in a read-only context — try to find existing dev tenant - const user = await ctx.db - .query("users") - .withIndex("by_email", (q) => q.eq("email", DEV_TENANT_EMAIL)) - .first(); - - if (user) { - const tenantId = await getTenantIdFromUser(ctx, user._id); - if (tenantId) { - return tenantId; - } - } - - throw new Error( - "No dev tenant found. Run a mutation first to auto-create it.", - ); -} - -/** - * Mutation variant of resolveTenantId that can auto-create a dev tenant. - * Use this in mutations instead of `resolveTenantId` when AUTH_ENABLED=false. - */ -export async function resolveTenantIdMut( - ctx: MutationCtx, - args: { machineToken?: string }, -): Promise> { - if (args.machineToken) { - return getTenantIdFromToken(ctx, args.machineToken); - } - - const authEnabled = process.env.AUTH_ENABLED !== "false"; - - if (authEnabled) { - return getTenantIdFromJwt(ctx); - } - - return getOrCreateDevTenant(ctx); + return getTenantIdFromJwt(ctx); } diff --git a/packages/backend/convex/messages.ts b/packages/backend/convex/messages.ts index 383e39d..4cef811 100644 --- a/packages/backend/convex/messages.ts +++ b/packages/backend/convex/messages.ts @@ -1,6 +1,6 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; -import { resolveTenantId, resolveTenantIdMut } from "./lib/auth"; +import { resolveTenantId } from "./lib/auth"; // List messages for a task export const listForTask = query({ @@ -132,7 +132,7 @@ export const create = mutation({ machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const { machineToken: _, ...rest } = args; const now = Date.now(); @@ -171,7 +171,7 @@ export const create = mutation({ export const remove = mutation({ args: { id: v.id("messages"), machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { - await resolveTenantIdMut(ctx, args); + await resolveTenantId(ctx, args); await ctx.db.delete(args.id); }, }); diff --git a/packages/backend/convex/notifications.ts b/packages/backend/convex/notifications.ts index 3130490..9ae3c91 100644 --- a/packages/backend/convex/notifications.ts +++ b/packages/backend/convex/notifications.ts @@ -1,6 +1,6 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; -import { resolveTenantId, resolveTenantIdMut } from "./lib/auth"; +import { resolveTenantId } from "./lib/auth"; // Get undelivered notifications for an agent (by session key) export const getUndelivered = query({ @@ -98,7 +98,7 @@ export const markDelivered = mutation({ machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { - await resolveTenantIdMut(ctx, args); + await resolveTenantId(ctx, args); const now = Date.now(); for (const id of args.notificationIds) { @@ -129,7 +129,7 @@ export const send = mutation({ machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const now = Date.now(); const targetKey = args.targetSessionKey; @@ -200,7 +200,7 @@ export const sendToMany = mutation({ machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const now = Date.now(); const notificationIds: string[] = []; @@ -247,7 +247,7 @@ export const sendToMany = mutation({ export const clearAll = mutation({ args: { sessionKey: v.string(), machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const agents = await ctx.db .query("agents") diff --git a/packages/backend/convex/routines.ts b/packages/backend/convex/routines.ts index 68f98b6..a9ca6d6 100644 --- a/packages/backend/convex/routines.ts +++ b/packages/backend/convex/routines.ts @@ -1,6 +1,6 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; -import { resolveTenantId, resolveTenantIdMut } from "./lib/auth"; +import { resolveTenantId } from "./lib/auth"; // Schedule validator (reusable) const scheduleValidator = v.object({ @@ -63,7 +63,7 @@ export const create = mutation({ color: v.string(), }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const { machineToken: _, ...rest } = args; const now = Date.now(); return await ctx.db.insert("routines", { @@ -93,7 +93,7 @@ export const update = mutation({ enabled: v.optional(v.boolean()), }, handler: async (ctx, args) => { - await resolveTenantIdMut(ctx, args); + await resolveTenantId(ctx, args); const { routineId, machineToken: _, ...updates } = args; // Filter out undefined values @@ -115,7 +115,7 @@ export const remove = mutation({ routineId: v.id("routines"), }, handler: async (ctx, args) => { - await resolveTenantIdMut(ctx, args); + await resolveTenantId(ctx, args); await ctx.db.delete(args.routineId); }, }); @@ -127,7 +127,7 @@ export const trigger = mutation({ routineId: v.id("routines"), }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const routine = await ctx.db.get(args.routineId); if (!routine) { throw new Error("Routine not found"); diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 4691edf..0d9e983 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -241,8 +241,6 @@ export default defineSchema({ tone: v.optional(v.string()), }), ), - approved: v.boolean(), - approvedAt: v.optional(v.number()), createdAt: v.number(), updatedAt: v.number(), }).index("by_tenant", ["tenantId"]), diff --git a/packages/backend/convex/tasks.ts b/packages/backend/convex/tasks.ts index ee1f0a8..6eb1640 100644 --- a/packages/backend/convex/tasks.ts +++ b/packages/backend/convex/tasks.ts @@ -1,7 +1,7 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; import type { Id } from "./_generated/dataModel"; -import { resolveTenantId, resolveTenantIdMut } from "./lib/auth"; +import { resolveTenantId } from "./lib/auth"; // List all tasks with optional filters export const list = query({ @@ -210,7 +210,7 @@ export const create = mutation({ createdBySessionKey: v.optional(v.string()), }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const now = Date.now(); // Find assignee if provided @@ -299,7 +299,7 @@ export const updateStatus = mutation({ bySessionKey: v.optional(v.string()), }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const now = Date.now(); const task = await ctx.db.get(args.taskId); if (!task) throw new Error("Task not found"); @@ -384,7 +384,7 @@ export const approve = mutation({ humanAuthor: v.optional(v.string()), }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const now = Date.now(); const task = await ctx.db.get(args.taskId); if (!task) throw new Error("Task not found"); @@ -445,7 +445,7 @@ export const requestChanges = mutation({ humanAuthor: v.optional(v.string()), }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const now = Date.now(); const task = await ctx.db.get(args.taskId); if (!task) throw new Error("Task not found"); @@ -505,7 +505,7 @@ export const assign = mutation({ bySessionKey: v.optional(v.string()), }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const now = Date.now(); const task = await ctx.db.get(args.taskId); if (!task) throw new Error("Task not found"); @@ -578,7 +578,7 @@ export const addComment = mutation({ humanAuthor: v.optional(v.string()), }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const now = Date.now(); let fromAgentId = undefined; @@ -633,7 +633,7 @@ export const addSubtask = mutation({ assigneeSessionKey: v.optional(v.string()), }, handler: async (ctx, args) => { - await resolveTenantIdMut(ctx, args); + await resolveTenantId(ctx, args); const task = await ctx.db.get(args.taskId); if (!task) throw new Error("Task not found"); @@ -689,7 +689,7 @@ export const updateSubtask = mutation({ bySessionKey: v.optional(v.string()), }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const now = Date.now(); const task = await ctx.db.get(args.taskId); if (!task) throw new Error("Task not found"); @@ -795,7 +795,7 @@ export const update = mutation({ ), }, handler: async (ctx, args) => { - await resolveTenantIdMut(ctx, args); + await resolveTenantId(ctx, args); const { machineToken, taskId, ...updates } = args; const filteredUpdates = Object.fromEntries( Object.entries(updates).filter(([, value]) => value !== undefined), @@ -824,7 +824,7 @@ export const createFromDashboard = mutation({ ), }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const now = Date.now(); // Find Clawe (main leader) to attribute the task creation @@ -883,7 +883,7 @@ export const createWithPlan = mutation({ ), }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const now = Date.now(); // Resolve creator @@ -1003,7 +1003,7 @@ export const remove = mutation({ taskId: v.id("tasks"), }, handler: async (ctx, args) => { - await resolveTenantIdMut(ctx, args); + await resolveTenantId(ctx, args); // Also delete related messages const messages = await ctx.db diff --git a/packages/backend/convex/tenants.ts b/packages/backend/convex/tenants.ts index 325276d..8c0fccb 100644 --- a/packages/backend/convex/tenants.ts +++ b/packages/backend/convex/tenants.ts @@ -1,11 +1,6 @@ import { v } from "convex/values"; import { query, mutation } from "./_generated/server"; -import { - getUser, - resolveTenantId, - resolveTenantIdMut, - validateWatcherToken, -} from "./lib/auth"; +import { getUser, resolveTenantId, validateWatcherToken } from "./lib/auth"; const DEFAULT_TIMEZONE = "America/New_York"; @@ -26,7 +21,7 @@ export const setTimezone = mutation({ timezone: v.string(), }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const tenant = await ctx.db.get(tenantId); if (!tenant) { throw new Error("Tenant not found"); @@ -42,13 +37,19 @@ export const setTimezone = mutation({ }, }); -// Check if onboarding is complete for the current tenant +// Check if onboarding is complete for the current tenant. +// Returns false for new users who don't have a tenant yet. export const isOnboardingComplete = query({ args: { machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { - const tenantId = await resolveTenantId(ctx, args); - const tenant = await ctx.db.get(tenantId); - return tenant?.settings?.onboardingComplete === true; + try { + const tenantId = await resolveTenantId(ctx, args); + const tenant = await ctx.db.get(tenantId); + return tenant?.settings?.onboardingComplete === true; + } catch { + // New user with no tenant — not onboarded + return false; + } }, }); @@ -56,7 +57,7 @@ export const isOnboardingComplete = query({ export const completeOnboarding = mutation({ args: { machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const tenant = await ctx.db.get(tenantId); if (!tenant) { throw new Error("Tenant not found"); @@ -159,7 +160,7 @@ export const setAnthropicKey = mutation({ apiKey: v.string(), }, handler: async (ctx, args) => { - const tenantId = await resolveTenantIdMut(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const tenant = await ctx.db.get(tenantId); if (!tenant) { throw new Error("Tenant not found"); diff --git a/packages/backend/convex/users.ts b/packages/backend/convex/users.ts index f72c5e9..c65bfc8 100644 --- a/packages/backend/convex/users.ts +++ b/packages/backend/convex/users.ts @@ -19,19 +19,41 @@ export const getOrCreateFromAuth = mutation({ .withIndex("by_email", (q) => q.eq("email", email)) .first(); - if (existing) { - return existing; + const now = Date.now(); + + let user = existing; + if (!user) { + const userId = await ctx.db.insert("users", { + email, + name: identity.name ?? undefined, + createdAt: now, + updatedAt: now, + }); + user = (await ctx.db.get(userId))!; } - const now = Date.now(); - const userId = await ctx.db.insert("users", { - email, - name: identity.name ?? undefined, - createdAt: now, - updatedAt: now, - }); + // Ensure account + membership exist + const membership = await ctx.db + .query("accountMembers") + .withIndex("by_user", (q) => q.eq("userId", user._id)) + .first(); + + if (!membership) { + const accountId = await ctx.db.insert("accounts", { + name: user.name ? `${user.name}'s Account` : undefined, + createdAt: now, + updatedAt: now, + }); + + await ctx.db.insert("accountMembers", { + userId: user._id, + accountId, + role: "owner", + createdAt: now, + }); + } - return (await ctx.db.get(userId))!; + return user; }, }); diff --git a/packages/backend/package.json b/packages/backend/package.json index 2811805..bd5bca6 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -19,7 +19,9 @@ "./types": { "types": "./convex/types.ts", "default": "./convex/types.ts" - } + }, + "./dev-jwks/jwks.json": "./convex/dev-jwks/jwks.json", + "./dev-jwks/private.pem": "./convex/dev-jwks/private.pem" }, "scripts": { "dev": "convex dev", diff --git a/packages/cli/src/commands/business-get.ts b/packages/cli/src/commands/business-get.ts index b93e575..6564c90 100644 --- a/packages/cli/src/commands/business-get.ts +++ b/packages/cli/src/commands/business-get.ts @@ -22,7 +22,6 @@ export async function businessGet(): Promise { description: context.description, favicon: context.favicon, metadata: context.metadata, - approved: context.approved, }, null, 2, diff --git a/packages/cli/src/commands/business-set.ts b/packages/cli/src/commands/business-set.ts index 9680f01..634ee49 100644 --- a/packages/cli/src/commands/business-set.ts +++ b/packages/cli/src/commands/business-set.ts @@ -9,7 +9,6 @@ export type BusinessSetOptions = { description?: string; favicon?: string; metadata?: string; // JSON string - approve?: boolean; removeBootstrap?: boolean; }; @@ -48,15 +47,10 @@ export async function businessSet( tone?: string; } | undefined, - approved: options.approve ?? false, }); console.log(`Business context saved (id: ${id})`); - if (options.approve) { - console.log("Business context approved."); - } - // Remove BOOTSTRAP.md if requested if (options.removeBootstrap) { const squadhubStateDir = diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 81f23dd..b70169b 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -88,7 +88,6 @@ Business Context: --description Business description --favicon Favicon URL --metadata Additional metadata as JSON - --approve Mark as approved --remove-bootstrap Remove BOOTSTRAP.md after saving Note: Only Clawe should use business:set. Other agents can read @@ -360,7 +359,7 @@ async function main(): Promise { const url = positionalArgs[0]; if (!url) { console.error( - "Usage: clawe business:set [--name ] [--description ] [--approve] [--remove-bootstrap]", + "Usage: clawe business:set [--name ] [--description ] [--remove-bootstrap]", ); process.exit(1); } @@ -369,7 +368,6 @@ async function main(): Promise { description: options.description, favicon: options.favicon, metadata: options.metadata, - approve: options.approve === "true", removeBootstrap: options["remove-bootstrap"] === "true", }); break; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ba1fdf..28cd7f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,9 @@ importers: ai: specifier: ^6.0.77 version: 6.0.77(zod@4.3.6) + aws-amplify: + specifier: ^6.16.2 + version: 6.16.2 axios: specifier: ^1.13.4 version: 1.13.4 @@ -120,12 +123,18 @@ importers: framer-motion: specifier: ^12.29.0 version: 12.29.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + jose: + specifier: ^6.1.3 + version: 6.1.3 lucide-react: specifier: ^0.562.0 version: 0.562.0(react@19.2.0) next: specifier: 16.1.0 version: 16.1.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + next-auth: + specifier: 5.0.0-beta.30 + version: 5.0.0-beta.30(next@16.1.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -462,6 +471,204 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@auth/core@0.41.0': + resolution: {integrity: sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + nodemailer: ^6.8.0 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + + '@aws-amplify/analytics@7.0.93': + resolution: {integrity: sha512-3WoB0VzATJyupTNQ+ZnzE0pLYnpZPtqNN4deZ8gadG5uzGhhvkt9uZtgVnn/QFGb35DnP8qNDTRiM0rL3vjyZQ==} + peerDependencies: + '@aws-amplify/core': ^6.1.0 + + '@aws-amplify/api-graphql@4.8.5': + resolution: {integrity: sha512-Xu45+MizoethsRfCFIdN9RORenCu0e41tMkiTFVE5oKC76eoOlYHg2LlhG2Lmmasby/Ggi5bZouVxJIcP4IeIA==} + + '@aws-amplify/api-rest@4.6.3': + resolution: {integrity: sha512-SPhttyB9SR2p5PkUPmUPfkXNqGrgvdqiNHNHhx7FjHnqFBXLDRtGhzqRbE7faDeAwrcWz1HCtcpk7MLHYt94yg==} + peerDependencies: + '@aws-amplify/core': ^6.1.0 + + '@aws-amplify/api@6.3.24': + resolution: {integrity: sha512-19CVHj+0J35aHMPNzy12nO1mJS4oP68yFUfiMnulSsiVGV5XhUDc/bkdcX0uI7U1SsUSs+9TOBwZg27bzYIGkg==} + peerDependencies: + '@aws-amplify/core': ^6.1.0 + + '@aws-amplify/auth@6.19.1': + resolution: {integrity: sha512-N6bqBUEly/xUiho0X5oGhLEDlQTWsj1i0FquDYsyuav5e9HHQBLNgG1zmpq28lyxtDaUREi/IDx+CD10EpcPcQ==} + peerDependencies: + '@aws-amplify/core': ^6.1.0 + '@aws-amplify/react-native': ^1.1.10 + peerDependenciesMeta: + '@aws-amplify/react-native': + optional: true + + '@aws-amplify/core@6.16.1': + resolution: {integrity: sha512-WHO6yYegmnZ+K3vnYzVwy+wnxYqSkdFakBIlgm4922QXHOQYWdIl/rrTcaagrpJEGT6YlTnqx1ANIoPojNxWmw==} + + '@aws-amplify/data-schema-types@1.2.1': + resolution: {integrity: sha512-SuYVcy9Hg8Ox9P0QCXEPwqHxX5zVPgVo2YvNBOm5TpkZr4UK6ir3USame7dELZsk5/9f6KoP70QAYhTvp/j1Og==} + + '@aws-amplify/data-schema@1.24.0': + resolution: {integrity: sha512-nly/+w3R2JIq6qxsw7io2nGxliSswBO9FQqzckpTnpUAd+oMe06HoTyDvQG6hxozQc9Woy0tT375WIJp4C84Uw==} + + '@aws-amplify/datastore@5.1.5': + resolution: {integrity: sha512-/9o4eYqWOlxVxe/riDd282FmUHHSiGUEAwle464T8wzNSqPTB7yTeQfzt2LFYTWsrYLCSR0OtOM1bY5VPSVmew==} + peerDependencies: + '@aws-amplify/core': ^6.1.0 + + '@aws-amplify/notifications@2.0.93': + resolution: {integrity: sha512-NtHKusaiWzkPXuaKsTyvKAWE8JnQcXmQoaidQ5/a9/nWWTzs983l5xgc4OPvfVR+3N63K+3iTmYHtKcEbhgS6w==} + peerDependencies: + '@aws-amplify/core': ^6.1.0 + + '@aws-amplify/storage@6.13.1': + resolution: {integrity: sha512-iNDUmdvevcujcW4PBY7IGBMeTm+nohsZgswH6k99tG0myVsZRg0lVC4l5lcwoXoyVLpQjOmfZ0JgwV0oQbZ6zg==} + peerDependencies: + '@aws-amplify/core': ^6.1.0 + + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-firehose@3.982.0': + resolution: {integrity: sha512-Qur2Siqep+gRReTjlKXcdpyX/MUnzm5OgNNudDPxzpmzdnc3ZKlUwGlbEoS1VA5cFS6N4zg6WfZqlwcXg//TSg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-kinesis@3.982.0': + resolution: {integrity: sha512-Gh3xyumdz3IRj91HIBR48TohQyA3VSn/blDcGXzl4dwQKXgM0ISdHgyniNo2GQNhORJF3d01MSMx72s5NNQxUA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-personalize-events@3.982.0': + resolution: {integrity: sha512-JllssIZCPxAgYy4gkIM2e/kXxWT0xQzzZd5y9rRStm0bl5MiLAxzX4q9WhGG7glyB++EuhYskiT1N+DzyM5nTw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-sso@3.990.0': + resolution: {integrity: sha512-xTEaPjZwOqVjGbLOP7qzwbdOWJOo1ne2mUhTZwEBBkPvNk4aXB/vcYwWwrjoSWUqtit4+GDbO75ePc/S6TUJYQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.973.10': + resolution: {integrity: sha512-4u/FbyyT3JqzfsESI70iFg6e2yp87MB5kS2qcxIA66m52VSTN1fvuvbCY1h/LKq1LvuxIrlJ1ItcyjvcKoaPLg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.8': + resolution: {integrity: sha512-r91OOPAcHnLCSxaeu/lzZAVRCZ/CtTNuwmJkUwpwSDshUrP7bkX1OmFn2nUMWd9kN53Q4cEo8b7226G4olt2Mg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.10': + resolution: {integrity: sha512-DTtuyXSWB+KetzLcWaSahLJCtTUe/3SXtlGp4ik9PCe9xD6swHEkG8n8/BNsQ9dsihb9nhFvuUB4DpdBGDcvVg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.8': + resolution: {integrity: sha512-n2dMn21gvbBIEh00E8Nb+j01U/9rSqFIamWRdGm/mE5e+vHQ9g0cBNdrYFlM6AAiryKVHZmShWT9D1JAWJ3ISw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.8': + resolution: {integrity: sha512-rMFuVids8ICge/X9DF5pRdGMIvkVhDV9IQFQ8aTYk6iF0rl9jOUa1C3kjepxiXUlpgJQT++sLZkT9n0TMLHhQw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.9': + resolution: {integrity: sha512-LfJfO0ClRAq2WsSnA9JuUsNyIicD2eyputxSlSL0EiMrtxOxELLRG6ZVYDf/a1HCepaYPXeakH4y8D5OLCauag==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.8': + resolution: {integrity: sha512-6cg26ffFltxM51OOS8NH7oE41EccaYiNlbd5VgUYwhiGCySLfHoGuGrLm2rMB4zhy+IO5nWIIG0HiodX8zdvHA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.8': + resolution: {integrity: sha512-35kqmFOVU1n26SNv+U37sM8b2TzG8LyqAcd6iM9gprqxyHEh/8IM3gzN4Jzufs3qM6IrH8e43ryZWYdvfVzzKQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.8': + resolution: {integrity: sha512-CZhN1bOc1J3ubQPqbmr5b4KaMJBgdDvYsmEIZuX++wFlzmZsKj1bwkaiTEb5U2V7kXuzLlpF5HJSOM9eY/6nGA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-host-header@3.972.3': + resolution: {integrity: sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-logger@3.972.3': + resolution: {integrity: sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-recursion-detection@3.972.3': + resolution: {integrity: sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-user-agent@3.972.10': + resolution: {integrity: sha512-bBEL8CAqPQkI91ZM5a9xnFAzedpzH6NYCOtNyLarRAzTUTFN2DKqaC60ugBa7pnU1jSi4mA7WAXBsrod7nJltg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.990.0': + resolution: {integrity: sha512-3NA0s66vsy8g7hPh36ZsUgO4SiMyrhwcYvuuNK1PezO52vX3hXDW4pQrC6OQLGKGJV0o6tbEyQtXb/mPs8zg8w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/region-config-resolver@3.972.3': + resolution: {integrity: sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.990.0': + resolution: {integrity: sha512-L3BtUb2v9XmYgQdfGBzbBtKMXaP5fV973y3Qdxeevs6oUTVXFmi/mV1+LnScA/1wVPJC9/hlK+1o5vbt7cG7EQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.1': + resolution: {integrity: sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-endpoints@3.982.0': + resolution: {integrity: sha512-M27u8FJP7O0Of9hMWX5dipp//8iglmV9jr7R8SR8RveU+Z50/8TqH68Tu6wUWBGMfXjzbVwn1INIAO5lZrlxXQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-endpoints@3.990.0': + resolution: {integrity: sha512-kVwtDc9LNI3tQZHEMNbkLIOpeDK8sRSTuT8eMnzGY+O+JImPisfSTjdh+jw9OTznu+MYZjQsv0258sazVKunYg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.4': + resolution: {integrity: sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-user-agent-browser@3.972.3': + resolution: {integrity: sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==} + + '@aws-sdk/util-user-agent-node@3.972.8': + resolution: {integrity: sha512-XJZuT0LWsFCW1C8dEpPAXSa7h6Pb3krr2y//1X0Zidpcl0vmgY5nL/X0JuBZlntpBzaN3+U4hvKjuijyiiR8zw==} + engines: {node: '>=20.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/xml-builder@3.972.4': + resolution: {integrity: sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.2.3': + resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -1061,6 +1268,9 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@panva/hkdf@1.2.1': + resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} @@ -1955,6 +2165,237 @@ packages: cpu: [x64] os: [win32] + '@smithy/abort-controller@4.2.8': + resolution: {integrity: sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==} + engines: {node: '>=18.0.0'} + + '@smithy/config-resolver@4.4.6': + resolution: {integrity: sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==} + engines: {node: '>=18.0.0'} + + '@smithy/core@3.23.0': + resolution: {integrity: sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.2.8': + resolution: {integrity: sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-codec@4.2.8': + resolution: {integrity: sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-browser@4.2.8': + resolution: {integrity: sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-config-resolver@4.3.8': + resolution: {integrity: sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-node@4.2.8': + resolution: {integrity: sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-universal@4.2.8': + resolution: {integrity: sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.3.9': + resolution: {integrity: sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-node@4.2.8': + resolution: {integrity: sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==} + engines: {node: '>=18.0.0'} + + '@smithy/invalid-dependency@4.2.8': + resolution: {integrity: sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/is-array-buffer@3.0.0': + resolution: {integrity: sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ==} + engines: {node: '>=16.0.0'} + + '@smithy/is-array-buffer@4.2.0': + resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==} + engines: {node: '>=18.0.0'} + + '@smithy/md5-js@2.0.7': + resolution: {integrity: sha512-2i2BpXF9pI5D1xekqUsgQ/ohv5+H//G9FlawJrkOJskV18PgJ8LiNbLiskMeYt07yAsSTZR7qtlcAaa/GQLWww==} + + '@smithy/middleware-content-length@4.2.8': + resolution: {integrity: sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-endpoint@4.4.14': + resolution: {integrity: sha512-FUFNE5KVeaY6U/GL0nzAAHkaCHzXLZcY1EhtQnsAqhD8Du13oPKtMB9/0WK4/LK6a/T5OZ24wPoSShff5iI6Ag==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-retry@4.4.31': + resolution: {integrity: sha512-RXBzLpMkIrxBPe4C8OmEOHvS8aH9RUuCOH++Acb5jZDEblxDjyg6un72X9IcbrGTJoiUwmI7hLypNfuDACypbg==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-serde@4.2.9': + resolution: {integrity: sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-stack@4.2.8': + resolution: {integrity: sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-config-provider@4.3.8': + resolution: {integrity: sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.4.10': + resolution: {integrity: sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA==} + engines: {node: '>=18.0.0'} + + '@smithy/property-provider@4.2.8': + resolution: {integrity: sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==} + engines: {node: '>=18.0.0'} + + '@smithy/protocol-http@5.3.8': + resolution: {integrity: sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-builder@4.2.8': + resolution: {integrity: sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-parser@4.2.8': + resolution: {integrity: sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==} + engines: {node: '>=18.0.0'} + + '@smithy/service-error-classification@4.2.8': + resolution: {integrity: sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==} + engines: {node: '>=18.0.0'} + + '@smithy/shared-ini-file-loader@4.4.3': + resolution: {integrity: sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.3.8': + resolution: {integrity: sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==} + engines: {node: '>=18.0.0'} + + '@smithy/smithy-client@4.11.3': + resolution: {integrity: sha512-Q7kY5sDau8OoE6Y9zJoRGgje8P4/UY0WzH8R2ok0PDh+iJ+ZnEKowhjEqYafVcubkbYxQVaqwm3iufktzhprGg==} + engines: {node: '>=18.0.0'} + + '@smithy/types@2.12.0': + resolution: {integrity: sha512-QwYgloJ0sVNBeBuBs65cIkTbfzV/Q6ZNPCJ99EICFEdJYG50nGIY/uYXp+TbsdJReIuPr0a0kXmCvren3MbRRw==} + engines: {node: '>=14.0.0'} + + '@smithy/types@3.7.2': + resolution: {integrity: sha512-bNwBYYmN8Eh9RyjS1p2gW6MIhSO2rl7X9QeLM8iTdcGRP+eDiIWDt66c9IysCc22gefKszZv+ubV9qZc7hdESg==} + engines: {node: '>=16.0.0'} + + '@smithy/types@4.12.0': + resolution: {integrity: sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==} + engines: {node: '>=18.0.0'} + + '@smithy/url-parser@4.2.8': + resolution: {integrity: sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-base64@3.0.0': + resolution: {integrity: sha512-Kxvoh5Qtt0CDsfajiZOCpJxgtPHXOKwmM+Zy4waD43UoEMA+qPxxa98aE/7ZhdnBFZFXMOiBR5xbcaMhLtznQQ==} + engines: {node: '>=16.0.0'} + + '@smithy/util-base64@4.3.0': + resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-browser@4.2.0': + resolution: {integrity: sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-node@4.2.1': + resolution: {integrity: sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-buffer-from@3.0.0': + resolution: {integrity: sha512-aEOHCgq5RWFbP+UDPvPot26EJHjOC+bRgse5A8V3FSShqd5E5UN4qc7zkwsvJPPAVsf73QwYcHN1/gt/rtLwQA==} + engines: {node: '>=16.0.0'} + + '@smithy/util-buffer-from@4.2.0': + resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==} + engines: {node: '>=18.0.0'} + + '@smithy/util-config-provider@4.2.0': + resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-browser@4.3.30': + resolution: {integrity: sha512-cMni0uVU27zxOiU8TuC8pQLC1pYeZ/xEMxvchSK/ILwleRd1ugobOcIRr5vXtcRqKd4aBLWlpeBoDPJJ91LQng==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-node@4.2.33': + resolution: {integrity: sha512-LEb2aq5F4oZUSzWBG7S53d4UytZSkOEJPXcBq/xbG2/TmK9EW5naUZ8lKu1BEyWMzdHIzEVN16M3k8oxDq+DJA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-endpoints@3.2.8': + resolution: {integrity: sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-hex-encoding@2.0.0': + resolution: {integrity: sha512-c5xY+NUnFqG6d7HFh1IFfrm3mGl29lC+vF+geHv4ToiuJCBmIfzx6IeHLg+OgRdPFKDXIw6pvi+p3CsscaMcMA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-hex-encoding@4.2.0': + resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-middleware@4.2.8': + resolution: {integrity: sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==} + engines: {node: '>=18.0.0'} + + '@smithy/util-retry@4.2.8': + resolution: {integrity: sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.5.12': + resolution: {integrity: sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-uri-escape@4.2.0': + resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-utf8@2.0.0': + resolution: {integrity: sha512-rctU1VkziY84n5OXe3bPNpKR001ZCME2JCaBBFgtiM2hfKbHFudc/BkMuPab8hRbLd0j3vbnBTTZ1igBf0wgiQ==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@3.0.0': + resolution: {integrity: sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==} + engines: {node: '>=16.0.0'} + + '@smithy/util-utf8@4.2.0': + resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-waiter@4.2.8': + resolution: {integrity: sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg==} + engines: {node: '>=18.0.0'} + + '@smithy/uuid@1.1.0': + resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} + engines: {node: '>=18.0.0'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2253,6 +2694,9 @@ packages: '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/aws-lambda@8.10.160': + resolution: {integrity: sha512-uoO4QVQNWFPJMh26pXtmtrRfGshPUSpMZGUyUQY20FhfHEElEBOPKgVmFs1z+kbpyBsRs2JnoOPT7++Z4GA9pA==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -2348,6 +2792,9 @@ packages: '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/uuid@9.0.8': + resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -2556,6 +3003,9 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + aws-amplify@6.16.2: + resolution: {integrity: sha512-7CHwfH5QxZ0rzCws/DNy5VLVcIIZWd9iUTtV1Oj6kPzpkFhCJ2I8gTvhFdh61HLhrg2lShcPQ8cecBIQS/ZJ0A==} + axios@1.13.4: resolution: {integrity: sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==} @@ -2565,12 +3015,18 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.9.11: resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -2586,6 +3042,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer@4.9.2: + resolution: {integrity: sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2702,6 +3161,11 @@ packages: react: optional: true + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} @@ -3026,6 +3490,14 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-xml-parser@5.3.4: + resolution: {integrity: sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==} + hasBin: true + + fast-xml-parser@5.3.6: + resolution: {integrity: sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==} + hasBin: true + fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -3156,6 +3628,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphql@15.8.0: + resolution: {integrity: sha512-5gghUc24tP9HRznNpV2+FIoq3xKkj5dTQqf4v0CpdPbFVwFkWoxOM+o+2OC9ZSvjEMTjfmG9QT+gcvggTwW1zw==} + engines: {node: '>= 10.x'} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -3222,6 +3698,12 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + idb@5.0.6: + resolution: {integrity: sha512-/PFvOWPzRcEPmlDt5jEvzVZVs0wyd/EvGvkDIcbBpGuMMLQKrTPG0TxvE2UJtgZtCQCmOtM2QD7yQJBVEjKGOw==} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -3233,6 +3715,9 @@ packages: immer@9.0.21: resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==} + immer@9.0.6: + resolution: {integrity: sha512-G95ivKpy+EvVAnAab4fVa4YGYn24J1SpEktnJX7JJ45Bd7xqME/SCplFzYFmTbrkwZbQ4xJK1xMTUYBkN6pWsQ==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -3371,6 +3856,9 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -3385,10 +3873,17 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3531,6 +4026,9 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -3760,6 +4258,22 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + next-auth@5.0.0-beta.30: + resolution: {integrity: sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + next: ^14.0.0-0 || ^15.0.0 || ^16.0.0 + nodemailer: ^7.0.7 + react: ^18.2.0 || ^19.0.0 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: @@ -3790,6 +4304,9 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + oauth4webapi@3.8.5: + resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -3915,6 +4432,14 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + preact-render-to-string@6.5.11: + resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==} + peerDependencies: + preact: '>=10' + + preact@10.24.3: + resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -4201,6 +4726,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -4327,6 +4855,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strnum@2.1.2: + resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -4552,6 +5083,10 @@ packages: ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + ulid@2.4.0: + resolution: {integrity: sha512-fIRiVTJNcSRmXKPZtGzFQv9WRrZ3M9eoptl/teFJvjOzmpU+/K/JH6HZ8deBfb5vMEpicJcLn7JmvdknlMq7Zg==} + hasBin: true + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -4618,6 +5153,10 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + vaul@1.1.2: resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} peerDependencies: @@ -4814,57 +5353,633 @@ snapshots: '@ai-sdk/gateway@3.0.39(zod@4.3.6)': dependencies: - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.14(zod@4.3.6) - '@vercel/oidc': 3.1.0 - zod: 4.3.6 + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@4.3.6) + '@vercel/oidc': 3.1.0 + zod: 4.3.6 + + '@ai-sdk/openai@3.0.26(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/provider-utils@4.0.14(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 4.3.6 + + '@ai-sdk/provider@3.0.8': + dependencies: + json-schema: 0.4.0 + + '@ai-sdk/react@3.0.79(react@19.2.0)(zod@4.3.6)': + dependencies: + '@ai-sdk/provider-utils': 4.0.14(zod@4.3.6) + ai: 6.0.77(zod@4.3.6) + react: 19.2.0 + swr: 2.4.0(react@19.2.0) + throttleit: 2.1.0 + transitivePeerDependencies: + - zod + + '@alloc/quick-lru@5.2.0': {} + + '@asamuzakjp/css-color@4.1.2': + dependencies: + '@csstools/css-calc': 3.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.5 + + '@asamuzakjp/dom-selector@6.7.8': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.1.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.5 + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@auth/core@0.41.0': + dependencies: + '@panva/hkdf': 1.2.1 + jose: 6.1.3 + oauth4webapi: 3.8.5 + preact: 10.24.3 + preact-render-to-string: 6.5.11(preact@10.24.3) + + '@aws-amplify/analytics@7.0.93(@aws-amplify/core@6.16.1)': + dependencies: + '@aws-amplify/core': 6.16.1 + '@aws-sdk/client-firehose': 3.982.0 + '@aws-sdk/client-kinesis': 3.982.0 + '@aws-sdk/client-personalize-events': 3.982.0 + '@smithy/util-utf8': 2.0.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-amplify/api-graphql@4.8.5': + dependencies: + '@aws-amplify/api-rest': 4.6.3(@aws-amplify/core@6.16.1) + '@aws-amplify/core': 6.16.1 + '@aws-amplify/data-schema': 1.24.0 + '@aws-sdk/types': 3.973.1 + graphql: 15.8.0 + rxjs: 7.8.2 + tslib: 2.8.1 + uuid: 11.1.0 + + '@aws-amplify/api-rest@4.6.3(@aws-amplify/core@6.16.1)': + dependencies: + '@aws-amplify/core': 6.16.1 + tslib: 2.8.1 + + '@aws-amplify/api@6.3.24(@aws-amplify/core@6.16.1)': + dependencies: + '@aws-amplify/api-graphql': 4.8.5 + '@aws-amplify/api-rest': 4.6.3(@aws-amplify/core@6.16.1) + '@aws-amplify/core': 6.16.1 + '@aws-amplify/data-schema': 1.24.0 + rxjs: 7.8.2 + tslib: 2.8.1 + + '@aws-amplify/auth@6.19.1(@aws-amplify/core@6.16.1)': + dependencies: + '@aws-amplify/core': 6.16.1 + '@aws-crypto/sha256-js': 5.2.0 + '@smithy/types': 3.7.2 + tslib: 2.8.1 + + '@aws-amplify/core@6.16.1': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/types': 3.973.1 + '@smithy/util-hex-encoding': 2.0.0 + '@types/uuid': 9.0.8 + js-cookie: 3.0.5 + rxjs: 7.8.2 + tslib: 2.8.1 + uuid: 11.1.0 + + '@aws-amplify/data-schema-types@1.2.1': + dependencies: + graphql: 15.8.0 + rxjs: 7.8.2 + + '@aws-amplify/data-schema@1.24.0': + dependencies: + '@aws-amplify/data-schema-types': 1.2.1 + '@smithy/util-base64': 3.0.0 + '@types/aws-lambda': 8.10.160 + '@types/json-schema': 7.0.15 + rxjs: 7.8.2 + + '@aws-amplify/datastore@5.1.5(@aws-amplify/core@6.16.1)': + dependencies: + '@aws-amplify/api': 6.3.24(@aws-amplify/core@6.16.1) + '@aws-amplify/api-graphql': 4.8.5 + '@aws-amplify/core': 6.16.1 + buffer: 4.9.2 + idb: 5.0.6 + immer: 9.0.6 + rxjs: 7.8.2 + ulid: 2.4.0 + + '@aws-amplify/notifications@2.0.93(@aws-amplify/core@6.16.1)': + dependencies: + '@aws-amplify/core': 6.16.1 + '@aws-sdk/types': 3.973.1 + lodash: 4.17.23 + tslib: 2.8.1 + + '@aws-amplify/storage@6.13.1(@aws-amplify/core@6.16.1)': + dependencies: + '@aws-amplify/core': 6.16.1 + '@aws-sdk/types': 3.973.1 + '@smithy/md5-js': 2.0.7 + buffer: 4.9.2 + crc-32: 1.2.2 + fast-xml-parser: 5.3.6 + tslib: 2.8.1 + + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.1 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-locate-window': 3.965.4 + '@smithy/util-utf8': 2.0.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.1 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-firehose@3.982.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.10 + '@aws-sdk/credential-provider-node': 3.972.9 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.10 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.982.0 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.8 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.23.0 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-retry': 4.4.31 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.10 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.30 + '@smithy/util-defaults-mode-node': 4.2.33 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-kinesis@3.982.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.10 + '@aws-sdk/credential-provider-node': 3.972.9 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.10 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.982.0 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.8 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.23.0 + '@smithy/eventstream-serde-browser': 4.2.8 + '@smithy/eventstream-serde-config-resolver': 4.3.8 + '@smithy/eventstream-serde-node': 4.2.8 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-retry': 4.4.31 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.10 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.30 + '@smithy/util-defaults-mode-node': 4.2.33 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-utf8': 4.2.0 + '@smithy/util-waiter': 4.2.8 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-personalize-events@3.982.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.10 + '@aws-sdk/credential-provider-node': 3.972.9 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.10 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.982.0 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.8 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.23.0 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-retry': 4.4.31 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.10 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.30 + '@smithy/util-defaults-mode-node': 4.2.33 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-sso@3.990.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.10 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.10 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.990.0 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.8 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.23.0 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-retry': 4.4.31 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.10 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.30 + '@smithy/util-defaults-mode-node': 4.2.33 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/core@3.973.10': + dependencies: + '@aws-sdk/types': 3.973.1 + '@aws-sdk/xml-builder': 3.972.4 + '@smithy/core': 3.23.0 + '@smithy/node-config-provider': 4.3.8 + '@smithy/property-provider': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/signature-v4': 5.3.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.8': + dependencies: + '@aws-sdk/core': 3.973.10 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.10': + dependencies: + '@aws-sdk/core': 3.973.10 + '@aws-sdk/types': 3.973.1 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/node-http-handler': 4.4.10 + '@smithy/property-provider': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/util-stream': 4.5.12 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.8': + dependencies: + '@aws-sdk/core': 3.973.10 + '@aws-sdk/credential-provider-env': 3.972.8 + '@aws-sdk/credential-provider-http': 3.972.10 + '@aws-sdk/credential-provider-login': 3.972.8 + '@aws-sdk/credential-provider-process': 3.972.8 + '@aws-sdk/credential-provider-sso': 3.972.8 + '@aws-sdk/credential-provider-web-identity': 3.972.8 + '@aws-sdk/nested-clients': 3.990.0 + '@aws-sdk/types': 3.973.1 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-login@3.972.8': + dependencies: + '@aws-sdk/core': 3.973.10 + '@aws-sdk/nested-clients': 3.990.0 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-node@3.972.9': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.8 + '@aws-sdk/credential-provider-http': 3.972.10 + '@aws-sdk/credential-provider-ini': 3.972.8 + '@aws-sdk/credential-provider-process': 3.972.8 + '@aws-sdk/credential-provider-sso': 3.972.8 + '@aws-sdk/credential-provider-web-identity': 3.972.8 + '@aws-sdk/types': 3.973.1 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-process@3.972.8': + dependencies: + '@aws-sdk/core': 3.973.10 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.8': + dependencies: + '@aws-sdk/client-sso': 3.990.0 + '@aws-sdk/core': 3.973.10 + '@aws-sdk/token-providers': 3.990.0 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-web-identity@3.972.8': + dependencies: + '@aws-sdk/core': 3.973.10 + '@aws-sdk/nested-clients': 3.990.0 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/middleware-host-header@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-logger@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-recursion-detection@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@aws/lambda-invoke-store': 0.2.3 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.972.10': + dependencies: + '@aws-sdk/core': 3.973.10 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.990.0 + '@smithy/core': 3.23.0 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.990.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.10 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.10 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.990.0 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.8 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.23.0 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-retry': 4.4.31 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.10 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.30 + '@smithy/util-defaults-mode-node': 4.2.33 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/region-config-resolver@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/config-resolver': 4.4.6 + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.990.0': + dependencies: + '@aws-sdk/core': 3.973.10 + '@aws-sdk/nested-clients': 3.990.0 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt - '@ai-sdk/openai@3.0.26(zod@4.3.6)': + '@aws-sdk/types@3.973.1': dependencies: - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.14(zod@4.3.6) - zod: 4.3.6 + '@smithy/types': 4.12.0 + tslib: 2.8.1 - '@ai-sdk/provider-utils@4.0.14(zod@4.3.6)': + '@aws-sdk/util-endpoints@3.982.0': dependencies: - '@ai-sdk/provider': 3.0.8 - '@standard-schema/spec': 1.1.0 - eventsource-parser: 3.0.6 - zod: 4.3.6 + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-endpoints': 3.2.8 + tslib: 2.8.1 - '@ai-sdk/provider@3.0.8': + '@aws-sdk/util-endpoints@3.990.0': dependencies: - json-schema: 0.4.0 + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-endpoints': 3.2.8 + tslib: 2.8.1 - '@ai-sdk/react@3.0.79(react@19.2.0)(zod@4.3.6)': + '@aws-sdk/util-locate-window@3.965.4': dependencies: - '@ai-sdk/provider-utils': 4.0.14(zod@4.3.6) - ai: 6.0.77(zod@4.3.6) - react: 19.2.0 - swr: 2.4.0(react@19.2.0) - throttleit: 2.1.0 - transitivePeerDependencies: - - zod + tslib: 2.8.1 - '@alloc/quick-lru@5.2.0': {} + '@aws-sdk/util-user-agent-browser@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + bowser: 2.14.1 + tslib: 2.8.1 - '@asamuzakjp/css-color@4.1.2': + '@aws-sdk/util-user-agent-node@3.972.8': dependencies: - '@csstools/css-calc': 3.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-color-parser': 4.0.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-tokenizer': 4.0.0 - lru-cache: 11.2.5 + '@aws-sdk/middleware-user-agent': 3.972.10 + '@aws-sdk/types': 3.973.1 + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 - '@asamuzakjp/dom-selector@6.7.8': + '@aws-sdk/xml-builder@3.972.4': dependencies: - '@asamuzakjp/nwsapi': 2.3.9 - bidi-js: 1.0.3 - css-tree: 3.1.0 - is-potential-custom-element-name: 1.0.1 - lru-cache: 11.2.5 + '@smithy/types': 4.12.0 + fast-xml-parser: 5.3.4 + tslib: 2.8.1 - '@asamuzakjp/nwsapi@2.3.9': {} + '@aws/lambda-invoke-store@0.2.3': {} '@babel/code-frame@7.29.0': dependencies: @@ -5346,6 +6461,8 @@ snapshots: '@opentelemetry/api@1.9.0': {} + '@panva/hkdf@1.2.1': {} + '@popperjs/core@2.11.8': {} '@radix-ui/number@1.1.1': {} @@ -6227,6 +7344,359 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.57.1': optional: true + '@smithy/abort-controller@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/config-resolver@4.4.6': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-config-provider': 4.2.0 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + tslib: 2.8.1 + + '@smithy/core@3.23.0': + dependencies: + '@smithy/middleware-serde': 4.2.9 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-stream': 4.5.12 + '@smithy/util-utf8': 4.2.0 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.2.8': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/property-provider': 4.2.8 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + tslib: 2.8.1 + + '@smithy/eventstream-codec@4.2.8': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.12.0 + '@smithy/util-hex-encoding': 4.2.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-browser@4.2.8': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-config-resolver@4.3.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-node@4.2.8': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-universal@4.2.8': + dependencies: + '@smithy/eventstream-codec': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.3.9': + dependencies: + '@smithy/protocol-http': 5.3.8 + '@smithy/querystring-builder': 4.2.8 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + tslib: 2.8.1 + + '@smithy/hash-node@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/invalid-dependency@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/is-array-buffer@3.0.0': + dependencies: + tslib: 2.8.1 + + '@smithy/is-array-buffer@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/md5-js@2.0.7': + dependencies: + '@smithy/types': 2.12.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@smithy/middleware-content-length@4.2.8': + dependencies: + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/middleware-endpoint@4.4.14': + dependencies: + '@smithy/core': 3.23.0 + '@smithy/middleware-serde': 4.2.9 + '@smithy/node-config-provider': 4.3.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-middleware': 4.2.8 + tslib: 2.8.1 + + '@smithy/middleware-retry@4.4.31': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/service-error-classification': 4.2.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + + '@smithy/middleware-serde@4.2.9': + dependencies: + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/middleware-stack@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/node-config-provider@4.3.8': + dependencies: + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.4.10': + dependencies: + '@smithy/abort-controller': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/querystring-builder': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/property-provider@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/protocol-http@5.3.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/querystring-builder@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + '@smithy/util-uri-escape': 4.2.0 + tslib: 2.8.1 + + '@smithy/querystring-parser@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/service-error-classification@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + + '@smithy/shared-ini-file-loader@4.4.3': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/signature-v4@5.3.8': + dependencies: + '@smithy/is-array-buffer': 4.2.0 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-uri-escape': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/smithy-client@4.11.3': + dependencies: + '@smithy/core': 3.23.0 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-stack': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-stream': 4.5.12 + tslib: 2.8.1 + + '@smithy/types@2.12.0': + dependencies: + tslib: 2.8.1 + + '@smithy/types@3.7.2': + dependencies: + tslib: 2.8.1 + + '@smithy/types@4.12.0': + dependencies: + tslib: 2.8.1 + + '@smithy/url-parser@4.2.8': + dependencies: + '@smithy/querystring-parser': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-base64@3.0.0': + dependencies: + '@smithy/util-buffer-from': 3.0.0 + '@smithy/util-utf8': 3.0.0 + tslib: 2.8.1 + + '@smithy/util-base64@4.3.0': + dependencies: + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-body-length-browser@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-body-length-node@4.2.1': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-buffer-from@3.0.0': + dependencies: + '@smithy/is-array-buffer': 3.0.0 + tslib: 2.8.1 + + '@smithy/util-buffer-from@4.2.0': + dependencies: + '@smithy/is-array-buffer': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-config-provider@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-defaults-mode-browser@4.3.30': + dependencies: + '@smithy/property-provider': 4.2.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-defaults-mode-node@4.2.33': + dependencies: + '@smithy/config-resolver': 4.4.6 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/property-provider': 4.2.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-endpoints@3.2.8': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-hex-encoding@2.0.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-hex-encoding@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-middleware@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-retry@4.2.8': + dependencies: + '@smithy/service-error-classification': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-stream@4.5.12': + dependencies: + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/node-http-handler': 4.4.10 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-uri-escape@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-utf8@2.0.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@3.0.0': + dependencies: + '@smithy/util-buffer-from': 3.0.0 + tslib: 2.8.1 + + '@smithy/util-utf8@4.2.0': + dependencies: + '@smithy/util-buffer-from': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-waiter@4.2.8': + dependencies: + '@smithy/abort-controller': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/uuid@1.1.0': + dependencies: + tslib: 2.8.1 + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.15': @@ -6534,6 +8004,8 @@ snapshots: '@types/aria-query@5.0.4': {} + '@types/aws-lambda@8.10.160': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.0 @@ -6644,6 +8116,8 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} + '@types/uuid@9.0.8': {} + '@types/ws@8.18.1': dependencies: '@types/node': 22.15.3 @@ -6929,6 +8403,20 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + aws-amplify@6.16.2: + dependencies: + '@aws-amplify/analytics': 7.0.93(@aws-amplify/core@6.16.1) + '@aws-amplify/api': 6.3.24(@aws-amplify/core@6.16.1) + '@aws-amplify/auth': 6.19.1(@aws-amplify/core@6.16.1) + '@aws-amplify/core': 6.16.1 + '@aws-amplify/datastore': 5.1.5(@aws-amplify/core@6.16.1) + '@aws-amplify/notifications': 2.0.93(@aws-amplify/core@6.16.1) + '@aws-amplify/storage': 6.13.1(@aws-amplify/core@6.16.1) + tslib: 2.8.1 + transitivePeerDependencies: + - '@aws-amplify/react-native' + - aws-crt + axios@1.13.4: dependencies: follow-redirects: 1.15.11 @@ -6941,12 +8429,16 @@ snapshots: balanced-match@1.0.2: {} + base64-js@1.5.1: {} + baseline-browser-mapping@2.9.11: {} bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 + bowser@2.14.1: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -6968,6 +8460,12 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + buffer@4.9.2: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + isarray: 1.0.0 + bundle-require@5.1.0(esbuild@0.27.0): dependencies: esbuild: 0.27.0 @@ -7056,6 +8554,8 @@ snapshots: optionalDependencies: react: 19.2.0 + crc-32@1.2.2: {} + crelt@1.0.6: {} cross-spawn@7.0.6: @@ -7491,6 +8991,14 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-xml-parser@5.3.4: + dependencies: + strnum: 2.1.2 + + fast-xml-parser@5.3.6: + dependencies: + strnum: 2.1.2 + fastq@1.19.1: dependencies: reusify: 1.1.0 @@ -7617,6 +9125,8 @@ snapshots: graceful-fs@4.2.11: {} + graphql@15.8.0: {} + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -7736,6 +9246,10 @@ snapshots: transitivePeerDependencies: - supports-color + idb@5.0.6: {} + + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -7743,6 +9257,8 @@ snapshots: immer@9.0.21: optional: true + immer@9.0.6: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -7882,6 +9398,8 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + isarray@1.0.0: {} + isarray@2.0.5: {} isexe@2.0.0: {} @@ -7897,8 +9415,12 @@ snapshots: jiti@2.6.1: {} + jose@6.1.3: {} + joycon@3.1.1: {} + js-cookie@3.0.5: {} + js-tokens@4.0.0: {} js-yaml@4.1.0: @@ -8026,6 +9548,8 @@ snapshots: lodash.merge@4.6.2: {} + lodash@4.17.23: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -8465,6 +9989,12 @@ snapshots: natural-compare@1.4.0: {} + next-auth@5.0.0-beta.30(next@16.1.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0): + dependencies: + '@auth/core': 0.41.0 + next: 16.1.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + next-themes@0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: react: 19.2.0 @@ -8497,6 +10027,8 @@ snapshots: node-releases@2.0.27: {} + oauth4webapi@3.8.5: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -8626,6 +10158,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + preact-render-to-string@6.5.11(preact@10.24.3): + dependencies: + preact: 10.24.3 + + preact@10.24.3: {} + prelude-ls@1.2.1: {} prettier-plugin-tailwindcss@0.7.2(prettier@3.7.4): @@ -9007,6 +10545,10 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + safe-array-concat@1.1.3: dependencies: call-bind: 1.0.8 @@ -9201,6 +10743,8 @@ snapshots: strip-json-comments@3.1.1: {} + strnum@2.1.2: {} + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -9425,6 +10969,8 @@ snapshots: ufo@1.6.3: {} + ulid@2.4.0: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -9500,6 +11046,8 @@ snapshots: dependencies: react: 19.2.0 + uuid@11.1.0: {} + vaul@1.1.2(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) diff --git a/scripts/convex-deploy.sh b/scripts/convex-deploy.sh new file mode 100755 index 0000000..2377a0a --- /dev/null +++ b/scripts/convex-deploy.sh @@ -0,0 +1,41 @@ +#!/bin/bash +set -e + +# Deploy Convex backend with the correct auth config. +# +# Copies the right auth.config template based on AUTH_PROVIDER: +# AUTH_PROVIDER=cognito → auth.config.cognito.ts (requires Cognito env vars) +# AUTH_PROVIDER=nextauth → auth.config.nextauth.ts (default, local / self-hosted) +# +# Usage: +# AUTH_PROVIDER=cognito ./scripts/convex-deploy.sh +# AUTH_PROVIDER=nextauth ./scripts/convex-deploy.sh + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" +CONVEX_DIR="$ROOT_DIR/packages/backend/convex" + +AUTH_PROVIDER="${AUTH_PROVIDER:-nextauth}" + +echo "==> Deploying Convex (AUTH_PROVIDER=$AUTH_PROVIDER)" + +if [ "$AUTH_PROVIDER" = "cognito" ]; then + echo "==> Using Cognito auth config" + cp "$CONVEX_DIR/auth.config.cognito.ts" "$CONVEX_DIR/auth.config.ts" + + # Set Cognito env vars in Convex + pnpm --filter @clawe/backend exec convex env set COGNITO_ISSUER_URL "$COGNITO_ISSUER_URL" + pnpm --filter @clawe/backend exec convex env set COGNITO_CLIENT_ID "$COGNITO_CLIENT_ID" +else + echo "==> Using NextAuth config (local / self-hosted)" + cp "$CONVEX_DIR/auth.config.nextauth.ts" "$CONVEX_DIR/auth.config.ts" + + # Set NextAuth env vars in Convex + pnpm --filter @clawe/backend exec convex env set NEXTAUTH_ISSUER_URL "$NEXTAUTH_URL" + pnpm --filter @clawe/backend exec convex env set NEXTAUTH_JWKS_URL "$NEXTAUTH_JWKS_URL" +fi + +# Deploy Convex functions and schema +pnpm --filter @clawe/backend deploy + +echo "==> Convex deployment complete" diff --git a/turbo.json b/turbo.json index 66b008e..3f88052 100644 --- a/turbo.json +++ b/turbo.json @@ -9,7 +9,13 @@ "SQUADHUB_URL", "SQUADHUB_TOKEN", "CLAWE_DATA_DIR", - "SQUADHUB_STATE_DIR" + "SQUADHUB_STATE_DIR", + "AUTH_PROVIDER", + "NEXTAUTH_SECRET", + "NEXTAUTH_URL", + "AUTO_LOGIN_EMAIL", + "GOOGLE_CLIENT_ID", + "GOOGLE_CLIENT_SECRET" ], "tasks": { "build": { From 6692f696ef3bbf477dc4180ad43a876e50d1bcd3 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Mon, 16 Feb 2026 22:04:50 +0200 Subject: [PATCH 5/8] fix: refactor codebase --- .env.example | 4 + apps/watcher/.env.example | 3 +- apps/watcher/README.md | 19 +- apps/watcher/src/config.spec.ts | 21 +- apps/watcher/src/config.ts | 4 +- apps/watcher/src/index.ts | 156 ++++++------- apps/web/package.json | 2 + .../web/src/app/api/tenant/provision/route.ts | 101 +++++++- apps/web/src/app/auth/login/page.tsx | 10 +- apps/web/src/app/layout.tsx | 25 +- apps/web/src/hooks/use-api-client.ts | 12 + apps/web/src/hooks/use-chat.spec.ts | 50 +++- apps/web/src/hooks/use-chat.ts | 14 +- apps/web/src/hooks/use-squadhub-status.ts | 30 +-- apps/web/src/lib/api/client.ts | 39 ++++ apps/web/src/lib/auth/verify-token.ts | 101 ++++++++ apps/web/src/lib/squadhub/provision.ts | 4 + .../web/src/providers/api-client-provider.tsx | 17 ++ apps/web/src/providers/auth-provider.tsx | 107 ++++----- apps/web/src/proxy.ts | 51 +++- docker/squadhub/entrypoint.sh | 8 +- docker/squadhub/scripts/pair-device.js | 221 ++++++++++++++---- packages/backend/package.json | 2 +- packages/plugins/eslint.config.mjs | 4 + packages/plugins/package.json | 29 +++ packages/plugins/src/cloud-plugins.d.ts | 9 + packages/plugins/src/defaults/index.ts | 2 + packages/plugins/src/defaults/lifecycle.ts | 27 +++ packages/plugins/src/defaults/provisioner.ts | 27 +++ packages/plugins/src/index.ts | 17 ++ packages/plugins/src/interfaces/index.ts | 8 + packages/plugins/src/interfaces/lifecycle.ts | 18 ++ .../plugins/src/interfaces/provisioner.ts | 29 +++ packages/plugins/src/registry.spec.ts | 80 +++++++ packages/plugins/src/registry.ts | 43 ++++ packages/plugins/tsconfig.json | 14 ++ pnpm-lock.yaml | 34 +++ scripts/convex-deploy.sh | 5 + scripts/sync-convex-env.sh | 36 +++ turbo.json | 3 +- 40 files changed, 1102 insertions(+), 284 deletions(-) create mode 100644 apps/web/src/hooks/use-api-client.ts create mode 100644 apps/web/src/lib/api/client.ts create mode 100644 apps/web/src/lib/auth/verify-token.ts create mode 100644 apps/web/src/providers/api-client-provider.tsx create mode 100644 packages/plugins/eslint.config.mjs create mode 100644 packages/plugins/package.json create mode 100644 packages/plugins/src/cloud-plugins.d.ts create mode 100644 packages/plugins/src/defaults/index.ts create mode 100644 packages/plugins/src/defaults/lifecycle.ts create mode 100644 packages/plugins/src/defaults/provisioner.ts create mode 100644 packages/plugins/src/index.ts create mode 100644 packages/plugins/src/interfaces/index.ts create mode 100644 packages/plugins/src/interfaces/lifecycle.ts create mode 100644 packages/plugins/src/interfaces/provisioner.ts create mode 100644 packages/plugins/src/registry.spec.ts create mode 100644 packages/plugins/src/registry.ts create mode 100644 packages/plugins/tsconfig.json create mode 100755 scripts/sync-convex-env.sh diff --git a/.env.example b/.env.example index 8376d0b..9e1268b 100644 --- a/.env.example +++ b/.env.example @@ -66,3 +66,7 @@ AUTO_LOGIN_EMAIL=dev@clawe.local # Development: ./.squadhub/config (relative to project root) # Production: /squadhub-data/config (shared Docker volume) SQUADHUB_STATE_DIR=./.squadhub/config + +# Watcher system token (must also be set as Convex env var) +# Used by the watcher service for system-level auth (tenants.listActive) +WATCHER_TOKEN=clawe-watcher-dev-token diff --git a/apps/watcher/.env.example b/apps/watcher/.env.example index e2b07ef..f87445b 100644 --- a/apps/watcher/.env.example +++ b/apps/watcher/.env.example @@ -4,5 +4,4 @@ # Required variables (from root .env): # - CONVEX_URL -# - SQUADHUB_URL -# - SQUADHUB_TOKEN +# - WATCHER_TOKEN diff --git a/apps/watcher/README.md b/apps/watcher/README.md index b522179..d73b040 100644 --- a/apps/watcher/README.md +++ b/apps/watcher/README.md @@ -4,22 +4,17 @@ Coordination watcher for Clawe multi-agent system. ## What It Does -1. **On startup:** Registers all agents in Convex (upsert) -2. **On startup:** Ensures heartbeat crons are configured for all agents -3. **Continuously:** Polls Convex for undelivered notifications and delivers them +1. **Continuously:** Polls Convex for undelivered notifications and delivers them +2. **Continuously:** Checks for due routines and triggers them -This enables: - -- Automatic agent heartbeat scheduling (no manual cron setup needed) -- Near-instant notification delivery without waiting for heartbeats +Tenant connection info (squadhub URL/token) comes from Convex via `tenants.listActive`. ## Environment Variables -| Variable | Required | Description | -| ---------------- | -------- | ----------------------------- | -| `CONVEX_URL` | Yes | Convex deployment URL | -| `SQUADHUB_URL` | Yes | Squadhub gateway URL | -| `SQUADHUB_TOKEN` | Yes | Squadhub authentication token | +| Variable | Required | Description | +| --------------- | -------- | ---------------------------------------------- | +| `CONVEX_URL` | Yes | Convex deployment URL | +| `WATCHER_TOKEN` | Yes | System-level token for querying active tenants | ## Running diff --git a/apps/watcher/src/config.spec.ts b/apps/watcher/src/config.spec.ts index f31c5eb..6de6a0e 100644 --- a/apps/watcher/src/config.spec.ts +++ b/apps/watcher/src/config.spec.ts @@ -15,8 +15,7 @@ describe("config", () => { describe("validateEnv", () => { it("exits when CONVEX_URL is missing", async () => { delete process.env.CONVEX_URL; - process.env.SQUADHUB_URL = "http://localhost:18789"; - process.env.SQUADHUB_TOKEN = "test-token"; + process.env.WATCHER_TOKEN = "test-token"; const mockExit = vi .spyOn(process, "exit") @@ -35,10 +34,9 @@ describe("config", () => { mockError.mockRestore(); }); - it("exits when SQUADHUB_URL is missing", async () => { + it("exits when WATCHER_TOKEN is missing", async () => { process.env.CONVEX_URL = "https://test.convex.cloud"; - delete process.env.SQUADHUB_URL; - process.env.SQUADHUB_TOKEN = "test-token"; + delete process.env.WATCHER_TOKEN; const mockExit = vi .spyOn(process, "exit") @@ -49,7 +47,7 @@ describe("config", () => { validateEnv(); expect(mockError).toHaveBeenCalledWith( - expect.stringContaining("SQUADHUB_URL"), + expect.stringContaining("WATCHER_TOKEN"), ); expect(mockExit).toHaveBeenCalledWith(1); @@ -59,8 +57,7 @@ describe("config", () => { it("does not exit when all required vars are set", async () => { process.env.CONVEX_URL = "https://test.convex.cloud"; - process.env.SQUADHUB_URL = "http://localhost:18789"; - process.env.SQUADHUB_TOKEN = "test-token"; + process.env.WATCHER_TOKEN = "test-token"; const mockExit = vi .spyOn(process, "exit") @@ -76,16 +73,14 @@ describe("config", () => { }); describe("config object", () => { - it("has correct default values", async () => { + it("has correct values from env", async () => { process.env.CONVEX_URL = "https://test.convex.cloud"; - process.env.SQUADHUB_URL = "http://custom:8080"; - process.env.SQUADHUB_TOKEN = "my-token"; + process.env.WATCHER_TOKEN = "my-watcher-token"; const { config } = await import("./config.js"); expect(config.convexUrl).toBe("https://test.convex.cloud"); - expect(config.squadhubUrl).toBe("http://custom:8080"); - expect(config.squadhubToken).toBe("my-token"); + expect(config.watcherToken).toBe("my-watcher-token"); }); }); diff --git a/apps/watcher/src/config.ts b/apps/watcher/src/config.ts index 9107b94..59b65cf 100644 --- a/apps/watcher/src/config.ts +++ b/apps/watcher/src/config.ts @@ -4,7 +4,7 @@ export const POLL_INTERVAL_MS = 2000; // Check every 2 seconds // Environment validation export function validateEnv(): void { - const required = ["CONVEX_URL", "SQUADHUB_URL", "SQUADHUB_TOKEN"]; + const required = ["CONVEX_URL", "WATCHER_TOKEN"]; const missing = required.filter((key) => !process.env[key]); if (missing.length > 0) { @@ -17,8 +17,6 @@ export function validateEnv(): void { export const config = { convexUrl: process.env.CONVEX_URL || "", - squadhubUrl: process.env.SQUADHUB_URL || "http://localhost:18789", - squadhubToken: process.env.SQUADHUB_TOKEN || "", watcherToken: process.env.WATCHER_TOKEN || "", pollIntervalMs: POLL_INTERVAL_MS, }; diff --git a/apps/watcher/src/index.ts b/apps/watcher/src/index.ts index a1c6b98..022dcdb 100644 --- a/apps/watcher/src/index.ts +++ b/apps/watcher/src/index.ts @@ -7,15 +7,12 @@ * Setup logic (agent registration, cron setup, routine seeding) has been * moved to the provisioning API route (POST /api/tenant/provision). * - * Multi-tenant ready: iterates over active tenants each loop iteration. - * When WATCHER_TOKEN is set, queries Convex for all active tenants. - * Falls back to single-tenant mode using SQUADHUB_URL/SQUADHUB_TOKEN env vars. + * Multi-tenant: iterates over active tenants each loop iteration. + * Queries Convex for all active tenants using WATCHER_TOKEN. * * Environment variables: * CONVEX_URL - Convex deployment URL - * WATCHER_TOKEN - System-level auth token for querying all tenants (optional) - * SQUADHUB_URL - Squadhub gateway URL (single-tenant fallback) - * SQUADHUB_TOKEN - Squadhub authentication token (single-tenant fallback) + * WATCHER_TOKEN - System-level auth token for querying all tenants */ import { ConvexHttpClient } from "convex/browser"; @@ -40,37 +37,22 @@ type TenantInfo = { /** * Get the list of active tenants to service. * - * When WATCHER_TOKEN is set, queries Convex `tenants.listActive` for all - * active tenants with their squadhub connection info. - * - * Falls back to single-tenant mode using SQUADHUB_URL/SQUADHUB_TOKEN env vars. + * Queries Convex `tenants.listActive` for all active tenants + * with their squadhub connection info. */ async function getActiveTenants(): Promise { - if (config.watcherToken) { - const tenants = await convex.query(api.tenants.listActive, { - watcherToken: config.watcherToken, - }); - return tenants.map( - (t: { id: string; squadhubUrl: string; squadhubToken: string }) => ({ - id: t.id, - connection: { - squadhubUrl: t.squadhubUrl, - squadhubToken: t.squadhubToken, - }, - }), - ); - } - - // Fallback: single-tenant mode from env vars - return [ - { - id: "default", + const tenants = await convex.query(api.tenants.listActive, { + watcherToken: config.watcherToken, + }); + return tenants.map( + (t: { id: string; squadhubUrl: string; squadhubToken: string }) => ({ + id: t.id, connection: { - squadhubUrl: config.squadhubUrl, - squadhubToken: config.squadhubToken, + squadhubUrl: t.squadhubUrl, + squadhubToken: t.squadhubToken, }, - }, - ]; + }), + ); } const ROUTINE_CHECK_INTERVAL_MS = 10_000; // Check routines every 10 seconds @@ -83,56 +65,66 @@ function sleep(ms: number): Promise { } /** - * Check for due routines and trigger them. + * Check for due routines and trigger them for a single tenant. * * Uses a 1-hour window for crash tolerance: if a routine is scheduled * for 6:00 AM, it can trigger anytime between 6:00 AM and 6:59 AM. - * + */ +async function checkRoutinesForTenant(machineToken: string): Promise { + // Get tenant's timezone from tenant settings + const timezone = + (await convex.query(api.tenants.getTimezone, { + machineToken, + })) ?? DEFAULT_TIMEZONE; + + // Get current timestamp and time in user's timezone + const now = new Date(); + const currentTimestamp = now.getTime(); + const { dayOfWeek, hour, minute } = getTimeInZone(now, timezone); + + // Query for due routines (with 1-hour window tolerance) + const dueRoutines = await convex.query(api.routines.getDueRoutines, { + machineToken, + currentTimestamp, + dayOfWeek, + hour, + minute, + }); + + // Trigger each due routine + for (const routine of dueRoutines) { + try { + const taskId = await convex.mutation(api.routines.trigger, { + machineToken, + routineId: routine._id, + }); + console.log( + `[watcher] ✓ Triggered routine "${routine.title}" → task ${taskId}`, + ); + } catch (err) { + console.error( + `[watcher] Failed to trigger routine "${routine.title}":`, + err instanceof Error ? err.message : err, + ); + } + } +} + +/** + * Check routines for all active tenants. */ async function checkRoutines(): Promise { - try { - // Get tenant's timezone from tenant settings - const timezone = - (await convex.query(api.tenants.getTimezone, { - machineToken: config.squadhubToken, - })) ?? DEFAULT_TIMEZONE; - - // Get current timestamp and time in user's timezone - const now = new Date(); - const currentTimestamp = now.getTime(); - const { dayOfWeek, hour, minute } = getTimeInZone(now, timezone); - - // Query for due routines (with 1-hour window tolerance) - const dueRoutines = await convex.query(api.routines.getDueRoutines, { - machineToken: config.squadhubToken, - currentTimestamp, - dayOfWeek, - hour, - minute, - }); + const tenants = await getActiveTenants(); - // Trigger each due routine - for (const routine of dueRoutines) { - try { - const taskId = await convex.mutation(api.routines.trigger, { - machineToken: config.squadhubToken, - routineId: routine._id, - }); - console.log( - `[watcher] ✓ Triggered routine "${routine.title}" → task ${taskId}`, - ); - } catch (err) { - console.error( - `[watcher] Failed to trigger routine "${routine.title}":`, - err instanceof Error ? err.message : err, - ); - } + for (const tenant of tenants) { + try { + await checkRoutinesForTenant(tenant.connection.squadhubToken); + } catch (err) { + console.error( + `[watcher] Error checking routines for tenant ${tenant.id}:`, + err instanceof Error ? err.message : err, + ); } - } catch (err) { - console.error( - "[watcher] Error checking routines:", - err instanceof Error ? err.message : err, - ); } } @@ -170,10 +162,12 @@ async function deliverToAgent( connection: SquadhubConnection, sessionKey: string, ): Promise { + const { squadhubToken: machineToken } = connection; + try { // Get undelivered notifications for this agent const notifications = await convex.query(api.notifications.getUndelivered, { - machineToken: config.squadhubToken, + machineToken, sessionKey, }); @@ -196,7 +190,7 @@ async function deliverToAgent( if (result.ok) { // Mark as delivered in Convex await convex.mutation(api.notifications.markDelivered, { - machineToken: config.squadhubToken, + machineToken, notificationIds: [notification._id], }); @@ -288,12 +282,6 @@ async function startDeliveryLoop(): Promise { async function main(): Promise { console.log("[watcher] 🦞 Clawe Watcher starting..."); console.log(`[watcher] Convex: ${config.convexUrl}`); - console.log( - `[watcher] Mode: ${config.watcherToken ? "multi-tenant (WATCHER_TOKEN)" : "single-tenant (env vars)"}`, - ); - if (!config.watcherToken) { - console.log(`[watcher] Squadhub: ${config.squadhubUrl}`); - } console.log(`[watcher] Notification poll interval: ${POLL_INTERVAL_MS}ms`); console.log( `[watcher] Routine check interval: ${ROUTINE_CHECK_INTERVAL_MS}ms\n`, diff --git a/apps/web/package.json b/apps/web/package.json index e32f630..5c7386c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,6 +20,7 @@ "@ai-sdk/openai": "^3.0.26", "@ai-sdk/react": "^3.0.79", "@clawe/backend": "workspace:*", + "@clawe/plugins": "workspace:*", "@clawe/shared": "workspace:*", "@clawe/ui": "workspace:*", "@tanstack/react-query": "^5.75.5", @@ -37,6 +38,7 @@ "@xyflow/react": "^12.10.0", "ai": "^6.0.77", "aws-amplify": "^6.16.2", + "aws-jwt-verify": "^5.1.1", "axios": "^1.13.4", "convex": "^1.21.0", "framer-motion": "^12.29.0", diff --git a/apps/web/src/app/api/tenant/provision/route.ts b/apps/web/src/app/api/tenant/provision/route.ts index 5619b90..091e64c 100644 --- a/apps/web/src/app/api/tenant/provision/route.ts +++ b/apps/web/src/app/api/tenant/provision/route.ts @@ -1,17 +1,28 @@ import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { ConvexHttpClient } from "convex/browser"; +import { api } from "@clawe/backend"; +import { loadPlugins, getPlugin } from "@clawe/plugins"; import { provisionTenant } from "@/lib/squadhub/provision"; -import { getConnection } from "@/lib/squadhub/connection"; /** * POST /api/tenant/provision * - * Runs tenant setup: registers default agents, configures heartbeat crons, - * and seeds initial routines. Idempotent — safe to call multiple times. + * Authenticated route that ensures the current user has a provisioned tenant. + * Idempotent — safe to call multiple times. * - * In Phase 7 this will also handle AWS infrastructure (ECS, EFS, CloudMap). - * For now it only runs the application-level setup. + * Requires an Authorization header with the Convex JWT (works for both + * NextAuth and Cognito — the client auth provider supplies the token). + * + * Flow: + * 1. Read JWT from Authorization header + * 2. Ensure account exists (accounts.getOrCreateForUser) + * 3. Check for existing tenant (tenants.getForCurrentUser) + * 4. If no active tenant: create tenant, provision via plugin, update status + * 5. Run app-level setup (agents, crons, routines) + * 6. Return { ok: true, tenantId } */ -export async function POST() { +export async function POST(request: NextRequest) { const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL; if (!convexUrl) { return NextResponse.json( @@ -20,11 +31,87 @@ export async function POST() { ); } + // 1. Read JWT from Authorization header + const authHeader = request.headers.get("authorization"); + const authToken = authHeader?.startsWith("Bearer ") + ? authHeader.slice(7) + : null; + + if (!authToken) { + return NextResponse.json( + { error: "Missing Authorization header" }, + { status: 401 }, + ); + } + + // Create authenticated Convex client + const convex = new ConvexHttpClient(convexUrl); + convex.setAuth(authToken); + try { - const result = await provisionTenant(getConnection(), convexUrl); + // 2. Ensure account exists + const account = await convex.mutation(api.accounts.getOrCreateForUser, {}); + + // 3. Check for existing tenant + const existingTenant = await convex.query( + api.tenants.getForCurrentUser, + {}, + ); + + if (existingTenant && existingTenant.status === "active") { + // Tenant already provisioned — just re-run app setup below + } else { + // 4. Create tenant + provision via plugin + await loadPlugins(); + const provisioner = getPlugin("provisioner"); + + // Create tenant record (or use existing non-active one) + const tenantIdToProvision = existingTenant + ? existingTenant._id + : await convex.mutation(api.tenants.create, { + accountId: account._id, + }); + + // Provision infrastructure (dev: reads env vars, cloud: creates AWS resources) + const provisionResult = await provisioner.provision({ + tenantId: tenantIdToProvision, + accountId: account._id, + convexUrl, + }); + + // Update tenant with connection details + await convex.mutation(api.tenants.updateStatus, { + tenantId: tenantIdToProvision, + status: "active", + squadhubUrl: provisionResult.squadhubUrl, + squadhubToken: provisionResult.squadhubToken, + ...(provisionResult.metadata?.squadhubServiceArn && { + squadhubServiceArn: provisionResult.metadata.squadhubServiceArn, + }), + ...(provisionResult.metadata?.efsAccessPointId && { + efsAccessPointId: provisionResult.metadata.efsAccessPointId, + }), + }); + } + + // Re-fetch tenant to get latest connection details + const tenant = await convex.query(api.tenants.getForCurrentUser, {}); + + // 5. Run app-level setup (agents, crons, routines) + const connection = { + squadhubUrl: + tenant?.squadhubUrl ?? + process.env.SQUADHUB_URL ?? + "http://localhost:18790", + squadhubToken: tenant?.squadhubToken ?? process.env.SQUADHUB_TOKEN ?? "", + }; + + const result = await provisionTenant(connection, convexUrl, authToken); + // 6. Return result return NextResponse.json({ ok: result.errors.length === 0, + tenantId: tenant?._id, agents: result.agents, crons: result.crons, routines: result.routines, diff --git a/apps/web/src/app/auth/login/page.tsx b/apps/web/src/app/auth/login/page.tsx index 53294d1..028f747 100644 --- a/apps/web/src/app/auth/login/page.tsx +++ b/apps/web/src/app/auth/login/page.tsx @@ -8,12 +8,14 @@ import { api } from "@clawe/backend"; import { Button } from "@clawe/ui/components/button"; import { Spinner } from "@clawe/ui/components/spinner"; import { useAuth } from "@/providers/auth-provider"; +import { useApiClient } from "@/hooks/use-api-client"; const AUTO_LOGIN_EMAIL = process.env.NEXT_PUBLIC_AUTO_LOGIN_EMAIL; export default function LoginPage() { const router = useRouter(); const { isAuthenticated, isLoading, signIn } = useAuth(); + const apiClient = useApiClient(); const getOrCreateUser = useMutation(api.users.getOrCreateFromAuth); const [autoLoginAttempted, setAutoLoginAttempted] = useState(false); @@ -32,15 +34,17 @@ export default function LoginPage() { const ensureUser = async () => { try { await getOrCreateUser(); + // Provision tenant (idempotent — fast for dev, creates tenant if needed) + await apiClient.post("/api/tenant/provision"); } catch { - // User creation may fail if Convex auth isn't ready yet — that's ok, - // the onboarding guard will handle it on the next page load. + // User creation or provisioning may fail if auth isn't ready yet. + // The onboarding guard handles this on the next page load. } router.replace("/"); }; ensureUser(); - }, [isAuthenticated, getOrCreateUser, router]); + }, [isAuthenticated, getOrCreateUser, apiClient, router]); return (
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index c8ddeb9..f6d92d7 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -7,6 +7,7 @@ import { AuthProvider } from "@/providers/auth-provider"; import { ConvexClientProvider } from "@/providers/convex-provider"; import { QueryProvider } from "@/providers/query-provider"; import { ThemeProvider } from "@/providers/theme-provider"; +import { ApiClientProvider } from "@/providers/api-client-provider"; import { Toaster } from "@clawe/ui/components/sonner"; const geistSans = localFont({ @@ -43,17 +44,19 @@ export default function RootLayout({ > - - - {children} - - - + + + + {children} + + + + diff --git a/apps/web/src/hooks/use-api-client.ts b/apps/web/src/hooks/use-api-client.ts new file mode 100644 index 0000000..d21c402 --- /dev/null +++ b/apps/web/src/hooks/use-api-client.ts @@ -0,0 +1,12 @@ +"use client"; + +import { useContext } from "react"; +import { ApiClientContext } from "@/providers/api-client-provider"; + +export const useApiClient = () => { + const context = useContext(ApiClientContext); + if (!context) { + throw new Error("useApiClient must be used within ApiClientProvider"); + } + return context; +}; diff --git a/apps/web/src/hooks/use-chat.spec.ts b/apps/web/src/hooks/use-chat.spec.ts index 8e894de..a9f2735 100644 --- a/apps/web/src/hooks/use-chat.spec.ts +++ b/apps/web/src/hooks/use-chat.spec.ts @@ -1,12 +1,21 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook, act, waitFor } from "@testing-library/react"; import { useChat } from "./use-chat"; -import axios from "axios"; -// Mock axios -vi.mock("axios"); +// Mock useApiClient const mockAxiosGet = vi.fn(); -(axios.get as unknown) = mockAxiosGet; +vi.mock("@/hooks/use-api-client", () => ({ + useApiClient: () => ({ + get: mockAxiosGet, + }), +})); + +// Mock fetchAuthToken +import { fetchAuthToken } from "@/lib/api/client"; +vi.mock("@/lib/api/client", () => ({ + fetchAuthToken: vi.fn(), +})); +const mockFetchAuthToken = vi.mocked(fetchAuthToken); // Mock fetch for streaming (sendMessage still uses fetch) const mockFetch = vi.fn(); @@ -15,6 +24,7 @@ global.fetch = mockFetch; describe("useChat", () => { beforeEach(() => { vi.clearAllMocks(); + mockFetchAuthToken.mockResolvedValue("mock-token"); }); afterEach(() => { @@ -147,6 +157,38 @@ describe("useChat", () => { expect(result.current.messages[0]?.role).toBe("user"); }); }); + + it("includes Authorization header in fetch", async () => { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode("Hello")); + controller.close(); + }, + }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + body: stream, + }); + + const { result } = renderHook(() => + useChat({ sessionKey: "test-session" }), + ); + + await act(async () => { + await result.current.sendMessage("Hello"); + }); + + expect(mockFetch).toHaveBeenCalledWith( + "/api/chat", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer mock-token", + }), + }), + ); + }); }); describe("abort", () => { diff --git a/apps/web/src/hooks/use-chat.ts b/apps/web/src/hooks/use-chat.ts index 7c47377..b63d38b 100644 --- a/apps/web/src/hooks/use-chat.ts +++ b/apps/web/src/hooks/use-chat.ts @@ -1,8 +1,9 @@ "use client"; import { useState, useCallback, useRef } from "react"; -import axios from "axios"; import type { ChatAttachment } from "@/components/chat/types"; +import { useApiClient } from "@/hooks/use-api-client"; +import { fetchAuthToken } from "@/lib/api/client"; export type Message = { id: string; @@ -110,6 +111,7 @@ export const useChat = ({ onError, onFinish, }: UseChatOptions): UseChatReturn => { + const apiClient = useApiClient(); const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [status, setStatus] = useState< @@ -163,9 +165,13 @@ export const useChat = ({ { role: "user" as const, content: trimmed }, ]; + const token = await fetchAuthToken(); const response = await fetch("/api/chat", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, body: JSON.stringify({ sessionKey, messages: apiMessages }), signal: abortRef.current.signal, }); @@ -232,7 +238,7 @@ export const useChat = ({ setStatus("loading"); try { - const response = await axios.get<{ messages?: unknown[] }>( + const response = await apiClient.get<{ messages?: unknown[] }>( `/api/chat/history?sessionKey=${encodeURIComponent(sessionKey)}&limit=50`, ); @@ -260,7 +266,7 @@ export const useChat = ({ console.warn("[chat] Failed to load history:", err); setStatus("idle"); } - }, [sessionKey]); + }, [sessionKey, apiClient]); const abort = useCallback(() => { abortRef.current?.abort(); diff --git a/apps/web/src/hooks/use-squadhub-status.ts b/apps/web/src/hooks/use-squadhub-status.ts index 5303ee4..242e772 100644 --- a/apps/web/src/hooks/use-squadhub-status.ts +++ b/apps/web/src/hooks/use-squadhub-status.ts @@ -1,27 +1,27 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import axios from "axios"; +import { useApiClient } from "@/hooks/use-api-client"; type SquadhubStatus = "active" | "down" | "idle"; -const checkSquadhubHealth = async (): Promise => { - try { - const { data } = await axios.post( - "/api/squadhub/health", - {}, - { timeout: 5000 }, - ); - return data.ok === true; - } catch { - return false; - } -}; - export const useSquadhubStatus = () => { + const apiClient = useApiClient(); + const { data: isHealthy, isLoading } = useQuery({ queryKey: ["squadhub-health"], - queryFn: checkSquadhubHealth, + queryFn: async (): Promise => { + try { + const { data } = await apiClient.post( + "/api/squadhub/health", + {}, + { timeout: 5000 }, + ); + return data.ok === true; + } catch { + return false; + } + }, refetchInterval: 30000, // Check every 30 seconds staleTime: 10000, retry: false, diff --git a/apps/web/src/lib/api/client.ts b/apps/web/src/lib/api/client.ts new file mode 100644 index 0000000..5c91c89 --- /dev/null +++ b/apps/web/src/lib/api/client.ts @@ -0,0 +1,39 @@ +import axios from "axios"; +import { fetchAuthSession } from "aws-amplify/auth"; + +const AUTH_PROVIDER = process.env.NEXT_PUBLIC_AUTH_PROVIDER ?? "nextauth"; + +export async function fetchAuthToken(): Promise { + if (AUTH_PROVIDER === "cognito") { + try { + const session = await fetchAuthSession(); + return session.tokens?.idToken?.toString() ?? null; + } catch { + return null; + } + } + + // NextAuth: JWT is in HttpOnly cookie, fetch via server endpoint + try { + const res = await fetch("/api/auth/token"); + if (!res.ok) return null; + const data = await res.json(); + return data.token ?? null; + } catch { + return null; + } +} + +export function createApiClient() { + const instance = axios.create(); + + instance.interceptors.request.use(async (config) => { + const token = await fetchAuthToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }); + + return instance; +} diff --git a/apps/web/src/lib/auth/verify-token.ts b/apps/web/src/lib/auth/verify-token.ts new file mode 100644 index 0000000..bd8b0a7 --- /dev/null +++ b/apps/web/src/lib/auth/verify-token.ts @@ -0,0 +1,101 @@ +import { CognitoJwtVerifier } from "aws-jwt-verify"; +import type { JWTPayload } from "jose"; + +const AUTH_PROVIDER = process.env.NEXT_PUBLIC_AUTH_PROVIDER ?? "nextauth"; + +export interface VerifiedToken extends JWTPayload { + sub: string; + email?: string; +} + +// --------------------------------------------------------------------------- +// Cognito: use the official AWS verifier (handles JWKS caching, kid rotation, +// token_use / client_id validation, and Cognito-specific claim checks). +// --------------------------------------------------------------------------- + +let cognitoVerifier: ReturnType; + +function getCognitoVerifier() { + if (!cognitoVerifier) { + const userPoolId = process.env.NEXT_PUBLIC_COGNITO_USER_POOL_ID; + const clientId = process.env.NEXT_PUBLIC_COGNITO_CLIENT_ID; + if (!userPoolId || !clientId) { + throw new Error( + "NEXT_PUBLIC_COGNITO_USER_POOL_ID and NEXT_PUBLIC_COGNITO_CLIENT_ID are required", + ); + } + + cognitoVerifier = CognitoJwtVerifier.create({ + userPoolId, + tokenUse: "id", + clientId, + }); + } + return cognitoVerifier; +} + +async function verifyCognitoToken( + token: string, +): Promise { + try { + const payload = await getCognitoVerifier().verify(token); + if (!payload.sub) return null; + return payload as VerifiedToken; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// NextAuth: verify with jose against the local JWKS bundled in @clawe/backend. +// Dynamic imports keep jose + dev JWKS out of the cloud bundle. +// --------------------------------------------------------------------------- + +let nextAuthVerify: typeof import("jose").jwtVerify; +let nextAuthKeySet: ReturnType; + +async function verifyNextAuthToken( + token: string, +): Promise { + try { + if (!nextAuthVerify) { + const jose = await import("jose"); + const jwks = (await import("@clawe/backend/dev-jwks/jwks.json")).default; + nextAuthVerify = jose.jwtVerify; + nextAuthKeySet = jose.createLocalJWKSet(jwks); + } + + const { payload } = await nextAuthVerify(token, nextAuthKeySet, { + issuer: process.env.NEXTAUTH_URL ?? "http://localhost:3000", + audience: "convex", + }); + + if (!payload.sub) return null; + return payload as VerifiedToken; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Verify and decode a JWT. + * + * - NextAuth: verifies against the local JWKS (RS256, dev keys) using `jose` + * - Cognito: verifies using `aws-jwt-verify` (JWKS caching, kid rotation, + * token_use / client_id validation) + * + * Returns the decoded payload on success, or `null` on any failure + * (expired, bad signature, wrong issuer/audience, malformed, etc.). + */ +export async function verifyToken( + token: string, +): Promise { + if (AUTH_PROVIDER === "cognito") { + return verifyCognitoToken(token); + } + return verifyNextAuthToken(token); +} diff --git a/apps/web/src/lib/squadhub/provision.ts b/apps/web/src/lib/squadhub/provision.ts index 2b19c3c..943ef54 100644 --- a/apps/web/src/lib/squadhub/provision.ts +++ b/apps/web/src/lib/squadhub/provision.ts @@ -216,8 +216,12 @@ async function seedRoutines(convex: ConvexHttpClient): Promise<{ export async function provisionTenant( connection: SquadhubConnection, convexUrl: string, + authToken?: string, ): Promise { const convex = new ConvexHttpClient(convexUrl); + if (authToken) { + convex.setAuth(authToken); + } const allErrors: string[] = []; // Check squadhub is reachable diff --git a/apps/web/src/providers/api-client-provider.tsx b/apps/web/src/providers/api-client-provider.tsx new file mode 100644 index 0000000..8824fa4 --- /dev/null +++ b/apps/web/src/providers/api-client-provider.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { createContext, useMemo } from "react"; +import type { AxiosInstance } from "axios"; +import type { ReactNode } from "react"; +import { createApiClient } from "@/lib/api/client"; + +export const ApiClientContext = createContext(null); + +export const ApiClientProvider = ({ children }: { children: ReactNode }) => { + const apiClient = useMemo(() => createApiClient(), []); + return ( + + {children} + + ); +}; diff --git a/apps/web/src/providers/auth-provider.tsx b/apps/web/src/providers/auth-provider.tsx index e5eb227..61d104b 100644 --- a/apps/web/src/providers/auth-provider.tsx +++ b/apps/web/src/providers/auth-provider.tsx @@ -9,6 +9,15 @@ import { useState, } from "react"; import type { ReactNode } from "react"; +import { Amplify } from "aws-amplify"; +import { + getCurrentUser, + fetchUserAttributes, + signInWithRedirect, + signOut as amplifySignOut, +} from "aws-amplify/auth"; +import { Hub } from "aws-amplify/utils"; +import { fetchAuthToken } from "@/lib/api/client"; const AUTH_PROVIDER = process.env.NEXT_PUBLIC_AUTH_PROVIDER ?? "nextauth"; @@ -115,8 +124,6 @@ const CognitoProvider = ({ children }: { children: ReactNode }) => { const checkAuthState = useCallback(async () => { try { - const { getCurrentUser, fetchUserAttributes } = - await import("aws-amplify/auth"); await getCurrentUser(); const attributes = await fetchUserAttributes(); setUser({ @@ -133,54 +140,46 @@ const CognitoProvider = ({ children }: { children: ReactNode }) => { }, []); useEffect(() => { - const configure = async () => { - const { Amplify } = await import("aws-amplify"); - Amplify.configure({ - Auth: { - Cognito: { - userPoolId: process.env.NEXT_PUBLIC_COGNITO_USER_POOL_ID!, - userPoolClientId: process.env.NEXT_PUBLIC_COGNITO_CLIENT_ID!, - loginWith: { - oauth: { - domain: process.env.NEXT_PUBLIC_COGNITO_DOMAIN!, - scopes: ["openid", "email", "profile"], - redirectSignIn: [window.location.origin], - redirectSignOut: [window.location.origin], - responseType: "code", - }, + Amplify.configure({ + Auth: { + Cognito: { + userPoolId: process.env.NEXT_PUBLIC_COGNITO_USER_POOL_ID!, + userPoolClientId: process.env.NEXT_PUBLIC_COGNITO_CLIENT_ID!, + loginWith: { + oauth: { + domain: process.env.NEXT_PUBLIC_COGNITO_DOMAIN!, + scopes: ["openid", "email", "profile"], + redirectSignIn: [window.location.origin], + redirectSignOut: [window.location.origin], + responseType: "code", }, }, }, - }); - - const { Hub } = await import("aws-amplify/utils"); - Hub.listen("auth", ({ payload }) => { - switch (payload.event) { - case "signedIn": - case "signInWithRedirect": - checkAuthState(); - break; - case "signedOut": - setUser(null); - setIsAuthenticated(false); - setIsLoading(false); - break; - } - }); - - checkAuthState(); - }; + }, + }); + + Hub.listen("auth", ({ payload }) => { + switch (payload.event) { + case "signedIn": + case "signInWithRedirect": + checkAuthState(); + break; + case "signedOut": + setUser(null); + setIsAuthenticated(false); + setIsLoading(false); + break; + } + }); - configure(); + checkAuthState(); }, [checkAuthState]); const signIn = useCallback(async () => { - const { signInWithRedirect } = await import("aws-amplify/auth"); await signInWithRedirect({ provider: "Google" }); }, []); const signOut = useCallback(async () => { - const { signOut: amplifySignOut } = await import("aws-amplify/auth"); await amplifySignOut(); setUser(null); setIsAuthenticated(false); @@ -220,30 +219,12 @@ export const useAuth = () => { export const useConvexAuth = () => { const { isLoading, isAuthenticated } = useAuth(); - const fetchAccessToken = useCallback( - async ({ - forceRefreshToken, - }: { - forceRefreshToken: boolean; - }): Promise => { - if (!isAuthenticated) return null; - - if (AUTH_PROVIDER === "cognito") { - const { fetchAuthSession } = await import("aws-amplify/auth"); - const session = await fetchAuthSession({ - forceRefresh: forceRefreshToken, - }); - return session.tokens?.idToken?.toString() ?? null; - } - - // NextAuth: fetch the JWT via server endpoint (cookie is HttpOnly). - const res = await fetch("/api/auth/token"); - if (!res.ok) return null; - const data = await res.json(); - return data.token ?? null; - }, - [isAuthenticated], - ); + const fetchAccessToken: (args: { + forceRefreshToken: boolean; + }) => Promise = useCallback(async () => { + if (!isAuthenticated) return null; + return fetchAuthToken(); + }, [isAuthenticated]); return useMemo( () => ({ isLoading, isAuthenticated, fetchAccessToken }), diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index 4aa105a..3595549 100644 --- a/apps/web/src/proxy.ts +++ b/apps/web/src/proxy.ts @@ -1,23 +1,58 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; +import { verifyToken } from "@/lib/auth/verify-token"; + +const AUTH_PROVIDER = process.env.NEXT_PUBLIC_AUTH_PROVIDER ?? "nextauth"; const PUBLIC_PATHS = ["/auth/login", "/api/auth", "/api/health"]; -export function proxy(request: NextRequest) { +function extractToken(request: NextRequest): string | null { + if (AUTH_PROVIDER === "nextauth") { + const cookie = + request.cookies.get("authjs.session-token") ?? + request.cookies.get("__Secure-authjs.session-token"); + return cookie?.value ?? null; + } + + // Cognito: token is in the Authorization header (API routes only). + const header = request.headers.get("authorization"); + if (!header?.startsWith("Bearer ")) return null; + return header.slice(7); +} + +function unauthorized(message: string) { + return new NextResponse(JSON.stringify({ error: message }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); +} + +export async function proxy(request: NextRequest) { const { pathname } = request.nextUrl; - // Allow public paths if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) { return NextResponse.next(); } - // Check for NextAuth session cookie - const sessionCookie = - request.cookies.get("authjs.session-token") ?? - request.cookies.get("__Secure-authjs.session-token"); + // Cognito page navigations carry no token — client-side useAuth guards those. + const isApiRoute = pathname.startsWith("/api/"); + if (AUTH_PROVIDER === "cognito" && !isApiRoute) { + return NextResponse.next(); + } + + const token = extractToken(request); + + if (!token) { + return isApiRoute + ? unauthorized("Unauthorized") + : NextResponse.redirect(new URL("/auth/login", request.url)); + } - if (!sessionCookie) { - return NextResponse.redirect(new URL("/auth/login", request.url)); + const payload = await verifyToken(token); + if (!payload) { + return isApiRoute + ? unauthorized("Invalid token") + : NextResponse.redirect(new URL("/auth/login", request.url)); } return NextResponse.next(); diff --git a/docker/squadhub/entrypoint.sh b/docker/squadhub/entrypoint.sh index 13ab518..0e01c04 100644 --- a/docker/squadhub/entrypoint.sh +++ b/docker/squadhub/entrypoint.sh @@ -53,11 +53,13 @@ else echo "==> Config exists. Skipping initialization." fi -# Ensure the local CLI device is paired with the gateway. -# On container recreation the CLI generates a new keypair, but the old -# paired.json from the volume is stale. Re-register every startup. +# Pre-pair: write paired.json from identity BEFORE gateway starts. +# This ensures the local CLI device is recognized on boot. node /opt/clawe/scripts/pair-device.js +# Background: watch for new pending requests every 60s. +node /opt/clawe/scripts/pair-device.js --watch & + echo "==> Starting OpenClaw gateway on port $PORT..." exec openclaw gateway run \ diff --git a/docker/squadhub/scripts/pair-device.js b/docker/squadhub/scripts/pair-device.js index 130657e..acd0c7a 100644 --- a/docker/squadhub/scripts/pair-device.js +++ b/docker/squadhub/scripts/pair-device.js @@ -1,14 +1,17 @@ #!/usr/bin/env node /** - * Auto-pair the local CLI device with the gateway. + * Auto-approve device pairing requests. * - * When the Docker container is recreated, the openclaw CLI generates a new - * keypair (identity/device.json) but the volume still has the old - * devices/paired.json. The gateway then rejects all connections with - * "pairing required". This script reads the current device identity and - * registers it as a paired operator device — the automated equivalent of - * clicking "approve" in the openclaw Control UI. + * Two modes: + * 1. SYNC (pre-start): Reads identity/device.json and writes paired.json + * before the gateway starts. This ensures the local CLI is pre-paired. + * 2. BACKGROUND (--watch): Polls pending.json every 60s and moves entries + * to paired.json. Runs alongside the gateway for new connections. + * + * Usage: + * node pair-device.js # Sync mode (run before gateway) + * node pair-device.js --watch # Background loop (run after gateway) */ const fs = require("fs"); @@ -18,51 +21,169 @@ const path = require("path"); const stateDir = process.env.OPENCLAW_STATE_DIR || "/data/config"; const identityFile = path.join(stateDir, "identity", "device.json"); const devicesDir = path.join(stateDir, "devices"); +const pendingFile = path.join(devicesDir, "pending.json"); const pairedFile = path.join(devicesDir, "paired.json"); -if (!fs.existsSync(identityFile)) { - console.log("==> No device identity found, skipping device registration."); - process.exit(0); +const INTERVAL_MS = 60_000; + +function readJson(filePath) { + try { + if (!fs.existsSync(filePath)) return {}; + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return {}; + } } -const identity = JSON.parse(fs.readFileSync(identityFile, "utf8")); - -// Extract the raw Ed25519 public key from the SPKI-encoded PEM (skip 12-byte header) -const spki = crypto - .createPublicKey(identity.publicKeyPem) - .export({ type: "spki", format: "der" }); -const publicKey = spki.subarray(12).toString("base64url"); - -const now = Date.now(); -const token = crypto.randomBytes(16).toString("hex"); - -const entry = { - deviceId: identity.deviceId, - publicKey, - displayName: "agent", - platform: "linux", - clientId: "gateway-client", - clientMode: "backend", - role: "operator", - roles: ["operator"], - scopes: ["operator.admin", "operator.approvals", "operator.pairing"], - tokens: { - operator: { - token, - role: "operator", - scopes: ["operator.admin", "operator.approvals", "operator.pairing"], - createdAtMs: now, - lastUsedAtMs: now, +function writeJson(filePath, data) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); +} + +function createPairedEntry(deviceId, publicKey, meta) { + const now = Date.now(); + return { + deviceId, + publicKey, + displayName: meta.clientId || "agent", + platform: meta.platform || "linux", + clientId: meta.clientId || "cli", + clientMode: meta.clientMode || "cli", + role: meta.role || "operator", + roles: meta.roles || ["operator"], + scopes: meta.scopes || [ + "operator.admin", + "operator.approvals", + "operator.pairing", + ], + tokens: { + operator: { + token: crypto.randomBytes(16).toString("hex"), + role: "operator", + scopes: meta.scopes || [ + "operator.admin", + "operator.approvals", + "operator.pairing", + ], + createdAtMs: now, + lastUsedAtMs: now, + }, }, - }, - createdAtMs: now, - approvedAtMs: now, -}; - -fs.mkdirSync(devicesDir, { recursive: true }); -fs.writeFileSync( - pairedFile, - JSON.stringify({ [identity.deviceId]: entry }, null, 2), -); - -console.log(`==> Device ${identity.deviceId.substring(0, 12)}... registered.`); + createdAtMs: meta.ts || now, + approvedAtMs: now, + }; +} + +/** + * Sync mode: pre-pair the local device from identity file. + * Runs before the gateway starts so it reads paired.json on boot. + */ +function pairFromIdentity() { + if (!fs.existsSync(identityFile)) { + console.log("[pair-device] No identity file, skipping pre-pair."); + return; + } + + const identity = JSON.parse(fs.readFileSync(identityFile, "utf8")); + + // Extract Ed25519 public key from SPKI PEM + const spki = crypto + .createPublicKey(identity.publicKeyPem) + .export({ type: "spki", format: "der" }); + const publicKey = spki.subarray(12).toString("base64url"); + + const paired = readJson(pairedFile); + + // Already paired — skip + if (paired[identity.deviceId]) { + console.log( + `[pair-device] Device ${identity.deviceId.substring(0, 12)}... already paired.`, + ); + return; + } + + paired[identity.deviceId] = createPairedEntry(identity.deviceId, publicKey, { + clientId: "gateway-client", + clientMode: "backend", + }); + + writeJson(pairedFile, paired); + console.log( + `[pair-device] ✓ Pre-paired device ${identity.deviceId.substring(0, 12)}...`, + ); +} + +/** + * Watch mode: approve any pending requests by moving them to paired.json. + */ +function approvePending() { + const pending = readJson(pendingFile); + const entries = Object.entries(pending); + + if (entries.length === 0) return; + + console.log( + `[pair-device] Found ${entries.length} pending request(s), approving...`, + ); + + const paired = readJson(pairedFile); + let approved = 0; + + for (const [requestId, entry] of entries) { + if (!entry.deviceId || !entry.publicKey) continue; + + paired[entry.deviceId] = createPairedEntry( + entry.deviceId, + entry.publicKey, + entry, + ); + + delete pending[requestId]; + approved++; + + console.log( + `[pair-device] ✓ Approved ${entry.deviceId.substring(0, 12)}...`, + ); + } + + if (approved > 0) { + writeJson(pairedFile, paired); + writeJson(pendingFile, pending); + } +} + +// --- Main --- + +const watchMode = process.argv.includes("--watch"); + +if (!watchMode) { + // Sync mode: pre-pair from identity, approve any stale pending + pairFromIdentity(); + approvePending(); +} else { + // Watch mode: poll pending.json every 60s + const { execSync } = require("child_process"); + + // Wait for gateway health + const waitForGateway = (cb) => { + const check = () => { + try { + execSync("wget -q --spider http://localhost:18789/health 2>/dev/null", { + stdio: "pipe", + }); + cb(); + } catch { + setTimeout(check, 2000); + } + }; + check(); + }; + + waitForGateway(() => { + console.log( + "[pair-device] Gateway ready, watching for pending requests (60s)", + ); + approvePending(); + setInterval(approvePending, INTERVAL_MS); + }); +} diff --git a/packages/backend/package.json b/packages/backend/package.json index bd5bca6..224396c 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -24,7 +24,7 @@ "./dev-jwks/private.pem": "./convex/dev-jwks/private.pem" }, "scripts": { - "dev": "convex dev", + "dev": "sh -c '../../scripts/sync-convex-env.sh & convex dev'", "debug": "convex dev", "deploy": "convex deploy", "check-types": "tsc --noEmit" diff --git a/packages/plugins/eslint.config.mjs b/packages/plugins/eslint.config.mjs new file mode 100644 index 0000000..d1a046d --- /dev/null +++ b/packages/plugins/eslint.config.mjs @@ -0,0 +1,4 @@ +import { config } from "@clawe/eslint-config/base"; + +/** @type {import("eslint").Linter.Config} */ +export default config; diff --git a/packages/plugins/package.json b/packages/plugins/package.json new file mode 100644 index 0000000..74a9926 --- /dev/null +++ b/packages/plugins/package.json @@ -0,0 +1,29 @@ +{ + "name": "@clawe/plugins", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "lint": "eslint . --max-warnings 0", + "lint:fix": "eslint . --fix --max-warnings 0", + "check-types": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@clawe/eslint-config": "workspace:*", + "@clawe/typescript-config": "workspace:*", + "@types/node": "^22.15.3", + "eslint": "^9.39.1", + "typescript": "5.9.2", + "vitest": "^4.0.18" + } +} diff --git a/packages/plugins/src/cloud-plugins.d.ts b/packages/plugins/src/cloud-plugins.d.ts new file mode 100644 index 0000000..a8ff0b5 --- /dev/null +++ b/packages/plugins/src/cloud-plugins.d.ts @@ -0,0 +1,9 @@ +/** + * Type declaration for the optional external plugin package. + * Dynamically imported by loadPlugins(). If not installed, dev defaults are used. + */ +declare module "@clawe/cloud-plugins" { + import type { PluginMap } from "./registry"; + + export function register(): PluginMap; +} diff --git a/packages/plugins/src/defaults/index.ts b/packages/plugins/src/defaults/index.ts new file mode 100644 index 0000000..3e6d452 --- /dev/null +++ b/packages/plugins/src/defaults/index.ts @@ -0,0 +1,2 @@ +export { DevProvisioner } from "./provisioner"; +export { DevLifecycle } from "./lifecycle"; diff --git a/packages/plugins/src/defaults/lifecycle.ts b/packages/plugins/src/defaults/lifecycle.ts new file mode 100644 index 0000000..a54708b --- /dev/null +++ b/packages/plugins/src/defaults/lifecycle.ts @@ -0,0 +1,27 @@ +import type { + SquadhubLifecycle, + SquadhubStatus, +} from "../interfaces/lifecycle"; + +/** + * Dev/self-hosted lifecycle manager. + * All operations are no-ops — user manages squadhub via docker compose. + * Always reports healthy. + */ +export class DevLifecycle implements SquadhubLifecycle { + async restart(): Promise { + // No-op — user manually restarts docker. + } + + async stop(): Promise { + // No-op. + } + + async destroy(): Promise { + // No-op. + } + + async getStatus(): Promise { + return { running: true, healthy: true }; + } +} diff --git a/packages/plugins/src/defaults/provisioner.ts b/packages/plugins/src/defaults/provisioner.ts new file mode 100644 index 0000000..9f59579 --- /dev/null +++ b/packages/plugins/src/defaults/provisioner.ts @@ -0,0 +1,27 @@ +import type { + TenantProvisioner, + ProvisionResult, + ProvisioningStatus, +} from "../interfaces/provisioner"; + +/** + * Dev/self-hosted provisioner. + * Reads SQUADHUB_URL and SQUADHUB_TOKEN from environment variables. + * Returns immediately — no infrastructure to create. + */ +export class DevProvisioner implements TenantProvisioner { + async provision(): Promise { + return { + squadhubUrl: process.env.SQUADHUB_URL ?? "http://localhost:18790", + squadhubToken: process.env.SQUADHUB_TOKEN ?? "", + }; + } + + async getProvisioningStatus(): Promise { + return { status: "active" }; + } + + async deprovision(): Promise { + // No-op in dev — user manages squadhub via docker compose. + } +} diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts new file mode 100644 index 0000000..edfc4b5 --- /dev/null +++ b/packages/plugins/src/index.ts @@ -0,0 +1,17 @@ +// Registry +export { loadPlugins, hasPlugin, getPlugin } from "./registry"; +export type { PluginMap } from "./registry"; + +// Interfaces +export type { + TenantProvisioner, + ProvisionParams, + ProvisionResult, + ProvisioningStatus, + SquadhubLifecycle, + SquadhubStatus, +} from "./interfaces"; + +// Dev defaults (for testing and direct use) +export { DevProvisioner } from "./defaults/provisioner"; +export { DevLifecycle } from "./defaults/lifecycle"; diff --git a/packages/plugins/src/interfaces/index.ts b/packages/plugins/src/interfaces/index.ts new file mode 100644 index 0000000..fd00872 --- /dev/null +++ b/packages/plugins/src/interfaces/index.ts @@ -0,0 +1,8 @@ +export type { + TenantProvisioner, + ProvisionParams, + ProvisionResult, + ProvisioningStatus, +} from "./provisioner"; + +export type { SquadhubLifecycle, SquadhubStatus } from "./lifecycle"; diff --git a/packages/plugins/src/interfaces/lifecycle.ts b/packages/plugins/src/interfaces/lifecycle.ts new file mode 100644 index 0000000..79b57ac --- /dev/null +++ b/packages/plugins/src/interfaces/lifecycle.ts @@ -0,0 +1,18 @@ +export interface SquadhubStatus { + running: boolean; + healthy: boolean; +} + +export interface SquadhubLifecycle { + /** Restart the tenant's squadhub service (e.g. after config change). */ + restart(tenantId: string): Promise; + + /** Stop the tenant's squadhub service. */ + stop(tenantId: string): Promise; + + /** Destroy tenant's squadhub resources permanently. */ + destroy(tenantId: string): Promise; + + /** Check health/status of the tenant's squadhub. */ + getStatus(tenantId: string): Promise; +} diff --git a/packages/plugins/src/interfaces/provisioner.ts b/packages/plugins/src/interfaces/provisioner.ts new file mode 100644 index 0000000..0fbb553 --- /dev/null +++ b/packages/plugins/src/interfaces/provisioner.ts @@ -0,0 +1,29 @@ +export interface ProvisionParams { + tenantId: string; + accountId: string; + anthropicApiKey?: string; + convexUrl: string; +} + +export interface ProvisionResult { + squadhubUrl: string; + squadhubToken: string; + /** Plugin-specific metadata. */ + metadata?: Record; +} + +export interface ProvisioningStatus { + status: "provisioning" | "active" | "error"; + message?: string; +} + +export interface TenantProvisioner { + /** Create infrastructure for a new tenant and return connection details. */ + provision(params: ProvisionParams): Promise; + + /** Check provisioning progress (for polling UI). */ + getProvisioningStatus(tenantId: string): Promise; + + /** Tear down all infrastructure for a tenant. */ + deprovision(tenantId: string): Promise; +} diff --git a/packages/plugins/src/registry.spec.ts b/packages/plugins/src/registry.spec.ts new file mode 100644 index 0000000..d083e40 --- /dev/null +++ b/packages/plugins/src/registry.spec.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { loadPlugins, hasPlugin, getPlugin } from "./registry"; +import { DevProvisioner } from "./defaults/provisioner"; +import { DevLifecycle } from "./defaults/lifecycle"; + +describe("registry", () => { + describe("loadPlugins", () => { + it("falls back to dev defaults when cloud-plugins is not installed", async () => { + await loadPlugins(); + expect(hasPlugin()).toBe(false); + }); + }); + + describe("getPlugin", () => { + it("returns dev provisioner by default", () => { + const provisioner = getPlugin("provisioner"); + expect(provisioner).toBeInstanceOf(DevProvisioner); + }); + + it("returns dev lifecycle by default", () => { + const lifecycle = getPlugin("lifecycle"); + expect(lifecycle).toBeInstanceOf(DevLifecycle); + }); + }); +}); + +describe("DevProvisioner", () => { + let provisioner: DevProvisioner; + + beforeEach(() => { + provisioner = new DevProvisioner(); + }); + + it("provision returns env-based connection", async () => { + const result = await provisioner.provision({ + tenantId: "test-tenant", + accountId: "test-account", + convexUrl: "https://convex.example.com", + }); + + expect(result).toHaveProperty("squadhubUrl"); + expect(result).toHaveProperty("squadhubToken"); + }); + + it("getProvisioningStatus always returns active", async () => { + const status = await provisioner.getProvisioningStatus("test-tenant"); + expect(status).toEqual({ status: "active" }); + }); + + it("deprovision is a no-op", async () => { + await expect( + provisioner.deprovision("test-tenant"), + ).resolves.toBeUndefined(); + }); +}); + +describe("DevLifecycle", () => { + let lifecycle: DevLifecycle; + + beforeEach(() => { + lifecycle = new DevLifecycle(); + }); + + it("restart is a no-op", async () => { + await expect(lifecycle.restart("test-tenant")).resolves.toBeUndefined(); + }); + + it("stop is a no-op", async () => { + await expect(lifecycle.stop("test-tenant")).resolves.toBeUndefined(); + }); + + it("destroy is a no-op", async () => { + await expect(lifecycle.destroy("test-tenant")).resolves.toBeUndefined(); + }); + + it("getStatus returns running and healthy", async () => { + const status = await lifecycle.getStatus("test-tenant"); + expect(status).toEqual({ running: true, healthy: true }); + }); +}); diff --git a/packages/plugins/src/registry.ts b/packages/plugins/src/registry.ts new file mode 100644 index 0000000..5c8dd93 --- /dev/null +++ b/packages/plugins/src/registry.ts @@ -0,0 +1,43 @@ +import type { TenantProvisioner } from "./interfaces/provisioner"; +import type { SquadhubLifecycle } from "./interfaces/lifecycle"; +import { DevProvisioner } from "./defaults/provisioner"; +import { DevLifecycle } from "./defaults/lifecycle"; + +export interface PluginMap { + provisioner: TenantProvisioner; + lifecycle: SquadhubLifecycle; +} + +let plugins: PluginMap = { + provisioner: new DevProvisioner(), + lifecycle: new DevLifecycle(), +}; + +let pluginsLoaded = false; + +/** + * Initialize plugins. Call once at app startup. + * Attempts to load an external plugin package. + * If not available, keeps the dev defaults. + */ +export async function loadPlugins(): Promise { + if (pluginsLoaded) return; + + try { + const external = await import("@clawe/cloud-plugins"); + plugins = external.register(); + pluginsLoaded = true; + } catch { + // No external plugins installed — using dev defaults. + } +} + +/** Returns true if external plugins are loaded (vs dev defaults). */ +export function hasPlugin(): boolean { + return pluginsLoaded; +} + +/** Get a plugin implementation. Always returns something (external or dev default). */ +export function getPlugin(name: K): PluginMap[K] { + return plugins[name]; +} diff --git a/packages/plugins/tsconfig.json b/packages/plugins/tsconfig.json new file mode 100644 index 0000000..5bb6f44 --- /dev/null +++ b/packages/plugins/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@clawe/typescript-config/base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["node"], + "rootDir": "./src", + "outDir": "./dist", + "declaration": true, + "declarationMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "**/*.spec.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28cd7f1..1049082 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,6 +63,9 @@ importers: '@clawe/backend': specifier: workspace:* version: link:../../packages/backend + '@clawe/plugins': + specifier: workspace:* + version: link:../../packages/plugins '@clawe/shared': specifier: workspace:* version: link:../../packages/shared @@ -114,6 +117,9 @@ importers: aws-amplify: specifier: ^6.16.2 version: 6.16.2 + aws-jwt-verify: + specifier: ^5.1.1 + version: 5.1.1 axios: specifier: ^1.13.4 version: 1.13.4 @@ -283,6 +289,27 @@ importers: specifier: ^8.50.0 version: 8.50.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.2) + packages/plugins: + devDependencies: + '@clawe/eslint-config': + specifier: workspace:* + version: link:../eslint-config + '@clawe/typescript-config': + specifier: workspace:* + version: link:../typescript-config + '@types/node': + specifier: ^22.15.3 + version: 22.15.3 + eslint: + specifier: ^9.39.1 + version: 9.39.1(jiti@2.6.1) + typescript: + specifier: 5.9.2 + version: 5.9.2 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.15.3)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2)(tsx@4.21.0) + packages/shared: dependencies: axios: @@ -3006,6 +3033,10 @@ packages: aws-amplify@6.16.2: resolution: {integrity: sha512-7CHwfH5QxZ0rzCws/DNy5VLVcIIZWd9iUTtV1Oj6kPzpkFhCJ2I8gTvhFdh61HLhrg2lShcPQ8cecBIQS/ZJ0A==} + aws-jwt-verify@5.1.1: + resolution: {integrity: sha512-j6whGdGJmQ27agk4ijY8RPv6itb8JLb7SCJ86fEnneTcSBrpxuwL8kLq6y5WVH95aIknyAloEqAsaOLS1J8ITQ==} + engines: {node: '>=18.0.0'} + axios@1.13.4: resolution: {integrity: sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==} @@ -3889,6 +3920,7 @@ packages: js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true jsdom@28.0.0: resolution: {integrity: sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==} @@ -8417,6 +8449,8 @@ snapshots: - '@aws-amplify/react-native' - aws-crt + aws-jwt-verify@5.1.1: {} + axios@1.13.4: dependencies: follow-redirects: 1.15.11 diff --git a/scripts/convex-deploy.sh b/scripts/convex-deploy.sh index 2377a0a..621a776 100755 --- a/scripts/convex-deploy.sh +++ b/scripts/convex-deploy.sh @@ -35,6 +35,11 @@ else pnpm --filter @clawe/backend exec convex env set NEXTAUTH_JWKS_URL "$NEXTAUTH_JWKS_URL" fi +# Set watcher token in Convex +if [ -n "$WATCHER_TOKEN" ]; then + pnpm --filter @clawe/backend exec convex env set WATCHER_TOKEN "$WATCHER_TOKEN" +fi + # Deploy Convex functions and schema pnpm --filter @clawe/backend deploy diff --git a/scripts/sync-convex-env.sh b/scripts/sync-convex-env.sh new file mode 100755 index 0000000..b24d39c --- /dev/null +++ b/scripts/sync-convex-env.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Sync environment variables to the local Convex backend on dev startup. +# Called as a background process from the backend's dev script. +# Waits for the local Convex backend to be ready, then sets env vars. + +CONVEX_LOCAL="http://127.0.0.1:3210" + +# Wait for local Convex backend +until curl -s "$CONVEX_LOCAL" >/dev/null 2>&1; do + sleep 1 +done + +AUTH_PROVIDER="${AUTH_PROVIDER:-nextauth}" + +if [ "$AUTH_PROVIDER" = "cognito" ]; then + [ -n "$COGNITO_ISSUER_URL" ] && convex env set COGNITO_ISSUER_URL "$COGNITO_ISSUER_URL" + [ -n "$COGNITO_CLIENT_ID" ] && convex env set COGNITO_CLIENT_ID "$COGNITO_CLIENT_ID" +else + # NextAuth: NEXTAUTH_ISSUER_URL comes from NEXTAUTH_URL + [ -n "$NEXTAUTH_URL" ] && convex env set NEXTAUTH_ISSUER_URL "$NEXTAUTH_URL" + + # NEXTAUTH_JWKS_URL: build data URI from dev-jwks/jwks.json if not already set + if [ -z "$NEXTAUTH_JWKS_URL" ]; then + JWKS_FILE="$(dirname "$0")/../packages/backend/convex/dev-jwks/jwks.json" + if [ -f "$JWKS_FILE" ]; then + JWKS_ENCODED=$(python3 -c "import urllib.parse, sys; print('data:application/json,' + urllib.parse.quote(sys.stdin.read().strip()))" < "$JWKS_FILE") + convex env set NEXTAUTH_JWKS_URL "$JWKS_ENCODED" + fi + else + convex env set NEXTAUTH_JWKS_URL "$NEXTAUTH_JWKS_URL" + fi +fi + +[ -n "$WATCHER_TOKEN" ] && convex env set WATCHER_TOKEN "$WATCHER_TOKEN" + +echo "[sync-convex-env] Convex env vars synced" diff --git a/turbo.json b/turbo.json index 3f88052..df418ac 100644 --- a/turbo.json +++ b/turbo.json @@ -15,7 +15,8 @@ "NEXTAUTH_URL", "AUTO_LOGIN_EMAIL", "GOOGLE_CLIENT_ID", - "GOOGLE_CLIENT_SECRET" + "GOOGLE_CLIENT_SECRET", + "WATCHER_TOKEN" ], "tasks": { "build": { From e91c6c577cb77672cc4d936fa9d45769dfe6d9df Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Tue, 17 Feb 2026 12:04:34 +0200 Subject: [PATCH 6/8] fix: refactor codebase --- .../web/src/app/api/tenant/provision/route.ts | 30 ++++-- apps/web/src/app/auth/login/page.tsx | 10 +- apps/web/src/app/page.tsx | 23 ++++- apps/web/src/app/setup/business/page.tsx | 7 +- apps/web/src/app/setup/complete/page.tsx | 2 +- apps/web/src/app/setup/provisioning/page.tsx | 98 +++++++++++++++++++ apps/web/src/hooks/use-onboarding-guard.ts | 72 ++++++++++++-- .../lib/squadhub/{provision.ts => setup.ts} | 2 +- .../templates/workspaces/clawe/SOUL.md | 12 +++ packages/backend/convex/accounts.ts | 58 +++++++++++ packages/backend/convex/schema.ts | 2 +- packages/backend/convex/tenants.ts | 36 ------- packages/cli/src/client.ts | 23 +++-- packages/plugins/src/registry.ts | 4 +- 14 files changed, 306 insertions(+), 73 deletions(-) create mode 100644 apps/web/src/app/setup/provisioning/page.tsx rename apps/web/src/lib/squadhub/{provision.ts => setup.ts} (99%) diff --git a/apps/web/src/app/api/tenant/provision/route.ts b/apps/web/src/app/api/tenant/provision/route.ts index 091e64c..df38a92 100644 --- a/apps/web/src/app/api/tenant/provision/route.ts +++ b/apps/web/src/app/api/tenant/provision/route.ts @@ -3,7 +3,7 @@ import type { NextRequest } from "next/server"; import { ConvexHttpClient } from "convex/browser"; import { api } from "@clawe/backend"; import { loadPlugins, getPlugin } from "@clawe/plugins"; -import { provisionTenant } from "@/lib/squadhub/provision"; +import { setupTenant } from "@/lib/squadhub/setup"; /** * POST /api/tenant/provision @@ -72,7 +72,7 @@ export async function POST(request: NextRequest) { accountId: account._id, }); - // Provision infrastructure (dev: reads env vars, cloud: creates AWS resources) + // Provision infrastructure (dev: reads env vars) const provisionResult = await provisioner.provision({ tenantId: tenantIdToProvision, accountId: account._id, @@ -97,16 +97,30 @@ export async function POST(request: NextRequest) { // Re-fetch tenant to get latest connection details const tenant = await convex.query(api.tenants.getForCurrentUser, {}); + if (!tenant) { + return NextResponse.json( + { error: "Failed to retrieve tenant after provisioning" }, + { status: 500 }, + ); + } else if (tenant.status !== "active") { + return NextResponse.json( + { error: `Tenant in unexpected status "${tenant.status}"` }, + { status: 500 }, + ); + } else if (!tenant.squadhubUrl || !tenant.squadhubToken) { + return NextResponse.json( + { error: "Tenant missing Squadhub connection details" }, + { status: 500 }, + ); + } + // 5. Run app-level setup (agents, crons, routines) const connection = { - squadhubUrl: - tenant?.squadhubUrl ?? - process.env.SQUADHUB_URL ?? - "http://localhost:18790", - squadhubToken: tenant?.squadhubToken ?? process.env.SQUADHUB_TOKEN ?? "", + squadhubUrl: tenant.squadhubUrl, + squadhubToken: tenant.squadhubToken, }; - const result = await provisionTenant(connection, convexUrl, authToken); + const result = await setupTenant(connection, convexUrl, authToken); // 6. Return result return NextResponse.json({ diff --git a/apps/web/src/app/auth/login/page.tsx b/apps/web/src/app/auth/login/page.tsx index 028f747..ba88fce 100644 --- a/apps/web/src/app/auth/login/page.tsx +++ b/apps/web/src/app/auth/login/page.tsx @@ -8,14 +8,12 @@ import { api } from "@clawe/backend"; import { Button } from "@clawe/ui/components/button"; import { Spinner } from "@clawe/ui/components/spinner"; import { useAuth } from "@/providers/auth-provider"; -import { useApiClient } from "@/hooks/use-api-client"; const AUTO_LOGIN_EMAIL = process.env.NEXT_PUBLIC_AUTO_LOGIN_EMAIL; export default function LoginPage() { const router = useRouter(); const { isAuthenticated, isLoading, signIn } = useAuth(); - const apiClient = useApiClient(); const getOrCreateUser = useMutation(api.users.getOrCreateFromAuth); const [autoLoginAttempted, setAutoLoginAttempted] = useState(false); @@ -34,17 +32,15 @@ export default function LoginPage() { const ensureUser = async () => { try { await getOrCreateUser(); - // Provision tenant (idempotent — fast for dev, creates tenant if needed) - await apiClient.post("/api/tenant/provision"); } catch { - // User creation or provisioning may fail if auth isn't ready yet. - // The onboarding guard handles this on the next page load. + // User creation may fail if auth isn't ready yet. + // The root page handles routing on the next page load. } router.replace("/"); }; ensureUser(); - }, [isAuthenticated, getOrCreateUser, apiClient, router]); + }, [isAuthenticated, getOrCreateUser, router]); return (
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 252f9ed..ae44797 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -20,20 +20,37 @@ export default function Home() { .catch(() => setUserReady(true)); }, [isAuthenticated, userReady, getOrCreateUser]); + const tenant = useQuery( + api.tenants.getForCurrentUser, + isAuthenticated && userReady ? {} : "skip", + ); + const isOnboardingComplete = useQuery( - api.tenants.isOnboardingComplete, + api.accounts.isOnboardingComplete, isAuthenticated && userReady ? {} : "skip", ); useEffect(() => { - if (!userReady || isOnboardingComplete === undefined) return; + if (!userReady) return; + + // Still loading tenant query + if (tenant === undefined) return; + + // No tenant or not active → provisioning + if (tenant === null || tenant.status !== "active") { + router.replace("/setup/provisioning"); + return; + } + + // Tenant is active — wait for onboarding check + if (isOnboardingComplete === undefined) return; if (isOnboardingComplete) { router.replace("/board"); } else { router.replace("/setup"); } - }, [isOnboardingComplete, userReady, router]); + }, [tenant, isOnboardingComplete, userReady, router]); return (
diff --git a/apps/web/src/app/setup/business/page.tsx b/apps/web/src/app/setup/business/page.tsx index a774ebb..219bc59 100644 --- a/apps/web/src/app/setup/business/page.tsx +++ b/apps/web/src/app/setup/business/page.tsx @@ -6,6 +6,7 @@ import { api } from "@clawe/backend"; import { Button } from "@clawe/ui/components/button"; import { Progress } from "@clawe/ui/components/progress"; import { Chat } from "@/components/chat"; +import { useAuth } from "@/providers/auth-provider"; const TOTAL_STEPS = 4; const CURRENT_STEP = 2; @@ -15,9 +16,13 @@ const CLAWE_SESSION_KEY = "agent:main:main"; export default function BusinessPage() { const router = useRouter(); + const { isAuthenticated } = useAuth(); // Real-time subscription - auto-updates when CLI saves - const businessContext = useQuery(api.businessContext.get, {}); + const businessContext = useQuery( + api.businessContext.get, + isAuthenticated ? {} : "skip", + ); const canContinue = businessContext !== null && businessContext !== undefined; return ( diff --git a/apps/web/src/app/setup/complete/page.tsx b/apps/web/src/app/setup/complete/page.tsx index a27781d..69a0302 100644 --- a/apps/web/src/app/setup/complete/page.tsx +++ b/apps/web/src/app/setup/complete/page.tsx @@ -14,7 +14,7 @@ const CURRENT_STEP = 4; export default function CompletePage() { const router = useRouter(); - const completeOnboarding = useMutation(api.tenants.completeOnboarding); + const completeOnboarding = useMutation(api.accounts.completeOnboarding); const [isCompleting, setIsCompleting] = useState(false); const handleFinish = async () => { diff --git a/apps/web/src/app/setup/provisioning/page.tsx b/apps/web/src/app/setup/provisioning/page.tsx new file mode 100644 index 0000000..ff4e0ad --- /dev/null +++ b/apps/web/src/app/setup/provisioning/page.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useQuery } from "convex/react"; +import { api } from "@clawe/backend"; +import { AlertTriangle } from "lucide-react"; +import { Button } from "@clawe/ui/components/button"; +import { Spinner } from "@clawe/ui/components/spinner"; +import { useAuth } from "@/providers/auth-provider"; +import { useApiClient } from "@/hooks/use-api-client"; + +export default function ProvisioningPage() { + const router = useRouter(); + const { isAuthenticated } = useAuth(); + const apiClient = useApiClient(); + const [error, setError] = useState(null); + const provisioningRef = useRef(false); + + const tenant = useQuery( + api.tenants.getForCurrentUser, + isAuthenticated ? {} : "skip", + ); + + const isOnboardingComplete = useQuery( + api.accounts.isOnboardingComplete, + isAuthenticated ? {} : "skip", + ); + + // Redirect when tenant becomes active + useEffect(() => { + if (tenant?.status !== "active") return; + if (isOnboardingComplete === undefined) return; + + if (isOnboardingComplete) { + router.replace("/board"); + } else { + router.replace("/setup/welcome"); + } + }, [tenant?.status, isOnboardingComplete, router]); + + // Trigger provisioning when no active tenant + useEffect(() => { + if (!isAuthenticated) return; + // Wait for tenant query to resolve + if (tenant === undefined) return; + // Already active — redirect effect handles it + if (tenant?.status === "active") return; + // Already provisioning in this render cycle + if (provisioningRef.current) return; + + provisioningRef.current = true; + provision(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAuthenticated, tenant]); + + const provision = async () => { + setError(null); + try { + await apiClient.post("/api/tenant/provision"); + // Convex subscription will reactively update `tenant` → redirect fires + } catch (err) { + const message = + err instanceof Error ? err.message : "An unexpected error occurred"; + setError(message); + provisioningRef.current = false; + } + }; + + if (error) { + return ( +
+
+
+ +

Setup failed

+

{error}

+
+ +
+
+ ); + } + + return ( +
+
+ +

Setting up your workspace...

+

+ This will only take a moment. +

+
+
+ ); +} diff --git a/apps/web/src/hooks/use-onboarding-guard.ts b/apps/web/src/hooks/use-onboarding-guard.ts index e53624d..5bbee25 100644 --- a/apps/web/src/hooks/use-onboarding-guard.ts +++ b/apps/web/src/hooks/use-onboarding-guard.ts @@ -1,12 +1,13 @@ "use client"; import { useEffect } from "react"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { useQuery } from "convex/react"; import { api } from "@clawe/backend"; import { useAuth } from "@/providers/auth-provider"; /** + * Redirects to /setup/provisioning if no active tenant. * Redirects to /setup if onboarding is not complete. * Redirects to /auth/login if not authenticated. * Use in dashboard/protected routes. @@ -14,8 +15,16 @@ import { useAuth } from "@/providers/auth-provider"; export const useRequireOnboarding = () => { const router = useRouter(); const { isAuthenticated, isLoading: authLoading } = useAuth(); + + const tenant = useQuery( + api.tenants.getForCurrentUser, + isAuthenticated ? {} : "skip", + ); + + const tenantActive = tenant?.status === "active"; + const isComplete = useQuery( - api.tenants.isOnboardingComplete, + api.accounts.isOnboardingComplete, isAuthenticated ? {} : "skip", ); @@ -24,27 +33,51 @@ export const useRequireOnboarding = () => { router.replace("/auth/login"); return; } + + // Still loading tenant + if (!isAuthenticated || tenant === undefined) return; + + // No tenant or not active → provisioning + if (tenant === null || !tenantActive) { + router.replace("/setup/provisioning"); + return; + } + + // Tenant active but not onboarded → setup if (isComplete === false) { router.replace("/setup"); } - }, [isComplete, isAuthenticated, authLoading, router]); + }, [isComplete, isAuthenticated, authLoading, tenant, tenantActive, router]); return { - isLoading: isComplete === undefined || authLoading, + isLoading: + authLoading || + tenant === undefined || + (isAuthenticated && isComplete === undefined), isComplete, }; }; /** + * Redirects to /setup/provisioning if no active tenant (unless already there). * Redirects to /board if onboarding is already complete. * Redirects to /auth/login if not authenticated. * Use in setup routes. */ export const useRedirectIfOnboarded = () => { const router = useRouter(); + const pathname = usePathname(); const { isAuthenticated, isLoading: authLoading } = useAuth(); + + const tenant = useQuery( + api.tenants.getForCurrentUser, + isAuthenticated ? {} : "skip", + ); + + const tenantActive = tenant?.status === "active"; + const isComplete = useQuery( - api.tenants.isOnboardingComplete, + api.accounts.isOnboardingComplete, isAuthenticated ? {} : "skip", ); @@ -53,13 +86,38 @@ export const useRedirectIfOnboarded = () => { router.replace("/auth/login"); return; } + + // Still loading tenant + if (!isAuthenticated || tenant === undefined) return; + + // No tenant or not active → provisioning (avoid redirect loop) + if ( + (tenant === null || !tenantActive) && + pathname !== "/setup/provisioning" + ) { + router.replace("/setup/provisioning"); + return; + } + + // Tenant active and onboarded → dashboard if (isComplete === true) { router.replace("/board"); } - }, [isComplete, isAuthenticated, authLoading, router]); + }, [ + isComplete, + isAuthenticated, + authLoading, + tenant, + tenantActive, + pathname, + router, + ]); return { - isLoading: isComplete === undefined || authLoading, + isLoading: + authLoading || + tenant === undefined || + (isAuthenticated && isComplete === undefined), isComplete, }; }; diff --git a/apps/web/src/lib/squadhub/provision.ts b/apps/web/src/lib/squadhub/setup.ts similarity index 99% rename from apps/web/src/lib/squadhub/provision.ts rename to apps/web/src/lib/squadhub/setup.ts index 943ef54..95a565a 100644 --- a/apps/web/src/lib/squadhub/provision.ts +++ b/apps/web/src/lib/squadhub/setup.ts @@ -213,7 +213,7 @@ async function seedRoutines(convex: ConvexHttpClient): Promise<{ * 3. Setup heartbeat cron jobs on squadhub * 4. Seed default routines in Convex */ -export async function provisionTenant( +export async function setupTenant( connection: SquadhubConnection, convexUrl: string, authToken?: string, diff --git a/docker/squadhub/templates/workspaces/clawe/SOUL.md b/docker/squadhub/templates/workspaces/clawe/SOUL.md index c4df86d..61f92ac 100644 --- a/docker/squadhub/templates/workspaces/clawe/SOUL.md +++ b/docker/squadhub/templates/workspaces/clawe/SOUL.md @@ -97,6 +97,18 @@ Sharp, competent, low-ego. You're running the show — coordinating, delegating, Concise by default. Thorough when it matters. Never waste your human's time. +## CLI Errors + +If a CLI command fails, **never work around it manually**. Do NOT try to save data through alternative means, write to files directly, or bypass the CLI in any way. + +Instead: + +1. Tell the user the command failed +2. Show them the error message +3. Ask them to report the issue + +Example: "I ran into an error saving your business context. Here's the error: `[error message]`. Could you report this issue so the team can fix it?" + ## Boundaries - Private things stay private diff --git a/packages/backend/convex/accounts.ts b/packages/backend/convex/accounts.ts index d5335f9..ba5bdf9 100644 --- a/packages/backend/convex/accounts.ts +++ b/packages/backend/convex/accounts.ts @@ -1,3 +1,4 @@ +import { v } from "convex/values"; import { query, mutation } from "./_generated/server"; import { getUser } from "./lib/auth"; @@ -62,3 +63,60 @@ export const getForCurrentUser = query({ return await ctx.db.get(membership.accountId); }, }); + +/** + * Check if onboarding is complete for the current user's account. + * Returns false for new users who don't have an account yet. + */ +export const isOnboardingComplete = query({ + args: { machineToken: v.optional(v.string()) }, + handler: async (ctx) => { + try { + const user = await getUser(ctx); + + const membership = await ctx.db + .query("accountMembers") + .withIndex("by_user", (q) => q.eq("userId", user._id)) + .first(); + + if (!membership) { + return false; + } + + const account = await ctx.db.get(membership.accountId); + return account?.onboardingComplete === true; + } catch { + // New user with no account — not onboarded + return false; + } + }, +}); + +/** + * Mark onboarding as complete for the current user's account. + */ +export const completeOnboarding = mutation({ + args: { machineToken: v.optional(v.string()) }, + handler: async (ctx) => { + const user = await getUser(ctx); + + const membership = await ctx.db + .query("accountMembers") + .withIndex("by_user", (q) => q.eq("userId", user._id)) + .first(); + + if (!membership) { + throw new Error("No account found for user"); + } + + const account = await ctx.db.get(membership.accountId); + if (!account) { + throw new Error("Account not found"); + } + + await ctx.db.patch(membership.accountId, { + onboardingComplete: true, + updatedAt: Date.now(), + }); + }, +}); diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 0d9e983..17e199f 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -13,6 +13,7 @@ export default defineSchema({ // Accounts - Billing/organizational unit (one auto-created per user on signup) accounts: defineTable({ name: v.optional(v.string()), + onboardingComplete: v.optional(v.boolean()), createdAt: v.number(), updatedAt: v.number(), }), @@ -45,7 +46,6 @@ export default defineSchema({ settings: v.optional( v.object({ timezone: v.optional(v.string()), - onboardingComplete: v.optional(v.boolean()), }), ), createdAt: v.number(), diff --git a/packages/backend/convex/tenants.ts b/packages/backend/convex/tenants.ts index 8c0fccb..3e09183 100644 --- a/packages/backend/convex/tenants.ts +++ b/packages/backend/convex/tenants.ts @@ -37,42 +37,6 @@ export const setTimezone = mutation({ }, }); -// Check if onboarding is complete for the current tenant. -// Returns false for new users who don't have a tenant yet. -export const isOnboardingComplete = query({ - args: { machineToken: v.optional(v.string()) }, - handler: async (ctx, args) => { - try { - const tenantId = await resolveTenantId(ctx, args); - const tenant = await ctx.db.get(tenantId); - return tenant?.settings?.onboardingComplete === true; - } catch { - // New user with no tenant — not onboarded - return false; - } - }, -}); - -// Mark onboarding as complete for the current tenant -export const completeOnboarding = mutation({ - args: { machineToken: v.optional(v.string()) }, - handler: async (ctx, args) => { - const tenantId = await resolveTenantId(ctx, args); - const tenant = await ctx.db.get(tenantId); - if (!tenant) { - throw new Error("Tenant not found"); - } - - await ctx.db.patch(tenantId, { - settings: { - ...tenant.settings, - onboardingComplete: true, - }, - updatedAt: Date.now(), - }); - }, -}); - // Create a new tenant within an account export const create = mutation({ args: { diff --git a/packages/cli/src/client.ts b/packages/cli/src/client.ts index 07df7ad..76267bd 100644 --- a/packages/cli/src/client.ts +++ b/packages/cli/src/client.ts @@ -13,10 +13,19 @@ const CONVEX_URL = process.env.CONVEX_URL; /** * Machine token read from SQUADHUB_TOKEN env var. * Used to identify which tenant this CLI session belongs to. - * TODO (Phase 2.6): Inject into all Convex calls for tenant scoping. + * Automatically injected into all Convex calls for tenant scoping. */ export const machineToken = process.env.SQUADHUB_TOKEN || ""; +/** + * Inject machineToken into Convex function args for tenant scoping. + * All tenant-scoped Convex functions accept optional `machineToken`. + */ +function injectToken(args: T[]): T[] { + const base = (args[0] ?? {}) as Record; + return [{ ...base, machineToken } as T]; +} + // Common MIME types by extension const MIME_TYPES: Record = { ".md": "text/markdown", @@ -49,35 +58,35 @@ const client = new ConvexHttpClient(CONVEX_URL); /** * Wrapper around ConvexHttpClient.query. - * TODO (Phase 2.6): Inject machineToken into args for tenant scoping. + * Injects machineToken for tenant scoping. */ export function query>( fn: F, ...args: OptionalRestArgs ): Promise> { - return client.query(fn, ...args); + return client.query(fn, ...(injectToken(args) as OptionalRestArgs)); } /** * Wrapper around ConvexHttpClient.mutation. - * TODO (Phase 2.6): Inject machineToken into args for tenant scoping. + * Injects machineToken for tenant scoping. */ export function mutation>( fn: F, ...args: OptionalRestArgs ): Promise> { - return client.mutation(fn, ...args); + return client.mutation(fn, ...(injectToken(args) as OptionalRestArgs)); } /** * Wrapper around ConvexHttpClient.action. - * TODO (Phase 2.6): Inject machineToken into args for tenant scoping. + * Injects machineToken for tenant scoping. */ export function action>( fn: F, ...args: OptionalRestArgs ): Promise> { - return client.action(fn, ...args); + return client.action(fn, ...(injectToken(args) as OptionalRestArgs)); } /** diff --git a/packages/plugins/src/registry.ts b/packages/plugins/src/registry.ts index 5c8dd93..ea69e2f 100644 --- a/packages/plugins/src/registry.ts +++ b/packages/plugins/src/registry.ts @@ -24,7 +24,9 @@ export async function loadPlugins(): Promise { if (pluginsLoaded) return; try { - const external = await import("@clawe/cloud-plugins"); + const external = await import( + /* webpackIgnore: true */ "@clawe/cloud-plugins" + ); plugins = external.register(); pluginsLoaded = true; } catch { From 62be3274116490bf3cea306563ad9ef4f26268ed Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Tue, 17 Feb 2026 13:47:52 +0200 Subject: [PATCH 7/8] fix: refactor codebase --- .../web/src/app/api/tenant/provision/route.ts | 11 +- apps/web/src/app/layout.tsx | 2 +- apps/web/src/app/setup/provisioning/page.tsx | 31 ++- apps/web/src/lib/auth/nextauth-config.ts | 12 +- apps/web/src/lib/auth/verify-token.ts | 14 +- docker-compose.yml | 3 +- packages/backend/convex/accounts.ts | 35 +-- packages/backend/convex/activities.ts | 51 +++-- packages/backend/convex/agents.ts | 62 +++--- packages/backend/convex/documents.ts | 75 ++++--- packages/backend/convex/lib/auth.ts | 42 +++- packages/backend/convex/lib/helpers.ts | 15 ++ packages/backend/convex/messages.ts | 60 +++--- packages/backend/convex/notifications.ts | 60 +++--- packages/backend/convex/routines.ts | 80 ++++--- packages/backend/convex/schema.ts | 32 ++- packages/backend/convex/tasks.ts | 199 ++++++++++-------- packages/backend/convex/tenants.ts | 35 ++- packages/backend/convex/users.ts | 25 +-- packages/cli/src/client.ts | 9 +- 20 files changed, 478 insertions(+), 375 deletions(-) create mode 100644 packages/backend/convex/lib/helpers.ts diff --git a/apps/web/src/app/api/tenant/provision/route.ts b/apps/web/src/app/api/tenant/provision/route.ts index df38a92..7298027 100644 --- a/apps/web/src/app/api/tenant/provision/route.ts +++ b/apps/web/src/app/api/tenant/provision/route.ts @@ -22,7 +22,7 @@ import { setupTenant } from "@/lib/squadhub/setup"; * 5. Run app-level setup (agents, crons, routines) * 6. Return { ok: true, tenantId } */ -export async function POST(request: NextRequest) { +export const POST = async (request: NextRequest) => { const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL; if (!convexUrl) { return NextResponse.json( @@ -68,9 +68,7 @@ export async function POST(request: NextRequest) { // Create tenant record (or use existing non-active one) const tenantIdToProvision = existingTenant ? existingTenant._id - : await convex.mutation(api.tenants.create, { - accountId: account._id, - }); + : await convex.mutation(api.tenants.create, {}); // Provision infrastructure (dev: reads env vars) const provisionResult = await provisioner.provision({ @@ -81,7 +79,6 @@ export async function POST(request: NextRequest) { // Update tenant with connection details await convex.mutation(api.tenants.updateStatus, { - tenantId: tenantIdToProvision, status: "active", squadhubUrl: provisionResult.squadhubUrl, squadhubToken: provisionResult.squadhubToken, @@ -125,7 +122,7 @@ export async function POST(request: NextRequest) { // 6. Return result return NextResponse.json({ ok: result.errors.length === 0, - tenantId: tenant?._id, + tenantId: tenant._id, agents: result.agents, crons: result.crons, routines: result.routines, @@ -135,4 +132,4 @@ export async function POST(request: NextRequest) { const message = error instanceof Error ? error.message : "Unknown error"; return NextResponse.json({ error: message }, { status: 500 }); } -} +}; diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index f6d92d7..2cb9734 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -29,7 +29,7 @@ const spaceGrotesk = Space_Grotesk({ export const metadata: Metadata = { title: "Clawe", - description: "AI Marketing Agency assistant.", + description: "AI-powered multi-agent coordination system.", }; export default function RootLayout({ diff --git a/apps/web/src/app/setup/provisioning/page.tsx b/apps/web/src/app/setup/provisioning/page.tsx index ff4e0ad..8029654 100644 --- a/apps/web/src/app/setup/provisioning/page.tsx +++ b/apps/web/src/app/setup/provisioning/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useQuery } from "convex/react"; import { api } from "@clawe/backend"; @@ -39,6 +39,19 @@ export default function ProvisioningPage() { } }, [tenant?.status, isOnboardingComplete, router]); + const provision = useCallback(async () => { + setError(null); + try { + await apiClient.post("/api/tenant/provision"); + // Convex subscription will reactively update `tenant` → redirect fires + } catch (err) { + const message = + err instanceof Error ? err.message : "An unexpected error occurred"; + setError(message); + provisioningRef.current = false; + } + }, [apiClient]); + // Trigger provisioning when no active tenant useEffect(() => { if (!isAuthenticated) return; @@ -51,21 +64,7 @@ export default function ProvisioningPage() { provisioningRef.current = true; provision(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isAuthenticated, tenant]); - - const provision = async () => { - setError(null); - try { - await apiClient.post("/api/tenant/provision"); - // Convex subscription will reactively update `tenant` → redirect fires - } catch (err) { - const message = - err instanceof Error ? err.message : "An unexpected error occurred"; - setError(message); - provisioningRef.current = false; - } - }; + }, [isAuthenticated, tenant, provision]); if (error) { return ( diff --git a/apps/web/src/lib/auth/nextauth-config.ts b/apps/web/src/lib/auth/nextauth-config.ts index 6f17fce..9f728ed 100644 --- a/apps/web/src/lib/auth/nextauth-config.ts +++ b/apps/web/src/lib/auth/nextauth-config.ts @@ -45,7 +45,7 @@ if (process.env.AUTO_LOGIN_EMAIL) { email: { label: "Email", type: "email" }, }, authorize: async (credentials) => { - const email = credentials.email as string; + const email = String(credentials.email ?? ""); if (!email) return null; return { id: email, email, name: email.split("@")[0] }; }, @@ -61,9 +61,9 @@ const nextAuth = NextAuth({ if (!token) return ""; const privateKey = await getPrivateKey(); return new SignJWT({ - sub: token.email as string, - email: token.email as string, - name: token.name as string, + sub: String(token.email ?? ""), + email: String(token.email ?? ""), + name: String(token.name ?? ""), }) .setProtectedHeader({ alg: "RS256", kid: "clawe-dev-key" }) .setIssuer(ISSUER) @@ -91,8 +91,8 @@ const nextAuth = NextAuth({ return token; }, session({ session, token }) { - if (token.email) session.user.email = token.email as string; - if (token.name) session.user.name = token.name as string; + if (token.email) session.user.email = String(token.email); + if (token.name) session.user.name = String(token.name); return session; }, }, diff --git a/apps/web/src/lib/auth/verify-token.ts b/apps/web/src/lib/auth/verify-token.ts index bd8b0a7..496ae59 100644 --- a/apps/web/src/lib/auth/verify-token.ts +++ b/apps/web/src/lib/auth/verify-token.ts @@ -8,6 +8,12 @@ export interface VerifiedToken extends JWTPayload { email?: string; } +function isVerifiedToken( + payload: JWTPayload | Record, +): payload is VerifiedToken { + return typeof payload.sub === "string"; +} + // --------------------------------------------------------------------------- // Cognito: use the official AWS verifier (handles JWKS caching, kid rotation, // token_use / client_id validation, and Cognito-specific claim checks). @@ -39,8 +45,8 @@ async function verifyCognitoToken( ): Promise { try { const payload = await getCognitoVerifier().verify(token); - if (!payload.sub) return null; - return payload as VerifiedToken; + if (!isVerifiedToken(payload)) return null; + return payload; } catch { return null; } @@ -70,8 +76,8 @@ async function verifyNextAuthToken( audience: "convex", }); - if (!payload.sub) return null; - return payload as VerifiedToken; + if (!isVerifiedToken(payload)) return null; + return payload; } catch { return null; } diff --git a/docker-compose.yml b/docker-compose.yml index 87e6400..a44b090 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,8 +43,7 @@ services: dockerfile: apps/watcher/Dockerfile environment: - CONVEX_URL=${CONVEX_URL} - - SQUADHUB_URL=http://squadhub:18789 - - SQUADHUB_TOKEN=${SQUADHUB_TOKEN} + - WATCHER_TOKEN=${WATCHER_TOKEN} depends_on: squadhub: condition: service_healthy diff --git a/packages/backend/convex/accounts.ts b/packages/backend/convex/accounts.ts index ba5bdf9..35f448c 100644 --- a/packages/backend/convex/accounts.ts +++ b/packages/backend/convex/accounts.ts @@ -1,6 +1,5 @@ -import { v } from "convex/values"; import { query, mutation } from "./_generated/server"; -import { getUser } from "./lib/auth"; +import { getUser, ensureAccountForUser } from "./lib/auth"; /** * Get or create an account for the current authenticated user. @@ -12,33 +11,7 @@ export const getOrCreateForUser = mutation({ args: {}, handler: async (ctx) => { const user = await getUser(ctx); - - // Check for existing account membership - const membership = await ctx.db - .query("accountMembers") - .withIndex("by_user", (q) => q.eq("userId", user._id)) - .first(); - - if (membership) { - return (await ctx.db.get(membership.accountId))!; - } - - // Create new account + membership - const now = Date.now(); - const accountId = await ctx.db.insert("accounts", { - name: user.name ? `${user.name}'s Account` : undefined, - createdAt: now, - updatedAt: now, - }); - - await ctx.db.insert("accountMembers", { - userId: user._id, - accountId, - role: "owner", - createdAt: now, - }); - - return (await ctx.db.get(accountId))!; + return ensureAccountForUser(ctx, user); }, }); @@ -69,7 +42,7 @@ export const getForCurrentUser = query({ * Returns false for new users who don't have an account yet. */ export const isOnboardingComplete = query({ - args: { machineToken: v.optional(v.string()) }, + args: {}, handler: async (ctx) => { try { const user = await getUser(ctx); @@ -96,7 +69,7 @@ export const isOnboardingComplete = query({ * Mark onboarding as complete for the current user's account. */ export const completeOnboarding = mutation({ - args: { machineToken: v.optional(v.string()) }, + args: {}, handler: async (ctx) => { const user = await getUser(ctx); diff --git a/packages/backend/convex/activities.ts b/packages/backend/convex/activities.ts index 8e8c320..d4b3bb9 100644 --- a/packages/backend/convex/activities.ts +++ b/packages/backend/convex/activities.ts @@ -1,6 +1,7 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; import { resolveTenantId } from "./lib/auth"; +import { getAgentBySessionKey } from "./lib/helpers"; // Get activity feed (most recent first) export const feed = query({ @@ -15,25 +16,32 @@ export const feed = query({ const { machineToken: _, ...filters } = args; const limit = filters.limit ?? 50; - // Always query by tenant first, then filter in JS - const allActivities = await ctx.db - .query("activities") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .order("desc") - .collect(); - let activities; if (filters.taskId) { - activities = allActivities - .filter((a) => a.taskId === filters.taskId) - .slice(0, limit); + activities = await ctx.db + .query("activities") + .withIndex("by_tenant_task", (q) => + q.eq("tenantId", tenantId).eq("taskId", filters.taskId), + ) + .order("desc") + .take(limit); } else if (filters.agentId) { + // No compound index for agentId — filter in JS + const allActivities = await ctx.db + .query("activities") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .order("desc") + .collect(); activities = allActivities .filter((a) => a.agentId === filters.agentId) .slice(0, limit); } else { - activities = allActivities.slice(0, limit); + activities = await ctx.db + .query("activities") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .order("desc") + .take(limit); } // Enrich with agent and task info @@ -84,14 +92,13 @@ export const byType = query({ const { machineToken: _, ...filters } = args; const limit = filters.limit ?? 50; - // Query by tenant first, then filter by type in JS - const allActivities = await ctx.db + return await ctx.db .query("activities") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .withIndex("by_tenant_type", (q) => + q.eq("tenantId", tenantId).eq("type", filters.type), + ) .order("desc") - .collect(); - - return allActivities.filter((a) => a.type === filters.type).slice(0, limit); + .take(limit); }, }); @@ -156,11 +163,11 @@ export const logBySession = mutation({ let agentId = undefined; if (fields.sessionKey) { - const sessionKey = fields.sessionKey; - const agent = await ctx.db - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", sessionKey)) - .first(); + const agent = await getAgentBySessionKey( + ctx, + tenantId, + fields.sessionKey, + ); if (agent) { agentId = agent._id; } diff --git a/packages/backend/convex/agents.ts b/packages/backend/convex/agents.ts index e74a608..125b2ea 100644 --- a/packages/backend/convex/agents.ts +++ b/packages/backend/convex/agents.ts @@ -1,6 +1,7 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; import { resolveTenantId } from "./lib/auth"; +import { getAgentBySessionKey } from "./lib/helpers"; const agentStatusValidator = v.union(v.literal("online"), v.literal("offline")); @@ -20,8 +21,10 @@ export const list = query({ export const get = query({ args: { id: v.id("agents"), machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { - await resolveTenantId(ctx, args); - return await ctx.db.get(args.id); + const tenantId = await resolveTenantId(ctx, args); + const agent = await ctx.db.get(args.id); + if (!agent || agent.tenantId !== tenantId) return null; + return agent; }, }); @@ -30,11 +33,7 @@ export const getBySessionKey = query({ args: { sessionKey: v.string(), machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { const tenantId = await resolveTenantId(ctx, args); - const agents = await ctx.db - .query("agents") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .collect(); - return agents.find((a) => a.sessionKey === args.sessionKey) ?? null; + return await getAgentBySessionKey(ctx, tenantId, args.sessionKey); }, }); @@ -43,11 +42,12 @@ export const listByStatus = query({ args: { status: agentStatusValidator, machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { const tenantId = await resolveTenantId(ctx, args); - const agents = await ctx.db + return await ctx.db .query("agents") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .withIndex("by_tenant_status", (q) => + q.eq("tenantId", tenantId).eq("status", args.status), + ) .collect(); - return agents.filter((a) => a.status === args.status); }, }); @@ -97,11 +97,7 @@ export const upsert = mutation({ const { machineToken: _, ...rest } = args; const now = Date.now(); - const agents = await ctx.db - .query("agents") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .collect(); - const existing = agents.find((a) => a.sessionKey === rest.sessionKey); + const existing = await getAgentBySessionKey(ctx, tenantId, rest.sessionKey); if (existing) { await ctx.db.patch(existing._id, { @@ -164,7 +160,11 @@ export const updateStatus = mutation({ machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { - await resolveTenantId(ctx, args); + const tenantId = await resolveTenantId(ctx, args); + const agent = await ctx.db.get(args.id); + if (!agent || agent.tenantId !== tenantId) { + throw new Error("Not found"); + } await ctx.db.patch(args.id, { status: args.status, updatedAt: Date.now(), @@ -179,11 +179,7 @@ export const heartbeat = mutation({ const tenantId = await resolveTenantId(ctx, args); const now = Date.now(); - const agents = await ctx.db - .query("agents") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .collect(); - const agent = agents.find((a) => a.sessionKey === args.sessionKey); + const agent = await getAgentBySessionKey(ctx, tenantId, args.sessionKey); if (!agent) { throw new Error(`Agent not found: ${args.sessionKey}`); @@ -223,11 +219,7 @@ export const setCurrentTask = mutation({ }, handler: async (ctx, args) => { const tenantId = await resolveTenantId(ctx, args); - const agents = await ctx.db - .query("agents") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .collect(); - const agent = agents.find((a) => a.sessionKey === args.sessionKey); + const agent = await getAgentBySessionKey(ctx, tenantId, args.sessionKey); if (!agent) { throw new Error(`Agent not found: ${args.sessionKey}`); @@ -249,11 +241,7 @@ export const setActivity = mutation({ }, handler: async (ctx, args) => { const tenantId = await resolveTenantId(ctx, args); - const agents = await ctx.db - .query("agents") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .collect(); - const agent = agents.find((a) => a.sessionKey === args.sessionKey); + const agent = await getAgentBySessionKey(ctx, tenantId, args.sessionKey); if (!agent) { throw new Error(`Agent not found: ${args.sessionKey}`); @@ -278,7 +266,11 @@ export const update = mutation({ machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { - await resolveTenantId(ctx, args); + const tenantId = await resolveTenantId(ctx, args); + const agent = await ctx.db.get(args.id); + if (!agent || agent.tenantId !== tenantId) { + throw new Error("Not found"); + } const { id, machineToken: _, ...updates } = args; const filteredUpdates = Object.fromEntries( Object.entries(updates).filter(([, value]) => value !== undefined), @@ -294,7 +286,11 @@ export const update = mutation({ export const remove = mutation({ args: { id: v.id("agents"), machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { - await resolveTenantId(ctx, args); + const tenantId = await resolveTenantId(ctx, args); + const agent = await ctx.db.get(args.id); + if (!agent || agent.tenantId !== tenantId) { + throw new Error("Not found"); + } await ctx.db.delete(args.id); }, }); diff --git a/packages/backend/convex/documents.ts b/packages/backend/convex/documents.ts index 78fbeda..c097fc0 100644 --- a/packages/backend/convex/documents.ts +++ b/packages/backend/convex/documents.ts @@ -1,6 +1,7 @@ import { v } from "convex/values"; import { action, mutation, query } from "./_generated/server"; import { resolveTenantId } from "./lib/auth"; +import { getAgentBySessionKey } from "./lib/helpers"; // Generate upload URL for file storage export const generateUploadUrl = action({ @@ -28,17 +29,23 @@ export const list = query({ const tenantId = await resolveTenantId(ctx, args); const limit = args.limit ?? 100; - const allDocs = await ctx.db - .query("documents") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .order("desc") - .collect(); - - const filtered = args.type - ? allDocs.filter((doc) => doc.type === args.type) - : allDocs; + let docsQuery; + const type = args.type; + if (type) { + docsQuery = ctx.db + .query("documents") + .withIndex("by_tenant_type", (q) => + q.eq("tenantId", tenantId).eq("type", type), + ) + .order("desc"); + } else { + docsQuery = ctx.db + .query("documents") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .order("desc"); + } - return filtered.slice(0, limit); + return await docsQuery.take(limit); }, }); @@ -47,13 +54,16 @@ export const getForTask = query({ args: { taskId: v.id("tasks"), machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { const tenantId = await resolveTenantId(ctx, args); - const allDocs = await ctx.db + const task = await ctx.db.get(args.taskId); + if (!task || task.tenantId !== tenantId) return []; + + const documents = await ctx.db .query("documents") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .withIndex("by_tenant_task", (q) => + q.eq("tenantId", tenantId).eq("taskId", args.taskId), + ) .collect(); - const documents = allDocs.filter((doc) => doc.taskId === args.taskId); - // Enrich with creator info and file URL return Promise.all( documents.map(async (doc) => { @@ -78,8 +88,10 @@ export const getForTask = query({ export const get = query({ args: { id: v.id("documents"), machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { - await resolveTenantId(ctx, args); - return await ctx.db.get(args.id); + const tenantId = await resolveTenantId(ctx, args); + const doc = await ctx.db.get(args.id); + if (!doc || doc.tenantId !== tenantId) return null; + return doc; }, }); @@ -105,12 +117,11 @@ export const create = mutation({ const now = Date.now(); // Find the creator agent - const agent = await ctx.db - .query("agents") - .withIndex("by_sessionKey", (q) => - q.eq("sessionKey", rest.createdBySessionKey), - ) - .first(); + const agent = await getAgentBySessionKey( + ctx, + tenantId, + rest.createdBySessionKey, + ); if (!agent) { throw new Error(`Agent not found: ${rest.createdBySessionKey}`); @@ -157,12 +168,11 @@ export const registerDeliverable = mutation({ const { machineToken: _, ...rest } = args; const now = Date.now(); - const agent = await ctx.db - .query("agents") - .withIndex("by_sessionKey", (q) => - q.eq("sessionKey", rest.createdBySessionKey), - ) - .first(); + const agent = await getAgentBySessionKey( + ctx, + tenantId, + rest.createdBySessionKey, + ); if (!agent) { throw new Error(`Agent not found: ${rest.createdBySessionKey}`); @@ -204,7 +214,10 @@ export const update = mutation({ machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { - await resolveTenantId(ctx, args); + const tenantId = await resolveTenantId(ctx, args); + const doc = await ctx.db.get(args.id); + if (!doc || doc.tenantId !== tenantId) throw new Error("Not found"); + const { id, machineToken: _, ...updates } = args; const filteredUpdates = Object.fromEntries( Object.entries(updates).filter(([, value]) => value !== undefined), @@ -221,7 +234,9 @@ export const update = mutation({ export const remove = mutation({ args: { id: v.id("documents"), machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { - await resolveTenantId(ctx, args); + const tenantId = await resolveTenantId(ctx, args); + const doc = await ctx.db.get(args.id); + if (!doc || doc.tenantId !== tenantId) throw new Error("Not found"); await ctx.db.delete(args.id); }, }); diff --git a/packages/backend/convex/lib/auth.ts b/packages/backend/convex/lib/auth.ts index 4556e83..a2532dc 100644 --- a/packages/backend/convex/lib/auth.ts +++ b/packages/backend/convex/lib/auth.ts @@ -1,7 +1,8 @@ -import type { Id } from "../_generated/dataModel"; -import type { QueryCtx } from "../_generated/server"; +import type { Doc, Id } from "../_generated/dataModel"; +import type { MutationCtx, QueryCtx } from "../_generated/server"; type ReadCtx = { db: QueryCtx["db"]; auth: QueryCtx["auth"] }; +type WriteCtx = { db: MutationCtx["db"] }; /** * Browser path: get the current user from JWT identity. @@ -127,3 +128,40 @@ export async function resolveTenantId( return getTenantIdFromJwt(ctx); } + +/** + * Ensure an account and membership exist for the given user. + * Returns the existing or newly created account. + */ +export async function ensureAccountForUser( + ctx: ReadCtx & WriteCtx, + user: Doc<"users">, +): Promise> { + const membership = await ctx.db + .query("accountMembers") + .withIndex("by_user", (q) => q.eq("userId", user._id)) + .first(); + + if (membership) { + const account = await ctx.db.get(membership.accountId); + if (account) return account; + } + + const now = Date.now(); + const accountId = await ctx.db.insert("accounts", { + name: user.name ? `${user.name}'s Account` : undefined, + createdAt: now, + updatedAt: now, + }); + + await ctx.db.insert("accountMembers", { + userId: user._id, + accountId, + role: "owner", + createdAt: now, + }); + + const account = await ctx.db.get(accountId); + if (!account) throw new Error("Failed to create account"); + return account; +} diff --git a/packages/backend/convex/lib/helpers.ts b/packages/backend/convex/lib/helpers.ts new file mode 100644 index 0000000..33a29aa --- /dev/null +++ b/packages/backend/convex/lib/helpers.ts @@ -0,0 +1,15 @@ +import type { QueryCtx } from "../_generated/server"; +import type { Doc, Id } from "../_generated/dataModel"; + +export async function getAgentBySessionKey( + ctx: { db: QueryCtx["db"] }, + tenantId: Id<"tenants">, + sessionKey: string, +): Promise | null> { + return await ctx.db + .query("agents") + .withIndex("by_tenant_sessionKey", (q) => + q.eq("tenantId", tenantId).eq("sessionKey", sessionKey), + ) + .first(); +} diff --git a/packages/backend/convex/messages.ts b/packages/backend/convex/messages.ts index 4cef811..ce7ec99 100644 --- a/packages/backend/convex/messages.ts +++ b/packages/backend/convex/messages.ts @@ -1,19 +1,23 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; import { resolveTenantId } from "./lib/auth"; +import { getAgentBySessionKey } from "./lib/helpers"; // List messages for a task export const listForTask = query({ args: { taskId: v.id("tasks"), machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { const tenantId = await resolveTenantId(ctx, args); - const allMessages = await ctx.db + const task = await ctx.db.get(args.taskId); + if (!task || task.tenantId !== tenantId) return []; + + const messages = await ctx.db .query("messages") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .withIndex("by_tenant_task", (q) => + q.eq("tenantId", tenantId).eq("taskId", args.taskId), + ) .collect(); - const messages = allMessages.filter((m) => m.taskId === args.taskId); - // Enrich with author info return Promise.all( messages.map(async (m) => { @@ -47,26 +51,20 @@ export const listByAgent = query({ handler: async (ctx, args) => { const tenantId = await resolveTenantId(ctx, args); - const agents = await ctx.db - .query("agents") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .collect(); - const agent = agents.find((a) => a.sessionKey === args.sessionKey); + const agent = await getAgentBySessionKey(ctx, tenantId, args.sessionKey); if (!agent) { return []; } - const allMessages = await ctx.db + const query = ctx.db .query("messages") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .collect(); - - const agentMessages = allMessages - .filter((m) => m.fromAgentId === agent._id) - .sort((a, b) => b.createdAt - a.createdAt); + .withIndex("by_tenant_agent", (q) => + q.eq("tenantId", tenantId).eq("fromAgentId", agent._id), + ) + .order("desc"); - return args.limit ? agentMessages.slice(0, args.limit) : agentMessages; + return args.limit ? await query.take(args.limit) : await query.collect(); }, }); @@ -77,14 +75,11 @@ export const recent = query({ const tenantId = await resolveTenantId(ctx, args); const limit = args.limit ?? 50; - const allMessages = await ctx.db + const messages = await ctx.db .query("messages") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .collect(); - - const messages = allMessages - .sort((a, b) => b.createdAt - a.createdAt) - .slice(0, limit); + .order("desc") + .take(limit); // Enrich with author and task info return Promise.all( @@ -138,11 +133,11 @@ export const create = mutation({ let fromAgentId = undefined; if (rest.fromSessionKey) { - const sessionKey = rest.fromSessionKey; - const agent = await ctx.db - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", sessionKey)) - .first(); + const agent = await getAgentBySessionKey( + ctx, + tenantId, + rest.fromSessionKey, + ); if (agent) { fromAgentId = agent._id; } @@ -160,7 +155,10 @@ export const create = mutation({ // Update task timestamp if linked to a task if (rest.taskId) { - await ctx.db.patch(rest.taskId, { updatedAt: now }); + const task = await ctx.db.get(rest.taskId); + if (task && task.tenantId === tenantId) { + await ctx.db.patch(rest.taskId, { updatedAt: now }); + } } return messageId; @@ -171,7 +169,9 @@ export const create = mutation({ export const remove = mutation({ args: { id: v.id("messages"), machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { - await resolveTenantId(ctx, args); + const tenantId = await resolveTenantId(ctx, args); + const message = await ctx.db.get(args.id); + if (!message || message.tenantId !== tenantId) throw new Error("Not found"); await ctx.db.delete(args.id); }, }); diff --git a/packages/backend/convex/notifications.ts b/packages/backend/convex/notifications.ts index 9ae3c91..b57916c 100644 --- a/packages/backend/convex/notifications.ts +++ b/packages/backend/convex/notifications.ts @@ -1,6 +1,7 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; import { resolveTenantId } from "./lib/auth"; +import { getAgentBySessionKey } from "./lib/helpers"; // Get undelivered notifications for an agent (by session key) export const getUndelivered = query({ @@ -9,11 +10,7 @@ export const getUndelivered = query({ const tenantId = await resolveTenantId(ctx, args); // Find the agent within this tenant - const agents = await ctx.db - .query("agents") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .collect(); - const agent = agents.find((a) => a.sessionKey === args.sessionKey); + const agent = await getAgentBySessionKey(ctx, tenantId, args.sessionKey); if (!agent) { return []; @@ -22,8 +19,11 @@ export const getUndelivered = query({ // Get undelivered notifications const notifications = await ctx.db .query("notifications") - .withIndex("by_target_undelivered", (q) => - q.eq("targetAgentId", agent._id).eq("delivered", false), + .withIndex("by_tenant_target_undelivered", (q) => + q + .eq("tenantId", tenantId) + .eq("targetAgentId", agent._id) + .eq("delivered", false), ) .collect(); @@ -68,11 +68,7 @@ export const getForAgent = query({ handler: async (ctx, args) => { const tenantId = await resolveTenantId(ctx, args); - const agents = await ctx.db - .query("agents") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .collect(); - const agent = agents.find((a) => a.sessionKey === args.sessionKey); + const agent = await getAgentBySessionKey(ctx, tenantId, args.sessionKey); if (!agent) { return []; @@ -80,7 +76,9 @@ export const getForAgent = query({ let query = ctx.db .query("notifications") - .withIndex("by_target", (q) => q.eq("targetAgentId", agent._id)) + .withIndex("by_tenant_target", (q) => + q.eq("tenantId", tenantId).eq("targetAgentId", agent._id), + ) .order("desc"); const notifications = args.limit @@ -98,10 +96,14 @@ export const markDelivered = mutation({ machineToken: v.optional(v.string()), }, handler: async (ctx, args) => { - await resolveTenantId(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const now = Date.now(); for (const id of args.notificationIds) { + const notification = await ctx.db.get(id); + if (!notification || notification.tenantId !== tenantId) { + throw new Error("Notification not found"); + } await ctx.db.patch(id, { delivered: true, deliveredAt: now, @@ -131,14 +133,13 @@ export const send = mutation({ handler: async (ctx, args) => { const tenantId = await resolveTenantId(ctx, args); const now = Date.now(); - const targetKey = args.targetSessionKey; // Find target agent within this tenant - const agents = await ctx.db - .query("agents") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .collect(); - const targetAgent = agents.find((a) => a.sessionKey === targetKey); + const targetAgent = await getAgentBySessionKey( + ctx, + tenantId, + args.targetSessionKey, + ); if (!targetAgent) { throw new Error(`Target agent not found: ${args.targetSessionKey}`); @@ -147,8 +148,10 @@ export const send = mutation({ // Find source agent if provided let sourceAgentId = undefined; if (args.sourceSessionKey) { - const sourceAgent = agents.find( - (a) => a.sessionKey === args.sourceSessionKey, + const sourceAgent = await getAgentBySessionKey( + ctx, + tenantId, + args.sourceSessionKey, ); if (sourceAgent) { sourceAgentId = sourceAgent._id; @@ -249,11 +252,7 @@ export const clearAll = mutation({ handler: async (ctx, args) => { const tenantId = await resolveTenantId(ctx, args); - const agents = await ctx.db - .query("agents") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .collect(); - const agent = agents.find((a) => a.sessionKey === args.sessionKey); + const agent = await getAgentBySessionKey(ctx, tenantId, args.sessionKey); if (!agent) { return 0; @@ -261,8 +260,11 @@ export const clearAll = mutation({ const notifications = await ctx.db .query("notifications") - .withIndex("by_target_undelivered", (q) => - q.eq("targetAgentId", agent._id).eq("delivered", false), + .withIndex("by_tenant_target_undelivered", (q) => + q + .eq("tenantId", tenantId) + .eq("targetAgentId", agent._id) + .eq("delivered", false), ) .collect(); diff --git a/packages/backend/convex/routines.ts b/packages/backend/convex/routines.ts index a9ca6d6..b7213a5 100644 --- a/packages/backend/convex/routines.ts +++ b/packages/backend/convex/routines.ts @@ -1,6 +1,7 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; import { resolveTenantId } from "./lib/auth"; +import { getAgentBySessionKey } from "./lib/helpers"; // Schedule validator (reusable) const scheduleValidator = v.object({ @@ -28,15 +29,20 @@ export const list = query({ }, handler: async (ctx, args) => { const tenantId = await resolveTenantId(ctx, args); - const routines = await ctx.db - .query("routines") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .collect(); if (args.enabledOnly) { - return routines.filter((r) => r.enabled); + return await ctx.db + .query("routines") + .withIndex("by_tenant_enabled", (q) => + q.eq("tenantId", tenantId).eq("enabled", true), + ) + .collect(); } - return routines; + + return await ctx.db + .query("routines") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); }, }); @@ -47,8 +53,10 @@ export const get = query({ routineId: v.id("routines"), }, handler: async (ctx, args) => { - await resolveTenantId(ctx, args); - return await ctx.db.get(args.routineId); + const tenantId = await resolveTenantId(ctx, args); + const routine = await ctx.db.get(args.routineId); + if (!routine || routine.tenantId !== tenantId) return null; + return routine; }, }); @@ -93,7 +101,10 @@ export const update = mutation({ enabled: v.optional(v.boolean()), }, handler: async (ctx, args) => { - await resolveTenantId(ctx, args); + const tenantId = await resolveTenantId(ctx, args); + const routine = await ctx.db.get(args.routineId); + if (!routine || routine.tenantId !== tenantId) throw new Error("Not found"); + const { routineId, machineToken: _, ...updates } = args; // Filter out undefined values @@ -115,7 +126,9 @@ export const remove = mutation({ routineId: v.id("routines"), }, handler: async (ctx, args) => { - await resolveTenantId(ctx, args); + const tenantId = await resolveTenantId(ctx, args); + const routine = await ctx.db.get(args.routineId); + if (!routine || routine.tenantId !== tenantId) throw new Error("Not found"); await ctx.db.delete(args.routineId); }, }); @@ -129,27 +142,36 @@ export const trigger = mutation({ handler: async (ctx, args) => { const tenantId = await resolveTenantId(ctx, args); const routine = await ctx.db.get(args.routineId); - if (!routine) { + if (!routine || routine.tenantId !== tenantId) { throw new Error("Routine not found"); } // Find Clawe (main leader) to attribute the task creation - const clawe = await ctx.db - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", "agent:main:main")) - .first(); + const clawe = await getAgentBySessionKey(ctx, tenantId, "agent:main:main"); const now = Date.now(); - // Deduplicate: skip if an active task with the same title already exists - const existingTasks = await ctx.db - .query("tasks") - .withIndex("by_createdAt") - .collect(); - const activeStatuses = ["inbox", "assigned", "in_progress", "review"]; - const duplicate = existingTasks.find( - (t) => t.title === routine.title && activeStatuses.includes(t.status), - ); + // Deduplicate: skip if an active task with the same title already exists (within this tenant) + const activeStatuses = [ + "inbox", + "assigned", + "in_progress", + "review", + ] as const; + let duplicate = null; + for (const status of activeStatuses) { + const match = await ctx.db + .query("tasks") + .withIndex("by_tenant_status", (q) => + q.eq("tenantId", tenantId).eq("status", status), + ) + .filter((q) => q.eq(q.field("title"), routine.title)) + .first(); + if (match) { + duplicate = match; + break; + } + } if (duplicate) { // Already an active task for this routine — skip creation await ctx.db.patch(args.routineId, { @@ -215,18 +237,18 @@ export const getDueRoutines = query({ const { currentTimestamp, dayOfWeek, hour, minute } = args; // Get all enabled routines for this tenant - const routines = await ctx.db + const enabledRoutines = await ctx.db .query("routines") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .withIndex("by_tenant_enabled", (q) => + q.eq("tenantId", tenantId).eq("enabled", true), + ) .collect(); - const enabledRoutines = routines.filter((r) => r.enabled); - // Current time as minutes since midnight (in user's timezone) const currentMinuteOfDay = hour * 60 + minute; const dueRoutines: Array<{ - _id: (typeof routines)[0]["_id"]; + _id: (typeof enabledRoutines)[0]["_id"]; title: string; cycleStart: number; }> = []; diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 17e199f..19672d2 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -74,7 +74,9 @@ export default defineSchema({ .index("by_tenant", ["tenantId"]) .index("by_sessionKey", ["sessionKey"]) .index("by_status", ["status"]) - .index("by_lastSeen", ["lastSeen"]), + .index("by_lastSeen", ["lastSeen"]) + .index("by_tenant_sessionKey", ["tenantId", "sessionKey"]) + .index("by_tenant_status", ["tenantId", "status"]), // Tasks - Mission queue with full workflow support tasks: defineTable({ @@ -128,7 +130,8 @@ export default defineSchema({ }) .index("by_tenant", ["tenantId"]) .index("by_status", ["status"]) - .index("by_createdAt", ["createdAt"]), + .index("by_createdAt", ["createdAt"]) + .index("by_tenant_status", ["tenantId", "status"]), // Messages - Task comments and agent communication messages: defineTable({ @@ -149,7 +152,9 @@ export default defineSchema({ .index("by_tenant", ["tenantId"]) .index("by_task", ["taskId"]) .index("by_agent", ["fromAgentId"]) - .index("by_created", ["createdAt"]), + .index("by_created", ["createdAt"]) + .index("by_tenant_agent", ["tenantId", "fromAgentId"]) + .index("by_tenant_task", ["tenantId", "taskId"]), // Notifications - Agent-to-agent coordination notifications: defineTable({ @@ -174,7 +179,13 @@ export default defineSchema({ .index("by_tenant", ["tenantId"]) .index("by_target_undelivered", ["targetAgentId", "delivered"]) .index("by_target", ["targetAgentId"]) - .index("by_createdAt", ["createdAt"]), + .index("by_createdAt", ["createdAt"]) + .index("by_tenant_target", ["tenantId", "targetAgentId"]) + .index("by_tenant_target_undelivered", [ + "tenantId", + "targetAgentId", + "delivered", + ]), // Activities - Audit log / activity feed activities: defineTable({ @@ -188,6 +199,7 @@ export default defineSchema({ v.literal("document_created"), v.literal("agent_heartbeat"), v.literal("notification_sent"), + v.literal("subtask_blocked"), ), agentId: v.optional(v.id("agents")), taskId: v.optional(v.id("tasks")), @@ -199,7 +211,9 @@ export default defineSchema({ .index("by_type", ["type"]) .index("by_agent", ["agentId"]) .index("by_task", ["taskId"]) - .index("by_createdAt", ["createdAt"]), + .index("by_createdAt", ["createdAt"]) + .index("by_tenant_task", ["tenantId", "taskId"]) + .index("by_tenant_type", ["tenantId", "type"]), // Documents - Deliverables and file references documents: defineTable({ @@ -222,7 +236,9 @@ export default defineSchema({ .index("by_tenant", ["tenantId"]) .index("by_task", ["taskId"]) .index("by_type", ["type"]) - .index("by_agent", ["createdBy"]), + .index("by_agent", ["createdBy"]) + .index("by_tenant_type", ["tenantId", "type"]) + .index("by_tenant_task", ["tenantId", "taskId"]), // Business Context - Website/business info for agent context businessContext: defineTable({ @@ -250,7 +266,6 @@ export default defineSchema({ tenantId: v.id("tenants"), type: v.string(), status: v.union(v.literal("connected"), v.literal("disconnected")), - accountId: v.optional(v.string()), connectedAt: v.optional(v.number()), metadata: v.optional(v.any()), }) @@ -293,5 +308,6 @@ export default defineSchema({ updatedAt: v.number(), }) .index("by_tenant", ["tenantId"]) - .index("by_enabled", ["enabled"]), + .index("by_enabled", ["enabled"]) + .index("by_tenant_enabled", ["tenantId", "enabled"]), }); diff --git a/packages/backend/convex/tasks.ts b/packages/backend/convex/tasks.ts index 6eb1640..53e5f4c 100644 --- a/packages/backend/convex/tasks.ts +++ b/packages/backend/convex/tasks.ts @@ -2,6 +2,7 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; import type { Id } from "./_generated/dataModel"; import { resolveTenantId } from "./lib/auth"; +import { getAgentBySessionKey } from "./lib/helpers"; // List all tasks with optional filters export const list = query({ @@ -21,19 +22,25 @@ export const list = query({ handler: async (ctx, args) => { const tenantId = await resolveTenantId(ctx, args); - let tasks = await ctx.db - .query("tasks") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .order("desc") - .collect(); - - if (args.status) { - tasks = tasks.filter((t) => t.status === args.status); + let tasksQuery; + const status = args.status; + if (status) { + tasksQuery = ctx.db + .query("tasks") + .withIndex("by_tenant_status", (q) => + q.eq("tenantId", tenantId).eq("status", status), + ) + .order("desc"); + } else { + tasksQuery = ctx.db + .query("tasks") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .order("desc"); } - if (args.limit) { - tasks = tasks.slice(0, args.limit); - } + const tasks = args.limit + ? await tasksQuery.take(args.limit) + : await tasksQuery.collect(); // Enrich with assignee info and document count return Promise.all( @@ -49,7 +56,9 @@ export const list = query({ // Get deliverable count for this task const documents = await ctx.db .query("documents") - .withIndex("by_task", (q) => q.eq("taskId", task._id)) + .withIndex("by_tenant_task", (q) => + q.eq("tenantId", tenantId).eq("taskId", task._id), + ) .collect(); const documentCount = documents.filter( (d) => d.type === "deliverable", @@ -78,10 +87,7 @@ export const getForAgent = query({ handler: async (ctx, args) => { const tenantId = await resolveTenantId(ctx, args); - const agent = await ctx.db - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey)) - .first(); + const agent = await getAgentBySessionKey(ctx, tenantId, args.sessionKey); if (!agent) { return []; @@ -107,10 +113,10 @@ export const get = query({ taskId: v.id("tasks"), }, handler: async (ctx, args) => { - await resolveTenantId(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const task = await ctx.db.get(args.taskId); - if (!task) return null; + if (!task || task.tenantId !== tenantId) return null; // Get assignees const assignees = task.assigneeIds @@ -126,7 +132,9 @@ export const get = query({ // Get messages (comments) const messages = await ctx.db .query("messages") - .withIndex("by_task", (q) => q.eq("taskId", args.taskId)) + .withIndex("by_tenant_task", (q) => + q.eq("tenantId", tenantId).eq("taskId", args.taskId), + ) .collect(); const messagesWithAuthors = await Promise.all( @@ -145,7 +153,9 @@ export const get = query({ // Get deliverables const deliverables = await ctx.db .query("documents") - .withIndex("by_task", (q) => q.eq("taskId", args.taskId)) + .withIndex("by_tenant_task", (q) => + q.eq("tenantId", tenantId).eq("taskId", args.taskId), + ) .collect(); // Enrich subtasks with assignee info @@ -216,11 +226,11 @@ export const create = mutation({ // Find assignee if provided let assigneeIds: Id<"agents">[] = []; if (args.assigneeSessionKey) { - const sessionKey = args.assigneeSessionKey; - const assignee = await ctx.db - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", sessionKey)) - .first(); + const assignee = await getAgentBySessionKey( + ctx, + tenantId, + args.assigneeSessionKey, + ); if (assignee) { assigneeIds = [assignee._id]; } @@ -230,11 +240,11 @@ export const create = mutation({ let createdBy = undefined; let creatorAgent = null; if (args.createdBySessionKey) { - const sessionKey = args.createdBySessionKey; - creatorAgent = await ctx.db - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", sessionKey)) - .first(); + creatorAgent = await getAgentBySessionKey( + ctx, + tenantId, + args.createdBySessionKey, + ); if (creatorAgent) { createdBy = creatorAgent._id; } @@ -302,7 +312,7 @@ export const updateStatus = mutation({ const tenantId = await resolveTenantId(ctx, args); const now = Date.now(); const task = await ctx.db.get(args.taskId); - if (!task) throw new Error("Task not found"); + if (!task || task.tenantId !== tenantId) throw new Error("Task not found"); const oldStatus = task.status; @@ -310,11 +320,11 @@ export const updateStatus = mutation({ let agentId = undefined; let agentName = "System"; if (args.bySessionKey) { - const sessionKey = args.bySessionKey; - const agent = await ctx.db - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", sessionKey)) - .first(); + const agent = await getAgentBySessionKey( + ctx, + tenantId, + args.bySessionKey, + ); if (agent) { agentId = agent._id; agentName = agent.name; @@ -387,7 +397,7 @@ export const approve = mutation({ const tenantId = await resolveTenantId(ctx, args); const now = Date.now(); const task = await ctx.db.get(args.taskId); - if (!task) throw new Error("Task not found"); + if (!task || task.tenantId !== tenantId) throw new Error("Task not found"); if (task.status !== "review") throw new Error("Task is not in review"); const authorName = args.humanAuthor ?? "Owner"; @@ -448,7 +458,7 @@ export const requestChanges = mutation({ const tenantId = await resolveTenantId(ctx, args); const now = Date.now(); const task = await ctx.db.get(args.taskId); - if (!task) throw new Error("Task not found"); + if (!task || task.tenantId !== tenantId) throw new Error("Task not found"); if (task.status !== "review") throw new Error("Task is not in review"); const authorName = args.humanAuthor ?? "Owner"; @@ -508,15 +518,12 @@ export const assign = mutation({ const tenantId = await resolveTenantId(ctx, args); const now = Date.now(); const task = await ctx.db.get(args.taskId); - if (!task) throw new Error("Task not found"); + if (!task || task.tenantId !== tenantId) throw new Error("Task not found"); // Find assignees const assigneeIds: Id<"agents">[] = []; for (const sessionKey of args.assigneeSessionKeys) { - const agent = await ctx.db - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", sessionKey)) - .first(); + const agent = await getAgentBySessionKey(ctx, tenantId, sessionKey); if (agent) { assigneeIds.push(agent._id); } @@ -525,11 +532,11 @@ export const assign = mutation({ // Find assigner let assignerId = undefined; if (args.bySessionKey) { - const sessionKey = args.bySessionKey; - const assigner = await ctx.db - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", sessionKey)) - .first(); + const assigner = await getAgentBySessionKey( + ctx, + tenantId, + args.bySessionKey, + ); if (assigner) { assignerId = assigner._id; } @@ -585,17 +592,21 @@ export const addComment = mutation({ let authorName = args.humanAuthor ?? "Unknown"; if (args.bySessionKey) { - const sessionKey = args.bySessionKey; - const agent = await ctx.db - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", sessionKey)) - .first(); + const agent = await getAgentBySessionKey( + ctx, + tenantId, + args.bySessionKey, + ); if (agent) { fromAgentId = agent._id; authorName = agent.name; } } + // Verify task belongs to tenant + const task = await ctx.db.get(args.taskId); + if (!task || task.tenantId !== tenantId) throw new Error("Task not found"); + const messageId = await ctx.db.insert("messages", { tenantId, taskId: args.taskId, @@ -633,19 +644,19 @@ export const addSubtask = mutation({ assigneeSessionKey: v.optional(v.string()), }, handler: async (ctx, args) => { - await resolveTenantId(ctx, args); + const tenantId = await resolveTenantId(ctx, args); const task = await ctx.db.get(args.taskId); - if (!task) throw new Error("Task not found"); + if (!task || task.tenantId !== tenantId) throw new Error("Task not found"); // Find assignee if provided let assigneeId = undefined; if (args.assigneeSessionKey) { - const sessionKey = args.assigneeSessionKey; - const assignee = await ctx.db - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", sessionKey)) - .first(); + const assignee = await getAgentBySessionKey( + ctx, + tenantId, + args.assigneeSessionKey, + ); if (assignee) { assigneeId = assignee._id; } @@ -692,7 +703,7 @@ export const updateSubtask = mutation({ const tenantId = await resolveTenantId(ctx, args); const now = Date.now(); const task = await ctx.db.get(args.taskId); - if (!task) throw new Error("Task not found"); + if (!task || task.tenantId !== tenantId) throw new Error("Task not found"); if (!task.subtasks || !task.subtasks[args.subtaskIndex]) { throw new Error("Subtask not found"); } @@ -730,11 +741,11 @@ export const updateSubtask = mutation({ let agentId = undefined; let agentName = "System"; if (args.bySessionKey) { - const sessionKey = args.bySessionKey; - const agent = await ctx.db - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", sessionKey)) - .first(); + const agent = await getAgentBySessionKey( + ctx, + tenantId, + args.bySessionKey, + ); if (agent) { agentId = agent._id; agentName = agent.name; @@ -754,7 +765,7 @@ export const updateSubtask = mutation({ } else if (newStatus === "blocked") { await ctx.db.insert("activities", { tenantId, - type: "subtask_blocked" as any, + type: "subtask_blocked", agentId, taskId: args.taskId, message: `${agentName} blocked "${updatedSubtask.title}" on "${task.title}"${args.blockedReason ? `: ${args.blockedReason}` : ""}`, @@ -795,8 +806,11 @@ export const update = mutation({ ), }, handler: async (ctx, args) => { - await resolveTenantId(ctx, args); - const { machineToken, taskId, ...updates } = args; + const tenantId = await resolveTenantId(ctx, args); + const task = await ctx.db.get(args.taskId); + if (!task || task.tenantId !== tenantId) throw new Error("Task not found"); + + const { machineToken: _, taskId, ...updates } = args; const filteredUpdates = Object.fromEntries( Object.entries(updates).filter(([, value]) => value !== undefined), ); @@ -828,10 +842,7 @@ export const createFromDashboard = mutation({ const now = Date.now(); // Find Clawe (main leader) to attribute the task creation - const clawe = await ctx.db - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", "agent:main:main")) - .first(); + const clawe = await getAgentBySessionKey(ctx, tenantId, "agent:main:main"); const taskId = await ctx.db.insert("tasks", { tenantId, @@ -890,11 +901,11 @@ export const createWithPlan = mutation({ let createdBy = undefined; let creatorName = "System"; if (args.createdBySessionKey) { - const sessionKey = args.createdBySessionKey; - const creator = await ctx.db - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", sessionKey)) - .first(); + const creator = await getAgentBySessionKey( + ctx, + tenantId, + args.createdBySessionKey, + ); if (creator) { createdBy = creator._id; creatorName = creator.name; @@ -904,11 +915,11 @@ export const createWithPlan = mutation({ // Resolve primary assignee const assigneeIds: Id<"agents">[] = []; if (args.assigneeSessionKey) { - const sessionKey = args.assigneeSessionKey; - const assignee = await ctx.db - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", sessionKey)) - .first(); + const assignee = await getAgentBySessionKey( + ctx, + tenantId, + args.assigneeSessionKey, + ); if (assignee) { assigneeIds.push(assignee._id); } @@ -919,11 +930,11 @@ export const createWithPlan = mutation({ for (const st of args.subtasks) { let assigneeId = undefined; if (st.assigneeSessionKey) { - const sessionKey = st.assigneeSessionKey; - const assignee = await ctx.db - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", sessionKey)) - .first(); + const assignee = await getAgentBySessionKey( + ctx, + tenantId, + st.assigneeSessionKey, + ); if (assignee) { assigneeId = assignee._id; // Also add subtask assignees to task-level assignees if not already there @@ -1003,12 +1014,16 @@ export const remove = mutation({ taskId: v.id("tasks"), }, handler: async (ctx, args) => { - await resolveTenantId(ctx, args); + const tenantId = await resolveTenantId(ctx, args); + const task = await ctx.db.get(args.taskId); + if (!task || task.tenantId !== tenantId) throw new Error("Task not found"); // Also delete related messages const messages = await ctx.db .query("messages") - .withIndex("by_task", (q) => q.eq("taskId", args.taskId)) + .withIndex("by_tenant_task", (q) => + q.eq("tenantId", tenantId).eq("taskId", args.taskId), + ) .collect(); for (const msg of messages) { @@ -1018,7 +1033,9 @@ export const remove = mutation({ // Delete related documents const documents = await ctx.db .query("documents") - .withIndex("by_task", (q) => q.eq("taskId", args.taskId)) + .withIndex("by_tenant_task", (q) => + q.eq("tenantId", tenantId).eq("taskId", args.taskId), + ) .collect(); for (const doc of documents) { diff --git a/packages/backend/convex/tenants.ts b/packages/backend/convex/tenants.ts index 3e09183..b737a6d 100644 --- a/packages/backend/convex/tenants.ts +++ b/packages/backend/convex/tenants.ts @@ -1,6 +1,12 @@ import { v } from "convex/values"; import { query, mutation } from "./_generated/server"; -import { getUser, resolveTenantId, validateWatcherToken } from "./lib/auth"; +import { + ensureAccountForUser, + getUser, + getTenantIdFromJwt, + resolveTenantId, + validateWatcherToken, +} from "./lib/auth"; const DEFAULT_TIMEZONE = "America/New_York"; @@ -39,18 +45,25 @@ export const setTimezone = mutation({ // Create a new tenant within an account export const create = mutation({ - args: { - accountId: v.id("accounts"), - }, - handler: async (ctx, args) => { + args: {}, + handler: async (ctx) => { + const user = await getUser(ctx); + const account = await ensureAccountForUser(ctx, user); + + // Idempotent: return existing tenant if one exists + const existing = await ctx.db + .query("tenants") + .withIndex("by_account", (q) => q.eq("accountId", account._id)) + .first(); + if (existing) return existing._id; + const now = Date.now(); - const tenantId = await ctx.db.insert("tenants", { - accountId: args.accountId, + return await ctx.db.insert("tenants", { + accountId: account._id, status: "provisioning", createdAt: now, updatedAt: now, }); - return tenantId; }, }); @@ -80,7 +93,6 @@ export const getForCurrentUser = query({ // Update tenant provisioning status export const updateStatus = mutation({ args: { - tenantId: v.id("tenants"), status: v.union( v.literal("provisioning"), v.literal("active"), @@ -93,12 +105,13 @@ export const updateStatus = mutation({ efsAccessPointId: v.optional(v.string()), }, handler: async (ctx, args) => { - const tenant = await ctx.db.get(args.tenantId); + const tenantId = await getTenantIdFromJwt(ctx); + const tenant = await ctx.db.get(tenantId); if (!tenant) { throw new Error("Tenant not found"); } - await ctx.db.patch(args.tenantId, { + await ctx.db.patch(tenantId, { status: args.status, ...(args.squadhubUrl !== undefined && { squadhubUrl: args.squadhubUrl, diff --git a/packages/backend/convex/users.ts b/packages/backend/convex/users.ts index c65bfc8..5e3e58e 100644 --- a/packages/backend/convex/users.ts +++ b/packages/backend/convex/users.ts @@ -1,5 +1,6 @@ import { query, mutation } from "./_generated/server"; import { v } from "convex/values"; +import { ensureAccountForUser } from "./lib/auth"; export const getOrCreateFromAuth = mutation({ args: {}, @@ -29,29 +30,13 @@ export const getOrCreateFromAuth = mutation({ createdAt: now, updatedAt: now, }); - user = (await ctx.db.get(userId))!; + const created = await ctx.db.get(userId); + if (!created) throw new Error("Failed to create user"); + user = created; } // Ensure account + membership exist - const membership = await ctx.db - .query("accountMembers") - .withIndex("by_user", (q) => q.eq("userId", user._id)) - .first(); - - if (!membership) { - const accountId = await ctx.db.insert("accounts", { - name: user.name ? `${user.name}'s Account` : undefined, - createdAt: now, - updatedAt: now, - }); - - await ctx.db.insert("accountMembers", { - userId: user._id, - accountId, - role: "owner", - createdAt: now, - }); - } + await ensureAccountForUser(ctx, user); return user; }, diff --git a/packages/cli/src/client.ts b/packages/cli/src/client.ts index 76267bd..a88c234 100644 --- a/packages/cli/src/client.ts +++ b/packages/cli/src/client.ts @@ -21,9 +21,12 @@ export const machineToken = process.env.SQUADHUB_TOKEN || ""; * Inject machineToken into Convex function args for tenant scoping. * All tenant-scoped Convex functions accept optional `machineToken`. */ -function injectToken(args: T[]): T[] { - const base = (args[0] ?? {}) as Record; - return [{ ...base, machineToken } as T]; +function injectToken(args: unknown[]): unknown[] { + const base = + args[0] != null && typeof args[0] === "object" + ? (args[0] as Record) + : {}; + return [{ ...base, machineToken }]; } // Common MIME types by extension From 9e08fe76e6cbfebf3ac3a9913e086fe2807e8017 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Tue, 17 Feb 2026 13:55:31 +0200 Subject: [PATCH 8/8] fix --- .../integrations/_components/telegram-integration-card.tsx | 6 +++--- .../integrations/_components/telegram-setup-dialog.tsx | 2 +- apps/web/src/app/setup/telegram/page.tsx | 2 +- packages/backend/convex/channels.ts | 2 -- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-integration-card.tsx b/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-integration-card.tsx index d3abe9f..0dd4347 100644 --- a/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-integration-card.tsx +++ b/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-integration-card.tsx @@ -55,7 +55,7 @@ export const TelegramIntegrationCard = () => {

Telegram

{isConnected - ? `@${channel.accountId}` + ? `@${channel.metadata?.botUsername ?? "connected"}` : "Receive and respond to messages"}

@@ -104,12 +104,12 @@ export const TelegramIntegrationCard = () => { ); diff --git a/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-setup-dialog.tsx b/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-setup-dialog.tsx index 7967625..57e7570 100644 --- a/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-setup-dialog.tsx +++ b/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-setup-dialog.tsx @@ -93,7 +93,7 @@ export const TelegramSetupDialog = ({ await upsertChannel({ type: "telegram", status: "connected", - accountId: botUsername ?? undefined, + metadata: { botUsername: botUsername ?? undefined }, }); setStep("success"); toast.success("Telegram connected successfully"); diff --git a/apps/web/src/app/setup/telegram/page.tsx b/apps/web/src/app/setup/telegram/page.tsx index bc9763e..d03ce19 100644 --- a/apps/web/src/app/setup/telegram/page.tsx +++ b/apps/web/src/app/setup/telegram/page.tsx @@ -101,7 +101,7 @@ export default function TelegramPage() { await upsertChannel({ type: "telegram", status: "connected", - accountId: botUsername ?? undefined, + metadata: { botUsername: botUsername ?? undefined }, }); setStep("success"); }, diff --git a/packages/backend/convex/channels.ts b/packages/backend/convex/channels.ts index 054d1a4..272e8f5 100644 --- a/packages/backend/convex/channels.ts +++ b/packages/backend/convex/channels.ts @@ -34,7 +34,6 @@ export const upsert = mutation({ machineToken: v.optional(v.string()), type: v.string(), status: v.union(v.literal("connected"), v.literal("disconnected")), - accountId: v.optional(v.string()), metadata: v.optional(v.any()), }, handler: async (ctx, args) => { @@ -51,7 +50,6 @@ export const upsert = mutation({ const data = { type: rest.type, status: rest.status, - accountId: rest.accountId, metadata: rest.metadata, connectedAt: rest.status === "connected" ? Date.now() : undefined, };