diff --git a/.env.example b/.env.example index 135c037..9e1268b 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,46 @@ 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 + +# ============================================================================= +# 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) # ============================================================================= -# 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 + +# 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/.gitignore b/.gitignore index 9885419..54db792 100644 --- a/.gitignore +++ b/.gitignore @@ -40,17 +40,21 @@ 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/ -# 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..f87445b 100644 --- a/apps/watcher/.env.example +++ b/apps/watcher/.env.example @@ -4,5 +4,4 @@ # Required variables (from root .env): # - CONVEX_URL -# - AGENCY_URL -# - AGENCY_TOKEN +# - WATCHER_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..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 | -| `AGENCY_URL` | Yes | Agency gateway URL | -| `AGENCY_TOKEN` | Yes | Agency authentication token | +| Variable | Required | Description | +| --------------- | -------- | ---------------------------------------------- | +| `CONVEX_URL` | Yes | Convex deployment URL | +| `WATCHER_TOKEN` | Yes | System-level token for querying active tenants | ## Running @@ -53,7 +48,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 +57,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..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.AGENCY_URL = "http://localhost:18789"; - process.env.AGENCY_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 AGENCY_URL is missing", async () => { + it("exits when WATCHER_TOKEN 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.WATCHER_TOKEN; const mockExit = vi .spyOn(process, "exit") @@ -49,7 +47,7 @@ describe("config", () => { validateEnv(); expect(mockError).toHaveBeenCalledWith( - expect.stringContaining("AGENCY_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.AGENCY_URL = "http://localhost:18789"; - process.env.AGENCY_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.AGENCY_URL = "http://custom:8080"; - process.env.AGENCY_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.agencyUrl).toBe("http://custom:8080"); - expect(config.agencyToken).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 871275c..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", "AGENCY_URL", "AGENCY_TOKEN"]; + const required = ["CONVEX_URL", "WATCHER_TOKEN"]; const missing = required.filter((key) => !process.env[key]); if (missing.length > 0) { @@ -17,7 +17,6 @@ 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 || "", + watcherToken: process.env.WATCHER_TOKEN || "", pollIntervalMs: POLL_INTERVAL_MS, }; diff --git a/apps/watcher/src/index.ts b/apps/watcher/src/index.ts index 2323f9e..022dcdb 100644 --- a/apps/watcher/src/index.ts +++ b/apps/watcher/src/index.ts @@ -1,25 +1,23 @@ /** * 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: iterates over active tenants each loop iteration. + * Queries Convex for all active tenants using WATCHER_TOKEN. * * Environment variables: * CONVEX_URL - Convex deployment URL - * AGENCY_URL - Agency gateway URL - * AGENCY_TOKEN - Agency authentication token + * WATCHER_TOKEN - System-level auth token for querying all tenants */ 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"; +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"; @@ -28,311 +26,106 @@ 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 + * Represents an active tenant for the watcher to service. */ -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} +type TenantInfo = { + id: string; + connection: SquadhubConnection; +}; /** - * Retry a function indefinitely with exponential backoff (capped) + * Get the list of active tenants to service. + * + * Queries Convex `tenants.listActive` for all active tenants + * with their squadhub connection info. */ -async function withRetry( - fn: () => Promise, - label: string, - baseDelayMs = RETRY_BASE_DELAY_MS, -): Promise { - let attempt = 0; +async function getActiveTenants(): Promise { + 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, + }, + }), + ); +} - 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); +const ROUTINE_CHECK_INTERVAL_MS = 10_000; // Check routines every 10 seconds - console.log( - `[watcher] ${label} failed (attempt ${attempt}), retrying in ${delayMs / 1000}s... (${error.message})`, - ); - await sleep(delayMs); - } - } +/** + * Sleep helper + */ +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); } /** - * Register all agents in Convex (upsert - creates or updates) + * 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 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`; - +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 { - await convex.mutation(api.agents.upsert, { - name: agent.name, - role: agent.role, - sessionKey, - emoji: agent.emoji, + const taskId = await convex.mutation(api.routines.trigger, { + machineToken, + routineId: routine._id, }); console.log( - `[watcher] ✓ ${agent.name} ${agent.emoji} registered (${sessionKey})`, + `[watcher] ✓ Triggered routine "${routine.title}" → task ${taskId}`, ); } catch (err) { console.error( - `[watcher] Failed to register ${agent.name}:`, + `[watcher] Failed to trigger routine "${routine.title}":`, err instanceof Error ? err.message : err, ); } } - - console.log("[watcher] Agent registration complete.\n"); } /** - * Setup heartbeat crons for all agents (if not already configured) + * Check routines for all active tenants. */ -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, - }, - 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"); -} - -/** - * Seed initial routines if none exist - */ -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..."); +async function checkRoutines(): Promise { + const tenants = await getActiveTenants(); - for (const routine of SEED_ROUTINES) { + for (const tenant of tenants) { try { - await convex.mutation(api.routines.create, routine); - console.log(`[watcher] ✓ Created routine: ${routine.title}`); + await checkRoutinesForTenant(tenant.connection.squadhubToken); } catch (err) { console.error( - `[watcher] Failed to create routine "${routine.title}":`, + `[watcher] Error checking routines for tenant ${tenant.id}:`, err instanceof Error ? err.message : err, ); } } - - console.log("[watcher] Routine seeding complete.\n"); -} - -/** - * Check for due routines and trigger them. - * - * 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 - const timezone = - (await convex.query(api.settings.getTimezone)) ?? 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, { - currentTimestamp, - dayOfWeek, - hour, - minute, - }); - - // Trigger each due routine - for (const routine of dueRoutines) { - try { - const taskId = await convex.mutation(api.routines.trigger, { - 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, - ); - } - } - } catch (err) { - console.error( - "[watcher] Error checking routines:", - err instanceof Error ? err.message : err, - ); - } } /** @@ -363,12 +156,18 @@ 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 { + const { squadhubToken: machineToken } = connection; + try { // Get undelivered notifications for this agent const notifications = await convex.query(api.notifications.getUndelivered, { + machineToken, sessionKey, }); @@ -385,12 +184,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, notificationIds: [notification._id], }); @@ -419,15 +219,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 agent of agents) { - if (agent.sessionKey) { - await deliverToAgent(agent.sessionKey); + 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(tenant.connection, agent.sessionKey); + } } } } @@ -476,21 +282,11 @@ 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] 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/package.json b/apps/web/package.json index 8b031cc..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", @@ -36,11 +37,15 @@ "@tiptap/suggestion": "^3.15.3", "@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", + "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)/@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 { 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)/agents/page.tsx b/apps/web/src/app/(dashboard)/agents/page.tsx index 960180e..a9a6602 100644 --- a/apps/web/src/app/(dashboard)/agents/page.tsx +++ b/apps/web/src/app/(dashboard)/agents/page.tsx @@ -44,7 +44,7 @@ const formatLastSeen = (timestamp?: number): string => { }; 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..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 @@ -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(""); @@ -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/(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..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 @@ -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 ; @@ -55,7 +55,7 @@ export const TelegramIntegrationCard = () => {

Telegram

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

@@ -94,7 +94,7 @@ export const TelegramIntegrationCard = () => { )} {isOffline && (

- Agency is offline + Squadhub is offline

)}
@@ -104,12 +104,12 @@ export const TelegramIntegrationCard = () => { ); 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..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 @@ -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(""); @@ -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"); @@ -214,10 +214,11 @@ 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 +355,11 @@ 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/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 217200c..57da4d3 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 }); } @@ -49,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"; @@ -63,14 +68,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 }); } @@ -95,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/api/chat/abort/route.spec.ts b/apps/web/src/app/api/chat/abort/route.spec.ts index e2399cf..f10ba65 100644 --- a/apps/web/src/app/api/chat/abort/route.spec.ts +++ b/apps/web/src/app/api/chat/abort/route.spec.ts @@ -5,7 +5,7 @@ import { POST } from "./route"; // Mock the shared client const mockRequest = vi.fn(); -vi.mock("@clawe/shared/agency", () => ({ +vi.mock("@clawe/shared/squadhub", () => ({ getSharedClient: vi.fn(async () => ({ 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..6c76a70 100644 --- a/apps/web/src/app/api/chat/history/route.spec.ts +++ b/apps/web/src/app/api/chat/history/route.spec.ts @@ -5,7 +5,7 @@ import { GET } from "./route"; // Mock the shared client const mockRequest = vi.fn(); -vi.mock("@clawe/shared/agency", () => ({ +vi.mock("@clawe/shared/squadhub", () => ({ getSharedClient: vi.fn(async () => ({ 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..7298027 --- /dev/null +++ b/apps/web/src/app/api/tenant/provision/route.ts @@ -0,0 +1,135 @@ +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 { setupTenant } from "@/lib/squadhub/setup"; + +/** + * POST /api/tenant/provision + * + * Authenticated route that ensures the current user has a provisioned tenant. + * Idempotent — safe to call multiple times. + * + * 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 const POST = async (request: NextRequest) => { + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL; + if (!convexUrl) { + return NextResponse.json( + { error: "NEXT_PUBLIC_CONVEX_URL not configured" }, + { status: 500 }, + ); + } + + // 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 { + // 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, {}); + + // Provision infrastructure (dev: reads env vars) + const provisionResult = await provisioner.provision({ + tenantId: tenantIdToProvision, + accountId: account._id, + convexUrl, + }); + + // Update tenant with connection details + await convex.mutation(api.tenants.updateStatus, { + 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, {}); + + 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, + squadhubToken: tenant.squadhubToken, + }; + + const result = await setupTenant(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, + 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/auth/login/page.tsx b/apps/web/src/app/auth/login/page.tsx new file mode 100644 index 0000000..ba88fce --- /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 auth isn't ready yet. + // The root page handles routing 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..2cb9734 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -3,9 +3,11 @@ 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"; +import { ApiClientProvider } from "@/providers/api-client-provider"; import { Toaster } from "@clawe/ui/components/sonner"; const geistSans = localFont({ @@ -27,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({ @@ -41,17 +43,21 @@ 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 a6f7295..ae44797 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,16 +1,48 @@ "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.settings.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 (!isAuthenticated || userReady) return; + getOrCreateUser() + .then(() => setUserReady(true)) + .catch(() => setUserReady(true)); + }, [isAuthenticated, userReady, getOrCreateUser]); + + const tenant = useQuery( + api.tenants.getForCurrentUser, + isAuthenticated && userReady ? {} : "skip", + ); + + const isOnboardingComplete = useQuery( + api.accounts.isOnboardingComplete, + isAuthenticated && userReady ? {} : "skip", + ); + + useEffect(() => { + 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) { @@ -18,7 +50,7 @@ export default function Home() { } else { router.replace("/setup"); } - }, [isOnboardingComplete, router]); + }, [tenant, 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 1d88cca..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,10 +16,14 @@ 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 canContinue = businessContext?.approved === true; + 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 2959b53..69a0302 100644 --- a/apps/web/src/app/setup/complete/page.tsx +++ b/apps/web/src/app/setup/complete/page.tsx @@ -14,13 +14,13 @@ const CURRENT_STEP = 4; export default function CompletePage() { const router = useRouter(); - const completeOnboarding = useMutation(api.settings.completeOnboarding); + const completeOnboarding = useMutation(api.accounts.completeOnboarding); const [isCompleting, setIsCompleting] = useState(false); 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/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/provisioning/page.tsx b/apps/web/src/app/setup/provisioning/page.tsx new file mode 100644 index 0000000..8029654 --- /dev/null +++ b/apps/web/src/app/setup/provisioning/page.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useCallback, 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]); + + 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; + // 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(); + }, [isAuthenticated, tenant, provision]); + + if (error) { + return ( +
+
+
+ +

Setup failed

+

{error}

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

Setting up your workspace...

+

+ This will only take a moment. +

+
+
+ ); +} diff --git a/apps/web/src/app/setup/telegram/page.tsx b/apps/web/src/app/setup/telegram/page.tsx index f12228c..d03ce19 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(""); @@ -101,7 +101,7 @@ export default function TelegramPage() { await upsertChannel({ type: "telegram", status: "connected", - accountId: botUsername ?? undefined, + metadata: { botUsername: botUsername ?? undefined }, }); setStep("success"); }, @@ -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/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-agency-status.ts b/apps/web/src/hooks/use-agency-status.ts deleted file mode 100644 index 7dab5ea..0000000 --- a/apps/web/src/hooks/use-agency-status.ts +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; -import axios from "axios"; - -type AgencyStatus = "active" | "down" | "idle"; - -const checkAgencyHealth = async (): Promise => { - try { - const { data } = await axios.post( - "/api/agency/health", - {}, - { timeout: 5000 }, - ); - return data.ok === true; - } catch { - return false; - } -}; - -export const useAgencyStatus = () => { - const { data: isHealthy, isLoading } = useQuery({ - queryKey: ["agency-health"], - queryFn: checkAgencyHealth, - refetchInterval: 30000, // Check every 30 seconds - staleTime: 10000, - retry: false, - }); - - const status: AgencyStatus = isLoading - ? "idle" - : isHealthy - ? "active" - : "down"; - - return { status, isHealthy, isLoading }; -}; 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-onboarding-guard.ts b/apps/web/src/hooks/use-onboarding-guard.ts index 6c98b7c..5bbee25 100644 --- a/apps/web/src/hooks/use-onboarding-guard.ts +++ b/apps/web/src/hooks/use-onboarding-guard.ts @@ -1,40 +1,123 @@ "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. */ export const useRequireOnboarding = () => { const router = useRouter(); - const isComplete = useQuery(api.settings.isOnboardingComplete); + const { isAuthenticated, isLoading: authLoading } = useAuth(); + + const tenant = useQuery( + api.tenants.getForCurrentUser, + isAuthenticated ? {} : "skip", + ); + + const tenantActive = tenant?.status === "active"; + + const isComplete = useQuery( + api.accounts.isOnboardingComplete, + isAuthenticated ? {} : "skip", + ); useEffect(() => { + if (!authLoading && !isAuthenticated) { + 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, router]); + }, [isComplete, isAuthenticated, authLoading, tenant, tenantActive, router]); - return { isLoading: isComplete === undefined, isComplete }; + return { + 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 isComplete = useQuery(api.settings.isOnboardingComplete); + 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.accounts.isOnboardingComplete, + isAuthenticated ? {} : "skip", + ); useEffect(() => { + if (!authLoading && !isAuthenticated) { + 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, router]); + }, [ + isComplete, + isAuthenticated, + authLoading, + tenant, + tenantActive, + pathname, + router, + ]); - return { isLoading: isComplete === undefined, isComplete }; + return { + isLoading: + authLoading || + tenant === undefined || + (isAuthenticated && isComplete === undefined), + isComplete, + }; }; diff --git a/apps/web/src/hooks/use-squadhub-status.ts b/apps/web/src/hooks/use-squadhub-status.ts new file mode 100644 index 0000000..242e772 --- /dev/null +++ b/apps/web/src/hooks/use-squadhub-status.ts @@ -0,0 +1,37 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useApiClient } from "@/hooks/use-api-client"; + +type SquadhubStatus = "active" | "down" | "idle"; + +export const useSquadhubStatus = () => { + const apiClient = useApiClient(); + + const { data: isHealthy, isLoading } = useQuery({ + queryKey: ["squadhub-health"], + 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, + }); + + const status: SquadhubStatus = isLoading + ? "idle" + : isHealthy + ? "active" + : "down"; + + return { status, isHealthy, isLoading }; +}; 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/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/nextauth-config.ts b/apps/web/src/lib/auth/nextauth-config.ts new file mode 100644 index 0000000..9f728ed --- /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 = String(credentials.email ?? ""); + 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: String(token.email ?? ""), + email: String(token.email ?? ""), + name: String(token.name ?? ""), + }) + .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 = String(token.email); + if (token.name) session.user.name = String(token.name); + 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/lib/auth/verify-token.ts b/apps/web/src/lib/auth/verify-token.ts new file mode 100644 index 0000000..496ae59 --- /dev/null +++ b/apps/web/src/lib/auth/verify-token.ts @@ -0,0 +1,107 @@ +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; +} + +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). +// --------------------------------------------------------------------------- + +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 (!isVerifiedToken(payload)) return null; + return payload; + } 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 (!isVerifiedToken(payload)) return null; + return payload; + } 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/agency/actions.spec.ts b/apps/web/src/lib/squadhub/actions.spec.ts similarity index 83% rename from apps/web/src/lib/agency/actions.spec.ts rename to apps/web/src/lib/squadhub/actions.spec.ts index 55f332c..8f1b6d6 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,13 @@ 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 +81,7 @@ describe("Agency Actions", () => { }); }); - describe("checkAgencyHealth", () => { + describe("checkSquadhubHealth", () => { it("returns health status", async () => { vi.mocked(checkHealth).mockResolvedValueOnce({ ok: true, @@ -85,7 +91,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/setup.ts b/apps/web/src/lib/squadhub/setup.ts new file mode 100644 index 0000000..95a565a --- /dev/null +++ b/apps/web/src/lib/squadhub/setup.ts @@ -0,0 +1,256 @@ +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 setupTenant( + connection: SquadhubConnection, + convexUrl: string, + authToken?: string, +): Promise { + const convex = new ConvexHttpClient(convexUrl); + if (authToken) { + convex.setAuth(authToken); + } + 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/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 new file mode 100644 index 0000000..61d104b --- /dev/null +++ b/apps/web/src/providers/auth-provider.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + 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"; + +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 { + await getCurrentUser(); + const attributes = await fetchUserAttributes(); + setUser({ + email: attributes.email ?? "", + name: attributes.name, + }); + setIsAuthenticated(true); + } catch { + setUser(null); + setIsAuthenticated(false); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + 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", + }, + }, + }, + }, + }); + + Hub.listen("auth", ({ payload }) => { + switch (payload.event) { + case "signedIn": + case "signInWithRedirect": + checkAuthState(); + break; + case "signedOut": + setUser(null); + setIsAuthenticated(false); + setIsLoading(false); + break; + } + }); + + checkAuthState(); + }, [checkAuthState]); + + const signIn = useCallback(async () => { + await signInWithRedirect({ provider: "Google" }); + }, []); + + const signOut = useCallback(async () => { + 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: (args: { + forceRefreshToken: boolean; + }) => Promise = useCallback(async () => { + if (!isAuthenticated) return null; + return fetchAuthToken(); + }, [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..3595549 --- /dev/null +++ b/apps/web/src/proxy.ts @@ -0,0 +1,63 @@ +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"]; + +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; + + if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) { + return NextResponse.next(); + } + + // 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)); + } + + const payload = await verifyToken(token); + if (!payload) { + return isApiRoute + ? unauthorized("Invalid token") + : NextResponse.redirect(new URL("/auth/login", request.url)); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], +}; 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..a44b090 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,12 @@ services: dockerfile: apps/watcher/Dockerfile environment: - CONVEX_URL=${CONVEX_URL} - - AGENCY_URL=http://agency:18789 - - AGENCY_TOKEN=${AGENCY_TOKEN} + - WATCHER_TOKEN=${WATCHER_TOKEN} depends_on: - agency: + squadhub: condition: service_healthy restart: unless-stopped volumes: - agency-data: + squadhub-data: driver: local diff --git a/docker/agency/scripts/pair-device.js b/docker/agency/scripts/pair-device.js deleted file mode 100644 index 130657e..0000000 --- a/docker/agency/scripts/pair-device.js +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env node - -/** - * Auto-pair the local CLI device with the gateway. - * - * 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. - */ - -const fs = require("fs"); -const crypto = require("crypto"); -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 pairedFile = path.join(devicesDir, "paired.json"); - -if (!fs.existsSync(identityFile)) { - console.log("==> No device identity found, skipping device registration."); - process.exit(0); -} - -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, - }, - }, - 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.`); 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 83% rename from docker/agency/entrypoint.sh rename to docker/squadhub/entrypoint.sh index 10032ed..0e01c04 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 @@ -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/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/squadhub/scripts/pair-device.js b/docker/squadhub/scripts/pair-device.js new file mode 100644 index 0000000..acd0c7a --- /dev/null +++ b/docker/squadhub/scripts/pair-device.js @@ -0,0 +1,189 @@ +#!/usr/bin/env node + +/** + * Auto-approve device pairing requests. + * + * 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"); +const crypto = require("crypto"); +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"); + +const INTERVAL_MS = 60_000; + +function readJson(filePath) { + try { + if (!fs.existsSync(filePath)) return {}; + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return {}; + } +} + +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: 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/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 90% rename from docker/agency/templates/workspaces/clawe/SOUL.md rename to docker/squadhub/templates/workspaces/clawe/SOUL.md index c4df86d..61f92ac 100644 --- a/docker/agency/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/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..412eba4 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,12 @@ "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", - "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/accounts.ts b/packages/backend/convex/accounts.ts new file mode 100644 index 0000000..35f448c --- /dev/null +++ b/packages/backend/convex/accounts.ts @@ -0,0 +1,95 @@ +import { query, mutation } from "./_generated/server"; +import { getUser, ensureAccountForUser } 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); + return ensureAccountForUser(ctx, user); + }, +}); + +/** + * 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); + }, +}); + +/** + * 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: {}, + 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: {}, + 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/activities.ts b/packages/backend/convex/activities.ts index cd2a9b9..d4b3bb9 100644 --- a/packages/backend/convex/activities.ts +++ b/packages/backend/convex/activities.ts @@ -1,5 +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({ @@ -7,31 +9,37 @@ 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; let activities; - if (args.taskId) { - // Filter by task + if (filters.taskId) { activities = await ctx.db .query("activities") - .withIndex("by_task", (q) => q.eq("taskId", args.taskId)) + .withIndex("by_tenant_task", (q) => + q.eq("tenantId", tenantId).eq("taskId", filters.taskId), + ) .order("desc") .take(limit); - } else if (args.agentId) { - // Filter by agent - activities = await ctx.db + } else if (filters.agentId) { + // No compound index for agentId — filter in JS + const allActivities = await ctx.db .query("activities") - .withIndex("by_agent", (q) => q.eq("agentId", args.agentId)) + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .order("desc") - .take(limit); + .collect(); + activities = allActivities + .filter((a) => a.agentId === filters.agentId) + .slice(0, limit); } else { - // All activities activities = await ctx.db .query("activities") - .withIndex("by_createdAt") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .order("desc") .take(limit); } @@ -77,13 +85,18 @@ 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("activities") - .withIndex("by_type", (q) => q.eq("type", args.type)) + .withIndex("by_tenant_type", (q) => + q.eq("tenantId", tenantId).eq("type", filters.type), + ) .order("desc") .take(limit); }, @@ -106,14 +119,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 resolveTenantId(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,27 +154,32 @@ 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 resolveTenantId(ctx, args); + const { machineToken: _, ...fields } = args; + let agentId = undefined; - if (args.sessionKey) { - const sessionKey = args.sessionKey; - const agent = await ctx.db - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", sessionKey)) - .first(); + if (fields.sessionKey) { + const agent = await getAgentBySessionKey( + ctx, + tenantId, + fields.sessionKey, + ); if (agent) { agentId = agent._id; } } 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..125b2ea 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 } from "./lib/auth"; +import { getAgentBySessionKey } from "./lib/helpers"; 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) => { - 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; }, }); // 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 - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey)) - .first(); + const tenantId = await resolveTenantId(ctx, args); + return await getAgentBySessionKey(ctx, tenantId, args.sessionKey); }, }); // List agents by status export const listByStatus = query({ - args: { status: agentStatusValidator }, + args: { status: agentStatusValidator, machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { + const tenantId = await resolveTenantId(ctx, args); return await ctx.db .query("agents") - .withIndex("by_status", (q) => q.eq("status", args.status)) + .withIndex("by_tenant_status", (q) => + q.eq("tenantId", tenantId).eq("status", args.status), + ) .collect(); }, }); // 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,33 @@ 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 resolveTenantId(ctx, args); + const { machineToken: _, ...rest } = args; const now = Date.now(); - const existing = await ctx.db - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey)) - .first(); + const existing = await getAgentBySessionKey(ctx, tenantId, 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 +132,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 resolveTenantId(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 +157,14 @@ export const updateStatus = mutation({ args: { id: v.id("agents"), status: agentStatusValidator, + machineToken: v.optional(v.string()), }, handler: async (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(), @@ -149,14 +174,12 @@ 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 resolveTenantId(ctx, args); const now = Date.now(); - 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) { throw new Error(`Agent not found: ${args.sessionKey}`); @@ -178,6 +201,7 @@ export const heartbeat = mutation({ type: "agent_heartbeat", agentId: agent._id, message: `${agent.name} is online`, + tenantId: agent.tenantId, createdAt: now, }); } @@ -191,12 +215,11 @@ 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 - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey)) - .first(); + const tenantId = await resolveTenantId(ctx, args); + const agent = await getAgentBySessionKey(ctx, tenantId, args.sessionKey); if (!agent) { throw new Error(`Agent not found: ${args.sessionKey}`); @@ -214,12 +237,11 @@ 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 - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey)) - .first(); + const tenantId = await resolveTenantId(ctx, args); + const agent = await getAgentBySessionKey(ctx, tenantId, args.sessionKey); if (!agent) { throw new Error(`Agent not found: ${args.sessionKey}`); @@ -241,9 +263,15 @@ 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; + 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), ); @@ -256,8 +284,13 @@ 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) => { + 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/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 db23d7a..a85c2a8 100644 --- a/packages/backend/convex/businessContext.ts +++ b/packages/backend/convex/businessContext.ts @@ -1,26 +1,35 @@ import { query, mutation } from "./_generated/server"; import { v } from "convex/values"; +import { resolveTenantId } 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(); }, }); /** - * 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: {}, - handler: async (ctx) => { - const context = await ctx.db.query("businessContext").first(); - return context?.approved === true; + 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 !== null; }, }); @@ -30,6 +39,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()), @@ -44,20 +54,22 @@ export const save = mutation({ tone: v.optional(v.string()), }), ), - approved: v.optional(v.boolean()), }, handler: async (ctx, args) => { + const tenantId = await resolveTenantId(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, updatedAt: now, }; @@ -68,41 +80,24 @@ export const save = mutation({ return await ctx.db.insert("businessContext", { ...data, + tenantId, createdAt: now, }); }, }); -/** - * Mark the current business context as approved. - */ -export const approve = mutation({ - args: {}, - handler: async (ctx) => { - const existing = await ctx.db.query("businessContext").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. */ 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 resolveTenantId(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..272e8f5 100644 --- a/packages/backend/convex/channels.ts +++ b/packages/backend/convex/channels.ts @@ -1,42 +1,57 @@ import { query, mutation } from "./_generated/server"; import { v } from "convex/values"; +import { resolveTenantId } 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 resolveTenantId(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, + metadata: rest.metadata, + connectedAt: rest.status === "connected" ? Date.now() : undefined, }; if (existing) { @@ -44,16 +59,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 resolveTenantId(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/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 e166dd5..c097fc0 100644 --- a/packages/backend/convex/documents.ts +++ b/packages/backend/convex/documents.ts @@ -1,9 +1,11 @@ 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({ - args: {}, + args: { machineToken: v.optional(v.string()) }, handler: async (ctx) => { return await ctx.storage.generateUploadUrl(); }, @@ -21,30 +23,45 @@ 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 + let docsQuery; + const type = args.type; + if (type) { + docsQuery = ctx.db .query("documents") - .withIndex("by_type", (q) => q.eq("type", type)) - .order("desc") - .take(limit); + .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 await ctx.db.query("documents").order("desc").take(limit); + return await docsQuery.take(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 tenantId = await resolveTenantId(ctx, args); + const task = await ctx.db.get(args.taskId); + if (!task || task.tenantId !== tenantId) return []; + 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(); // Enrich with creator info and file URL @@ -69,9 +86,12 @@ 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) => { - 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; }, }); @@ -89,29 +109,32 @@ 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 resolveTenantId(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), - ) - .first(); + const agent = await getAgentBySessionKey( + ctx, + tenantId, + rest.createdBySessionKey, + ); 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 +143,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 +161,31 @@ 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 resolveTenantId(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), - ) - .first(); + const agent = await getAgentBySessionKey( + ctx, + tenantId, + rest.createdBySessionKey, + ); 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 +194,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 +211,14 @@ 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; + 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), ); @@ -199,8 +232,11 @@ 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) => { + 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 new file mode 100644 index 0000000..a2532dc --- /dev/null +++ b/packages/backend/convex/lib/auth.ts @@ -0,0 +1,167 @@ +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. + * 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. + */ +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"); + } +} + +/** + * Unified tenant resolver for all tenant-scoped functions. + * + * Resolution order: + * 1. If `machineToken` provided → look up tenant by squadhub token + * 2. Otherwise → resolve from JWT identity + * + * Works for both queries and mutations (only needs read access). + */ +export async function resolveTenantId( + ctx: ReadCtx, + args: { machineToken?: string }, +): Promise> { + if (args.machineToken) { + return getTenantIdFromToken(ctx, args.machineToken); + } + + 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 dbf8954..ce7ec99 100644 --- a/packages/backend/convex/messages.ts +++ b/packages/backend/convex/messages.ts @@ -1,13 +1,21 @@ 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") }, + args: { taskId: v.id("tasks"), machineToken: v.optional(v.string()) }, handler: async (ctx, args) => { + const tenantId = await resolveTenantId(ctx, args); + const task = await ctx.db.get(args.taskId); + if (!task || task.tenantId !== tenantId) return []; + 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(); // Enrich with author info @@ -38,20 +46,22 @@ 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 - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey)) - .first(); + const tenantId = await resolveTenantId(ctx, args); + + const agent = await getAgentBySessionKey(ctx, tenantId, args.sessionKey); if (!agent) { return []; } - let query = ctx.db + const query = ctx.db .query("messages") - .withIndex("by_agent", (q) => q.eq("fromAgentId", agent._id)) + .withIndex("by_tenant_agent", (q) => + q.eq("tenantId", tenantId).eq("fromAgentId", agent._id), + ) .order("desc"); return args.limit ? await query.take(args.limit) : await query.collect(); @@ -60,13 +70,14 @@ export const listByAgent = query({ // 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 .query("messages") - .withIndex("by_created") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .order("desc") .take(limit); @@ -113,34 +124,41 @@ 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 resolveTenantId(ctx, args); + const { machineToken: _, ...rest } = args; const now = Date.now(); let fromAgentId = undefined; - if (args.fromSessionKey) { - const sessionKey = args.fromSessionKey; - const agent = await ctx.db - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", sessionKey)) - .first(); + if (rest.fromSessionKey) { + const agent = await getAgentBySessionKey( + ctx, + tenantId, + rest.fromSessionKey, + ); if (agent) { fromAgentId = agent._id; } } 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) { + const task = await ctx.db.get(rest.taskId); + if (task && task.tenantId === tenantId) { + await ctx.db.patch(rest.taskId, { updatedAt: now }); + } } return messageId; @@ -149,8 +167,11 @@ 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) => { + 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 5b22f71..b57916c 100644 --- a/packages/backend/convex/notifications.ts +++ b/packages/backend/convex/notifications.ts @@ -1,15 +1,16 @@ 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({ - 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 - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey)) - .first(); + const tenantId = await resolveTenantId(ctx, args); + + // Find the agent within this tenant + const agent = await getAgentBySessionKey(ctx, tenantId, args.sessionKey); if (!agent) { return []; @@ -18,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(); @@ -59,12 +63,12 @@ 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 - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey)) - .first(); + const tenantId = await resolveTenantId(ctx, args); + + const agent = await getAgentBySessionKey(ctx, tenantId, args.sessionKey); if (!agent) { return []; @@ -72,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 @@ -87,11 +93,17 @@ export const getForAgent = query({ export const markDelivered = mutation({ args: { notificationIds: v.array(v.id("notifications")), + machineToken: v.optional(v.string()), }, handler: async (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, @@ -116,16 +128,18 @@ 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 resolveTenantId(ctx, args); const now = Date.now(); - const targetKey = args.targetSessionKey; - // Find target agent - const targetAgent = await ctx.db - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", targetKey)) - .first(); + // Find target agent within this tenant + const targetAgent = await getAgentBySessionKey( + ctx, + tenantId, + args.targetSessionKey, + ); if (!targetAgent) { throw new Error(`Target agent not found: ${args.targetSessionKey}`); @@ -134,11 +148,11 @@ 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 = await getAgentBySessionKey( + ctx, + tenantId, + args.sourceSessionKey, + ); if (sourceAgent) { sourceAgentId = sourceAgent._id; } @@ -146,6 +160,7 @@ export const send = mutation({ // Create notification const notificationId = await ctx.db.insert("notifications", { + tenantId, targetAgentId: targetAgent._id, sourceAgentId, type: args.type, @@ -157,6 +172,7 @@ export const send = mutation({ // Log activity await ctx.db.insert("activities", { + tenantId, type: "notification_sent", agentId: sourceAgentId, taskId: args.taskId, @@ -184,32 +200,36 @@ 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 resolveTenantId(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 +248,11 @@ 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 - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey)) - .first(); + const tenantId = await resolveTenantId(ctx, args); + + const agent = await getAgentBySessionKey(ctx, tenantId, args.sessionKey); if (!agent) { return 0; @@ -241,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 8351810..b7213a5 100644 --- a/packages/backend/convex/routines.ts +++ b/packages/backend/convex/routines.ts @@ -1,5 +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({ @@ -21,29 +23,47 @@ 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); + if (args.enabledOnly) { return await ctx.db .query("routines") - .withIndex("by_enabled", (q) => q.eq("enabled", true)) + .withIndex("by_tenant_enabled", (q) => + q.eq("tenantId", tenantId).eq("enabled", true), + ) .collect(); } - return await ctx.db.query("routines").collect(); + + return await ctx.db + .query("routines") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); }, }); // 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) => { - 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; }, }); // 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 +71,16 @@ export const create = mutation({ color: v.string(), }, handler: async (ctx, args) => { + const tenantId = await resolveTenantId(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 +91,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 +101,11 @@ export const update = mutation({ enabled: v.optional(v.boolean()), }, handler: async (ctx, args) => { - const { routineId, ...updates } = 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 const filteredUpdates = Object.fromEntries( @@ -93,38 +121,57 @@ 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) => { + 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); }, }); // 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 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, { @@ -136,6 +183,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 +201,7 @@ export const trigger = mutation({ // Log activity await ctx.db.insert("activities", { + tenantId, type: "task_created", agentId: clawe?._id, taskId, @@ -177,30 +226,34 @@ 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 - const routines = await ctx.db + // Get all enabled routines for this tenant + const enabledRoutines = await ctx.db .query("routines") - .withIndex("by_enabled", (q) => q.eq("enabled", true)) + .withIndex("by_tenant_enabled", (q) => + q.eq("tenantId", tenantId).eq("enabled", true), + ) .collect(); // 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; }> = []; - 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..19672d2 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()), + onboardingComplete: v.optional(v.boolean()), + 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()), + }), + ), + 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,16 @@ 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"]), + .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({ + tenantId: v.id("tenants"), title: v.string(), description: v.optional(v.string()), status: v.union( @@ -70,11 +128,14 @@ export default defineSchema({ updatedAt: v.number(), completedAt: v.optional(v.number()), }) + .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({ + 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 +149,16 @@ 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"]), + .index("by_created", ["createdAt"]) + .index("by_tenant_agent", ["tenantId", "fromAgentId"]) + .index("by_tenant_task", ["tenantId", "taskId"]), // 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 +176,20 @@ 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"]), + .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({ + tenantId: v.id("tenants"), type: v.union( v.literal("task_created"), v.literal("task_assigned"), @@ -126,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")), @@ -133,13 +207,17 @@ 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"]) - .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({ + 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 +233,16 @@ 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"]), + .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({ + tenantId: v.id("tenants"), url: v.string(), name: v.optional(v.string()), description: v.optional(v.string()), @@ -182,23 +257,24 @@ 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"]), // 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 +306,8 @@ export default defineSchema({ // Metadata createdAt: v.number(), updatedAt: v.number(), - }).index("by_enabled", ["enabled"]), + }) + .index("by_tenant", ["tenantId"]) + .index("by_enabled", ["enabled"]) + .index("by_tenant_enabled", ["tenantId", "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..53e5f4c 100644 --- a/packages/backend/convex/tasks.ts +++ b/packages/backend/convex/tasks.ts @@ -1,10 +1,13 @@ 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({ args: { + machineToken: v.optional(v.string()), status: v.optional( v.union( v.literal("inbox"), @@ -17,26 +20,27 @@ export const list = query({ limit: v.optional(v.number()), }, handler: async (ctx, args) => { - let tasks; + const tenantId = await resolveTenantId(ctx, args); - if (args.status) { - const status = args.status; - tasks = await ctx.db + let tasksQuery; + const status = args.status; + if (status) { + tasksQuery = ctx.db .query("tasks") - .withIndex("by_status", (q) => q.eq("status", status)) - .order("desc") - .collect(); + .withIndex("by_tenant_status", (q) => + q.eq("tenantId", tenantId).eq("status", status), + ) + .order("desc"); } else { - tasks = await ctx.db + tasksQuery = ctx.db .query("tasks") - .withIndex("by_createdAt") - .order("desc") - .collect(); + .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( @@ -52,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", @@ -74,21 +80,23 @@ 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 agent = await ctx.db - .query("agents") - .withIndex("by_sessionKey", (q) => q.eq("sessionKey", args.sessionKey)) - .first(); + const tenantId = await resolveTenantId(ctx, args); + + const agent = await getAgentBySessionKey(ctx, tenantId, args.sessionKey); if (!agent) { 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,10 +108,15 @@ 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) => { + 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 @@ -119,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( @@ -138,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 @@ -188,6 +205,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,16 +220,17 @@ export const create = mutation({ createdBySessionKey: v.optional(v.string()), }, handler: async (ctx, args) => { + const tenantId = await resolveTenantId(ctx, args); const now = Date.now(); // 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]; } @@ -221,17 +240,18 @@ 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; } } const taskId = await ctx.db.insert("tasks", { + tenantId, title: args.title, description: args.description, status: assigneeIds.length > 0 ? "assigned" : "inbox", @@ -244,6 +264,7 @@ export const create = mutation({ // Log activity await ctx.db.insert("activities", { + tenantId, type: "task_created", agentId: createdBy, taskId, @@ -257,6 +278,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 +297,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,9 +309,10 @@ export const updateStatus = mutation({ bySessionKey: v.optional(v.string()), }, handler: async (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"); + if (!task || task.tenantId !== tenantId) throw new Error("Task not found"); const oldStatus = task.status; @@ -296,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; @@ -337,6 +361,7 @@ export const updateStatus = mutation({ // Log activity await ctx.db.insert("activities", { + tenantId, type: "task_status_changed", agentId, taskId: args.taskId, @@ -348,6 +373,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,13 +389,15 @@ 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 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"; @@ -383,6 +411,7 @@ export const approve = mutation({ // Add comment await ctx.db.insert("messages", { + tenantId, taskId: args.taskId, humanAuthor: authorName, type: "comment", @@ -392,6 +421,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 +433,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,14 +449,16 @@ 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 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"; @@ -438,6 +471,7 @@ export const requestChanges = mutation({ // Add feedback as comment await ctx.db.insert("messages", { + tenantId, taskId: args.taskId, humanAuthor: authorName, type: "comment", @@ -447,6 +481,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 +493,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,22 +509,21 @@ 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 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); } @@ -497,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; } @@ -517,6 +552,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 +565,7 @@ export const assign = mutation({ // Log activity await ctx.db.insert("activities", { + tenantId, type: "task_assigned", agentId: assignerId, taskId: args.taskId, @@ -541,30 +578,37 @@ 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 resolveTenantId(ctx, args); const now = Date.now(); let fromAgentId = undefined; 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, fromAgentId, humanAuthor: args.humanAuthor, @@ -578,6 +622,7 @@ export const addComment = mutation({ // Log activity await ctx.db.insert("activities", { + tenantId, type: "message_sent", agentId: fromAgentId, taskId: args.taskId, @@ -592,23 +637,26 @@ 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) => { + 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; } @@ -636,6 +684,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,9 +700,10 @@ export const updateSubtask = mutation({ bySessionKey: v.optional(v.string()), }, handler: async (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"); + if (!task || task.tenantId !== tenantId) throw new Error("Task not found"); if (!task.subtasks || !task.subtasks[args.subtaskIndex]) { throw new Error("Subtask not found"); } @@ -691,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; @@ -705,6 +755,7 @@ export const updateSubtask = mutation({ // Log activity if (isDone) { await ctx.db.insert("activities", { + tenantId, type: "subtask_completed", agentId, taskId: args.taskId, @@ -713,7 +764,8 @@ export const updateSubtask = mutation({ }); } else if (newStatus === "blocked") { await ctx.db.insert("activities", { - type: "subtask_blocked" as any, + tenantId, + type: "subtask_blocked", agentId, taskId: args.taskId, message: `${agentName} blocked "${updatedSubtask.title}" on "${task.title}"${args.blockedReason ? `: ${args.blockedReason}` : ""}`, @@ -723,6 +775,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 +792,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 +806,11 @@ export const update = mutation({ ), }, handler: async (ctx, args) => { - const { 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), ); @@ -767,6 +825,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,15 +838,14 @@ export const createFromDashboard = mutation({ ), }, handler: async (ctx, args) => { + const tenantId = await resolveTenantId(ctx, args); 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, title: args.title, description: args.description, status: "inbox", @@ -799,6 +857,7 @@ export const createFromDashboard = mutation({ // Log activity await ctx.db.insert("activities", { + tenantId, type: "task_created", agentId: clawe?._id, taskId, @@ -813,6 +872,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,17 +894,18 @@ export const createWithPlan = mutation({ ), }, handler: async (ctx, args) => { + const tenantId = await resolveTenantId(ctx, args); const now = Date.now(); // Resolve creator 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; @@ -854,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); } @@ -869,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 @@ -893,6 +954,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 +968,7 @@ export const createWithPlan = mutation({ // Log activity await ctx.db.insert("activities", { + tenantId, type: "task_created", agentId: createdBy, taskId, @@ -928,6 +991,7 @@ export const createWithPlan = mutation({ : `📋 New task assigned: "${args.title}"`; await ctx.db.insert("notifications", { + tenantId, targetAgentId: assigneeId, sourceAgentId: createdBy, type: "task_assigned", @@ -945,12 +1009,21 @@ 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) => { + 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) { @@ -960,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 new file mode 100644 index 0000000..b737a6d --- /dev/null +++ b/packages/backend/convex/tenants.ts @@ -0,0 +1,175 @@ +import { v } from "convex/values"; +import { query, mutation } from "./_generated/server"; +import { + ensureAccountForUser, + getUser, + getTenantIdFromJwt, + resolveTenantId, + validateWatcherToken, +} 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 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, + timezone: args.timezone, + }, + updatedAt: Date.now(), + }); + }, +}); + +// Create a new tenant within an account +export const create = mutation({ + 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(); + return await ctx.db.insert("tenants", { + accountId: account._id, + status: "provisioning", + createdAt: now, + updatedAt: now, + }); + }, +}); + +// 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: { + 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 tenantId = await getTenantIdFromJwt(ctx); + const tenant = await ctx.db.get(tenantId); + if (!tenant) { + throw new Error("Tenant not found"); + } + + await ctx.db.patch(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 resolveTenantId(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!, + })); + }, +}); 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..5e3e58e --- /dev/null +++ b/packages/backend/convex/users.ts @@ -0,0 +1,92 @@ +import { query, mutation } from "./_generated/server"; +import { v } from "convex/values"; +import { ensureAccountForUser } from "./lib/auth"; + +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(); + + 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, + }); + const created = await ctx.db.get(userId); + if (!created) throw new Error("Failed to create user"); + user = created; + } + + // Ensure account + membership exist + await ensureAccountForUser(ctx, user); + + return user; + }, +}); + +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..224396c 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -19,10 +19,12 @@ "./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", + "dev": "sh -c '../../scripts/sync-convex-env.sh & convex dev'", "debug": "convex dev", "deploy": "convex deploy", "check-types": "tsc --noEmit" @@ -32,6 +34,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..a88c234 100644 --- a/packages/cli/src/client.ts +++ b/packages/cli/src/client.ts @@ -1,10 +1,34 @@ 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. + * 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: unknown[]): unknown[] { + const base = + args[0] != null && typeof args[0] === "object" + ? (args[0] as Record) + : {}; + return [{ ...base, machineToken }]; +} + // Common MIME types by extension const MIME_TYPES: Record = { ".md": "text/markdown", @@ -33,7 +57,40 @@ if (!CONVEX_URL) { process.exit(1); } -export const client = new ConvexHttpClient(CONVEX_URL); +const client = new ConvexHttpClient(CONVEX_URL); + +/** + * Wrapper around ConvexHttpClient.query. + * Injects machineToken for tenant scoping. + */ +export function query>( + fn: F, + ...args: OptionalRestArgs +): Promise> { + return client.query(fn, ...(injectToken(args) as OptionalRestArgs)); +} + +/** + * Wrapper around ConvexHttpClient.mutation. + * Injects machineToken for tenant scoping. + */ +export function mutation>( + fn: F, + ...args: OptionalRestArgs +): Promise> { + return client.mutation(fn, ...(injectToken(args) as OptionalRestArgs)); +} + +/** + * Wrapper around ConvexHttpClient.action. + * Injects machineToken for tenant scoping. + */ +export function action>( + fn: F, + ...args: OptionalRestArgs +): Promise> { + return client.action(fn, ...(injectToken(args) as OptionalRestArgs)); +} /** * Upload a file to Convex storage @@ -44,7 +101,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..6564c90 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."); @@ -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 86a850c..634ee49 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 = { @@ -9,7 +9,6 @@ export type BusinessSetOptions = { description?: string; favicon?: string; metadata?: string; // JSON string - approve?: boolean; removeBootstrap?: boolean; }; @@ -33,7 +32,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, @@ -48,21 +47,16 @@ 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 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/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/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..ea69e2f --- /dev/null +++ b/packages/plugins/src/registry.ts @@ -0,0 +1,45 @@ +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( + /* webpackIgnore: true */ "@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/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..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 @@ -111,6 +114,12 @@ importers: ai: specifier: ^6.0.77 version: 6.0.77(zod@4.3.6) + 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 @@ -120,12 +129,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) @@ -206,6 +221,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 @@ -271,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: @@ -459,6 +498,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'} @@ -1058,6 +1295,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==} @@ -1952,6 +2192,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==} @@ -2250,6 +2721,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==} @@ -2322,6 +2796,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: @@ -2342,6 +2819,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==} @@ -2550,6 +3030,13 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + 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==} @@ -2559,12 +3046,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==} @@ -2580,6 +3073,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} @@ -2696,6 +3192,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==} @@ -3020,6 +3521,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==} @@ -3150,6 +3659,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'} @@ -3216,6 +3729,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'} @@ -3227,6 +3746,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'} @@ -3365,6 +3887,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==} @@ -3379,15 +3904,23 @@ 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==} js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true jsdom@28.0.0: resolution: {integrity: sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==} @@ -3525,6 +4058,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==} @@ -3754,6 +4290,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: @@ -3784,6 +4336,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'} @@ -3909,6 +4464,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'} @@ -4195,6 +4758,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'} @@ -4321,6 +4887,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==} @@ -4546,6 +5115,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'} @@ -4553,6 +5126,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'} @@ -4609,6 +5185,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: @@ -4857,17 +5437,593 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} - '@babel/code-frame@7.29.0': + '@auth/core@0.41.0': dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 + '@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) - '@babel/compat-data@7.29.0': {} + '@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 - '@babel/core@7.29.0': + '@aws-amplify/api-graphql@4.8.5': dependencies: - '@babel/code-frame': 7.29.0 + '@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 + + '@aws-sdk/types@3.973.1': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.982.0': + dependencies: + '@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 + + '@aws-sdk/util-endpoints@3.990.0': + dependencies: + '@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 + + '@aws-sdk/util-locate-window@3.965.4': + dependencies: + tslib: 2.8.1 + + '@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 + + '@aws-sdk/util-user-agent-node@3.972.8': + dependencies: + '@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 + + '@aws-sdk/xml-builder@3.972.4': + dependencies: + '@smithy/types': 4.12.0 + fast-xml-parser: 5.3.4 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.3': {} + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) @@ -5337,6 +6493,8 @@ snapshots: '@opentelemetry/api@1.9.0': {} + '@panva/hkdf@1.2.1': {} + '@popperjs/core@2.11.8': {} '@radix-ui/number@1.1.1': {} @@ -6218,6 +7376,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': @@ -6525,6 +8036,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 @@ -6615,6 +8128,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 @@ -6631,6 +8148,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 @@ -6916,6 +8435,22 @@ 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 + + aws-jwt-verify@5.1.1: {} + axios@1.13.4: dependencies: follow-redirects: 1.15.11 @@ -6928,12 +8463,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 @@ -6955,6 +8494,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 @@ -7043,6 +8588,8 @@ snapshots: optionalDependencies: react: 19.2.0 + crc-32@1.2.2: {} + crelt@1.0.6: {} cross-spawn@7.0.6: @@ -7478,6 +9025,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 @@ -7604,6 +9159,8 @@ snapshots: graceful-fs@4.2.11: {} + graphql@15.8.0: {} + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -7723,6 +9280,10 @@ snapshots: transitivePeerDependencies: - supports-color + idb@5.0.6: {} + + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -7730,6 +9291,8 @@ snapshots: immer@9.0.21: optional: true + immer@9.0.6: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -7869,6 +9432,8 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + isarray@1.0.0: {} + isarray@2.0.5: {} isexe@2.0.0: {} @@ -7884,8 +9449,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: @@ -8013,6 +9582,8 @@ snapshots: lodash.merge@4.6.2: {} + lodash@4.17.23: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -8452,6 +10023,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 @@ -8484,6 +10061,8 @@ snapshots: node-releases@2.0.27: {} + oauth4webapi@3.8.5: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -8613,6 +10192,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): @@ -8994,6 +10579,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 @@ -9188,6 +10777,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 @@ -9412,6 +11003,8 @@ snapshots: ufo@1.6.3: {} + ulid@2.4.0: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -9421,6 +11014,8 @@ snapshots: undici-types@6.21.0: {} + undici-types@7.16.0: {} + undici@7.21.0: {} unified@11.0.5: @@ -9485,6 +11080,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..621a776 --- /dev/null +++ b/scripts/convex-deploy.sh @@ -0,0 +1,46 @@ +#!/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 + +# 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 + +echo "==> Convex deployment complete" 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/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 45c727c..df418ac 100644 --- a/turbo.json +++ b/turbo.json @@ -6,10 +6,17 @@ "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", + "AUTH_PROVIDER", + "NEXTAUTH_SECRET", + "NEXTAUTH_URL", + "AUTO_LOGIN_EMAIL", + "GOOGLE_CLIENT_ID", + "GOOGLE_CLIENT_SECRET", + "WATCHER_TOKEN" ], "tasks": { "build": {