diff --git a/.claude/skills/setup-deploy-auth/SKILL.md b/.claude/skills/setup-deploy-auth/SKILL.md new file mode 100644 index 00000000..718d2673 --- /dev/null +++ b/.claude/skills/setup-deploy-auth/SKILL.md @@ -0,0 +1,124 @@ +--- +name: setup-deploy-auth +description: One-time migration to enable single-user auth + Fly deploy. Installs new deps, pushes auth tables to Convex, generates the admin password, bootstraps the admin user, and prints next steps. Run after pulling the auto-deploy + auth upgrade. +--- + +# Set up deploy + auth + +Migration triggered when pulling in the auto-deploy + auth changes. Installs deps, pushes the schema additions, generates and stores the admin password, bootstraps the admin user, and reconciles `.env.local` against `.env.example`. Idempotent — safe to re-run. + +# Operating principles + +- Never proceed with a dirty working tree. Refuse and ask the user to commit or stash. +- Lean on `npm`, `npx convex`, `git`. Don't hand-edit generated files. +- Idempotent: each step is safe to re-run. The user might Ctrl-C halfway and resume. +- Surface errors clearly. If a step fails, stop and report — don't silently continue. + +# Step 0: Preflight + +Run: +- `git status --porcelain` + +If output is non-empty, tell the user to commit or stash and stop. + +Verify the new files exist (these came in with the upgrade): +- `convex/auth.ts` +- `convex/auth.config.ts` +- `convex/users.ts` +- `server/auth.ts` + +If any are missing the merge didn't bring them in — stop and tell the user. + +# Step 1: Install new deps + +Run: +- `npm install` + +Picks up `jose`, `@convex-dev/auth`, `@auth/core`, and any other dep moves from the new `package.json`. + +# Step 2: Push schema additions to Convex + +The upgrade adds `authTables` from `@convex-dev/auth/server` to the schema (`users`, `authAccounts`, `authSessions`, etc.). Push them: + +- `npx convex dev --once` + +Output should end with "Convex functions ready". If the user is offline or doesn't want to push right now, they can skip — but Step 4 (bootstrap) will fail until the schema is pushed. + +# Step 3: Generate the admin password + +Check if `BOOP_ADMIN_PASSWORD` is already set in `.env.local`: +- `grep -E '^BOOP_ADMIN_PASSWORD=.+' .env.local` + +If a non-empty value exists, ask whether to keep it or generate a new one. + +To generate a strong password: +- `openssl rand -base64 24` + +Store the chosen value as `$BOOP_PASSWORD` for the remaining steps. Print it once for the user to record (they'll need it to log into the dashboard). + +# Step 4: Set the password in Convex env + +Run: +- `npx convex env set BOOP_ADMIN_PASSWORD ` + +The `bootstrap` action reads this from `process.env` inside Convex. + +# Step 5: Bootstrap the admin user + +Run: +- `npx convex run users:bootstrap` + +Expected output: +- First run: `{ created: true }` +- Re-run: `{ created: false, reason: "user already exists" }` + +If the action throws "BOOP_ADMIN_PASSWORD is not set", Step 4 didn't take effect — re-run it and try again. + +# Step 6: Reconcile `.env.local` against `.env.example` + +The upgrade added new keys to `.env.example`. Find ones missing from `.env.local`: + +``` +comm -23 \ + <(grep -oE '^[A-Z_][A-Z0-9_]*=' .env.example | sort -u) \ + <(grep -oE '^[A-Z_][A-Z0-9_]*=' .env.local 2>/dev/null | sort -u) +``` + +For each missing key, append a blank line to `.env.local` and tell the user what they need to set: + +| Key | When to set | Where to get | +|---|---|---| +| `SENDBLUE_SIGNING_SECRET` | Required for any non-localhost deploy. Without it, the webhook accepts unsigned requests in local dev only. | Sendblue dashboard → Webhook Settings → Signing Secret | +| `BOOP_ADMIN_PASSWORD` | Set to the value from Step 3. (For local dev, the dashboard reads this from Convex env, not `.env.local` — but storing it locally helps you remember.) | Step 3 of this skill | +| `CLAUDE_CODE_OAUTH_TOKEN` | Optional. Use this on deployed forks if you'd rather use your Claude subscription than `ANTHROPIC_API_KEY`. | Run `claude setup-token` locally | + +Don't fill values automatically. The user needs to choose what's relevant for their setup. + +# Step 7: Print next steps + +If the user is running boop locally (not deployed): +- "Run `npm run dev`. Visit `http://localhost:5173`. Log in with the password from Step 3." +- "iMessage flow keeps working — `/sendblue/webhook` is allowlisted." + +If the user is deploying (or planning to): +- "Run `npm run deploy` — interactive script that creates a Fly app, generates secrets, configures Convex/Sendblue/GitHub Actions, and ships the first deploy." +- "Read `docs/deploying.md` for the full walkthrough and operational notes (annual `CLAUDE_CODE_OAUTH_TOKEN` rotation, password rotation, single-replica constraint)." + +Print rollback info: "If something broke, the `/upgrade-boop` rollback tag (printed at upgrade end) reverses everything in this skill plus the upgrade itself." + +# Idempotency notes + +- `npm install` — idempotent. +- `convex dev --once` — pushes schema; idempotent if schema unchanged. +- `convex env set` — overwrites existing value (intentional — Step 3 may have generated a new password). +- `users:bootstrap` — returns "user already exists" if already bootstrapped (the action's first check). +- `.env.local` reconciliation — only appends missing keys; never overwrites values. + +A user running this skill twice in a row gets: +- Step 1: no change +- Step 2: no schema diff to push +- Step 3: prompted to keep existing or regenerate +- Step 4: env value possibly updated +- Step 5: "user already exists" (or recreated if Step 3 regenerated and Step 4 stored new) +- Step 6: no missing keys to append +- Step 7: prints again diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..3f838e60 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +node_modules +debug/dist +debug/node_modules +.env +.env.local +.env.*.local +.git +.github +.claude +.cursor +.idea +.vscode +docs +assets +*.md +tests +__tests__ +**/*.test.ts diff --git a/.env.example b/.env.example index 82a01638..6dabebe2 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,10 @@ # Convex will create a project and auto-populate your .env.local. CONVEX_DEPLOYMENT= CONVEX_URL= +# CONVEX_SITE_URL hosts /.well-known/jwks.json for Convex Auth JWT +# verification (the .convex.site domain). `npx convex dev` sets this for +# you; in deploy it's derived from CONVEX_URL if absent. +CONVEX_SITE_URL= VITE_CONVEX_URL= # ---- Sendblue (iMessage bridge) ---- @@ -15,12 +19,23 @@ SENDBLUE_API_KEY= SENDBLUE_API_SECRET= SENDBLUE_FROM_NUMBER= +# ---- Sendblue webhook signing ---- +# Get this from your Sendblue dashboard under Webhook Settings → Signing Secret. +# Required when running on a public URL — the webhook handler verifies every +# incoming request's HMAC-SHA256 signature against this secret. +SENDBLUE_SIGNING_SECRET= + # ---- Claude model ---- # Uses your Claude Code subscription automatically — no separate API key needed. # Override with ANTHROPIC_API_KEY if you want to bypass the subscription. BOOP_MODEL=claude-sonnet-4-6 # ANTHROPIC_API_KEY= +# When deploying to a server, prefer CLAUDE_CODE_OAUTH_TOKEN (subscription) +# over ANTHROPIC_API_KEY. Generate one locally with `claude setup-token`, +# paste it as a Fly secret. Token lasts 1 year, then regenerate. +# CLAUDE_CODE_OAUTH_TOKEN= + # ---- Upgrade notifications ---- # On `npm run dev`, Boop checks your `upstream` remote for new commits and # prints a banner with the commit count + a reminder to run `/upgrade-boop`. @@ -52,6 +67,13 @@ PUBLIC_URL=http://localhost:3456 # paste it into the dashboard. Set to "false" to disable this behavior. # SENDBLUE_AUTO_WEBHOOK=true +# ---- Boop dashboard / admin auth (deployment only) ---- +# The single password for the dashboard and admin endpoints when deployed. +# `npm run deploy` will offer to auto-generate a 32-char random value. +# Set as both a Fly secret AND a Convex env var (the dashboard auth +# verifies via Convex; the bootstrap action reads from Convex env). +BOOP_ADMIN_PASSWORD= + # ---- Composio (1000+ integrations, zero-code) ---- # Get an API key at https://app.composio.dev/developers. # Once set, connect toolkits (Gmail, Slack, GitHub, Linear, …) from the diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..aac557f1 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,59 @@ +name: Deploy + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + concurrency: + group: deploy-${{ github.ref }} + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - name: Run unit tests + run: npm test + + - name: Push Convex backend + run: npx convex deploy --yes + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} + + - name: Bootstrap admin user (idempotent) + # CONVEX_DEPLOY_KEY scopes the call to the production deployment + # automatically. If the Convex CLI version in use rejects this, + # add `--prod` explicitly. + run: npx convex run users:bootstrap + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} + + - uses: superfly/flyctl-actions/setup-flyctl@master + + - name: Deploy to Fly + run: flyctl deploy --remote-only --app "$FLY_APP_NAME" + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + FLY_APP_NAME: ${{ secrets.FLY_APP_NAME }} + + - name: Smoke test + run: | + for i in {1..30}; do + if curl -fsS "https://${FLY_APP_NAME}.fly.dev/health"; then + exit 0 + fi + sleep 5 + done + echo "health check failed after 150s" + exit 1 + env: + FLY_APP_NAME: ${{ secrets.FLY_APP_NAME }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 82f718ac..3cf3ce8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,26 @@ Format: --- -## Unreleased — Composio integration layer +## Unreleased + +### Auto-deploy + single-user auth + +- **[BREAKING]** Express admin endpoints (`/chat`, `/consolidate`, `/agents/:id/cancel`, `/agents/:id/retry`, `/composio/*`) now require a valid Convex Auth JWT (`Authorization: Bearer `). The dashboard handles this automatically via the new login form. Direct API callers must obtain a JWT first. Run `/setup-deploy-auth` to configure the new auth flow. +- **[BREAKING]** Convex public `query`/`mutation` functions now call `await requireUser(ctx)` and throw "unauthenticated" if no user identity is present. Server-side code uses `internal.X.YInternal` twins that skip the check (deploy-key path). External callers using the Convex deployment URL directly will get "unauthenticated" until they set up auth. +- **[BREAKING]** Sendblue webhook now verifies HMAC signatures via `SENDBLUE_SIGNING_SECRET` (when set) and rejects requests where `from_number !== SENDBLUE_FROM_NUMBER`. Set the signing secret from your Sendblue dashboard → Webhook Settings. +- **[BREAKING]** WebSocket `/ws` upgrade now requires an `?token=` query parameter. The dashboard sets this automatically; direct WS clients need updating. +- **[BREAKING]** Convex schema spreads `authTables` from `@convex-dev/auth/server` (adds `users`, `authAccounts`, `authSessions`, etc.). Pushed automatically on next `npx convex dev`. +- **[BREAKING]** New env vars: `BOOP_ADMIN_PASSWORD` (single dashboard password — must be set in Convex env, not just `.env.local`), `SENDBLUE_SIGNING_SECRET` (Sendblue dashboard → Webhook Settings), `CLAUDE_CODE_OAUTH_TOKEN` (optional, for deployed forks using Claude subscription auth). +- Added: `npm run deploy` — interactive script that creates a Fly app, generates secrets, configures Convex/Sendblue/GitHub Actions, and ships the first deploy. See `docs/deploying.md`. +- Added: `Dockerfile` (multi-stage `node:22-slim`, runs server with `tsx`), `fly.toml` (single machine, always-on), `.github/workflows/deploy.yml` (test → `convex deploy` → bootstrap → `fly deploy` → smoke). +- Added: `convex/auth.ts`, `convex/auth.config.ts`, `convex/users.ts` (with `bootstrap` and `setPassword` admin actions), `server/auth.ts` (`verifyHmac`, `requireAdmin`). +- Added: `debug/src/auth.tsx` (login form), `debug/src/api-client.ts` (authed `fetch` wrapper). Existing `fetch` call sites in `ConsolidationPanel` + `ComposioSection` migrated. +- Added: 15 unit tests (5 `verifyHmac`, 6 `requireAdmin`, 4 Sendblue webhook auth) using Node's built-in `node:test` runner. Run with `npm test`. +- Added: `scripts/create-test-stubs.mjs` (pretest hook) so `npm test` works without a Convex deployment configured. +- Added: `docs/deploying.md`, `.claude/skills/setup-deploy-auth/SKILL.md`. +- Added npm deps: `jose` (JWT verification), `@convex-dev/auth`, `@auth/core`. `tsx` promoted from `devDependencies` to `dependencies` so the production Docker image can run it. + +### Composio integration layer - **[BREAKING]** Hand-built integrations (`/integrations/gmail`, `/integrations/google-calendar`, `/integrations/notion`, `/integrations/slack`, `/integrations/_template`) removed. To reconnect equivalents: set `COMPOSIO_API_KEY` in `.env.local`, open the Debug UI's Connections tab, click Connect on the toolkit you want. The dispatcher will see it under the same slug (`gmail`, `slack`, `notion`, `googlecalendar`). - **[BREAKING]** Convex `connections` table dropped. Composio stores OAuth state on its side. Any existing rows in that table are discarded on the next `convex dev` push. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..181052df --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# ---- Stage 1: install deps ---- +FROM node:22-slim AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +# ---- Stage 2: build debug UI bundle ---- +FROM node:22-slim AS build +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +# convex/_generated is gitignored, so it's not in the build context. +# Generate it inside the image so the debug UI build (which imports types +# from ../convex/_generated/api) can resolve them. +RUN npx convex codegen --typecheck=disable +RUN npm run build:debug + +# ---- Stage 3: runtime ---- +FROM node:22-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production +COPY --from=deps /app/node_modules ./node_modules +COPY --from=build /app/server ./server +COPY --from=build /app/convex ./convex +COPY --from=build /app/debug/dist ./debug/dist +COPY --from=build /app/scripts/preflight.mjs ./scripts/preflight.mjs +COPY package.json tsconfig.json ./ +EXPOSE 3456 +USER node +CMD ["npx", "tsx", "server/index.ts"] diff --git a/README.md b/README.md index 98e69fcf..c8c61f16 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ Built on: - **Debug dashboard** (React + Vite) with a Boop mascot — Dashboard (spend + tokens + agent status), Agents (timeline + integration logos), Automations, Memory (table + force-directed graph), Events, Connections. - **Convex** for persistence — real-time, typed, free tier. - **Uses your Claude Code subscription** — no separate Anthropic API key required. +- **Deploy** — one-command production setup with `npm run deploy`. See [`docs/deploying.md`](docs/deploying.md).

Agents view in the Boop debug dashboard diff --git a/convex/agents.ts b/convex/agents.ts index aa52c5b5..b1e264cc 100644 --- a/convex/agents.ts +++ b/convex/agents.ts @@ -1,5 +1,6 @@ -import { mutation, query } from "./_generated/server"; +import { internalMutation, internalQuery, mutation, query } from "./_generated/server"; import { v } from "convex/values"; +import { requireUser } from "./auth.js"; const statusV = v.union( v.literal("spawned"), @@ -9,7 +10,7 @@ const statusV = v.union( v.literal("cancelled"), ); -export const create = mutation({ +export const create = internalMutation({ args: { agentId: v.string(), conversationId: v.optional(v.string()), @@ -29,7 +30,7 @@ export const create = mutation({ }, }); -export const update = mutation({ +export const update = internalMutation({ args: { agentId: v.string(), status: v.optional(statusV), @@ -54,7 +55,7 @@ export const update = mutation({ }, }); -export const addLog = mutation({ +export const addLog = internalMutation({ args: { agentId: v.string(), logType: v.union( @@ -75,6 +76,7 @@ export const addLog = mutation({ export const list = query({ args: { status: v.optional(statusV), limit: v.optional(v.number()) }, handler: async (ctx, args) => { + await requireUser(ctx); const limit = args.limit ?? 50; if (args.status) { return await ctx.db @@ -88,6 +90,32 @@ export const list = query({ }); export const get = query({ + args: { agentId: v.string() }, + handler: async (ctx, args) => { + await requireUser(ctx); + return await ctx.db + .query("executionAgents") + .withIndex("by_agent_id", (q) => q.eq("agentId", args.agentId)) + .unique(); + }, +}); + +export const listInternal = internalQuery({ + args: { status: v.optional(statusV), limit: v.optional(v.number()) }, + handler: async (ctx, args) => { + const limit = args.limit ?? 50; + if (args.status) { + return await ctx.db + .query("executionAgents") + .withIndex("by_status", (q) => q.eq("status", args.status!)) + .order("desc") + .take(limit); + } + return await ctx.db.query("executionAgents").order("desc").take(limit); + }, +}); + +export const getInternal = internalQuery({ args: { agentId: v.string() }, handler: async (ctx, args) => { return await ctx.db @@ -100,6 +128,7 @@ export const get = query({ export const getLogs = query({ args: { agentId: v.string(), limit: v.optional(v.number()) }, handler: async (ctx, args) => { + await requireUser(ctx); return await ctx.db .query("agentLogs") .withIndex("by_agent", (q) => q.eq("agentId", args.agentId)) diff --git a/convex/auth.config.ts b/convex/auth.config.ts new file mode 100644 index 00000000..34fd0504 --- /dev/null +++ b/convex/auth.config.ts @@ -0,0 +1,10 @@ +// Convex Auth provider config — single password provider, single user. +// See https://labs.convex.dev/auth for full docs. +export default { + providers: [ + { + domain: process.env.CONVEX_SITE_URL, + applicationID: "convex", + }, + ], +}; diff --git a/convex/auth.ts b/convex/auth.ts new file mode 100644 index 00000000..29008620 --- /dev/null +++ b/convex/auth.ts @@ -0,0 +1,17 @@ +import { Password } from "@convex-dev/auth/providers/Password"; +import { convexAuth } from "@convex-dev/auth/server"; +import type { GenericQueryCtx, GenericMutationCtx } from "convex/server"; +import type { DataModel } from "./_generated/dataModel.js"; + +export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({ + providers: [Password], +}); + +export async function requireUser( + ctx: GenericQueryCtx | GenericMutationCtx, +): Promise { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + throw new Error("unauthenticated"); + } +} diff --git a/convex/automations.ts b/convex/automations.ts index d50875b5..c3d4f1de 100644 --- a/convex/automations.ts +++ b/convex/automations.ts @@ -1,7 +1,8 @@ -import { mutation, query } from "./_generated/server"; +import { internalMutation, internalQuery, mutation, query } from "./_generated/server"; import { v } from "convex/values"; +import { requireUser } from "./auth.js"; -export const create = mutation({ +export const create = internalMutation({ args: { automationId: v.string(), name: v.string(), @@ -29,6 +30,7 @@ export const create = mutation({ export const list = query({ args: { enabledOnly: v.optional(v.boolean()) }, handler: async (ctx, args) => { + await requireUser(ctx); let results; if (args.enabledOnly) { results = await ctx.db @@ -45,6 +47,7 @@ export const list = query({ export const get = query({ args: { automationId: v.string() }, handler: async (ctx, args) => { + await requireUser(ctx); return await ctx.db .query("automations") .withIndex("by_automation_id", (q) => q.eq("automationId", args.automationId)) @@ -55,6 +58,7 @@ export const get = query({ export const setEnabled = mutation({ args: { automationId: v.string(), enabled: v.boolean() }, handler: async (ctx, args) => { + await requireUser(ctx); const auto = await ctx.db .query("automations") .withIndex("by_automation_id", (q) => q.eq("automationId", args.automationId)) @@ -68,6 +72,7 @@ export const setEnabled = mutation({ export const remove = mutation({ args: { automationId: v.string() }, handler: async (ctx, args) => { + await requireUser(ctx); const auto = await ctx.db .query("automations") .withIndex("by_automation_id", (q) => q.eq("automationId", args.automationId)) @@ -78,7 +83,46 @@ export const remove = mutation({ }, }); -export const markRan = mutation({ +export const listInternal = internalQuery({ + args: { enabledOnly: v.optional(v.boolean()) }, + handler: async (ctx, args) => { + if (args.enabledOnly) { + return await ctx.db + .query("automations") + .withIndex("by_enabled", (q) => q.eq("enabled", true)) + .collect(); + } + return await ctx.db.query("automations").order("desc").collect(); + }, +}); + +export const setEnabledInternal = internalMutation({ + args: { automationId: v.string(), enabled: v.boolean() }, + handler: async (ctx, args) => { + const auto = await ctx.db + .query("automations") + .withIndex("by_automation_id", (q) => q.eq("automationId", args.automationId)) + .unique(); + if (!auto) return null; + await ctx.db.patch(auto._id, { enabled: args.enabled }); + return auto._id; + }, +}); + +export const removeInternal = internalMutation({ + args: { automationId: v.string() }, + handler: async (ctx, args) => { + const auto = await ctx.db + .query("automations") + .withIndex("by_automation_id", (q) => q.eq("automationId", args.automationId)) + .unique(); + if (!auto) return null; + await ctx.db.delete(auto._id); + return auto._id; + }, +}); + +export const markRan = internalMutation({ args: { automationId: v.string(), lastRunAt: v.number(), @@ -98,7 +142,7 @@ export const markRan = mutation({ }, }); -export const createRun = mutation({ +export const createRun = internalMutation({ args: { runId: v.string(), automationId: v.string(), @@ -112,7 +156,7 @@ export const createRun = mutation({ }, }); -export const updateRun = mutation({ +export const updateRun = internalMutation({ args: { runId: v.string(), status: v.union(v.literal("running"), v.literal("completed"), v.literal("failed")), @@ -139,6 +183,7 @@ export const updateRun = mutation({ export const recentRuns = query({ args: { automationId: v.optional(v.string()), limit: v.optional(v.number()) }, handler: async (ctx, args) => { + await requireUser(ctx); const limit = args.limit ?? 50; if (args.automationId) { return await ctx.db diff --git a/convex/consolidation.ts b/convex/consolidation.ts index dfe1a6f3..75a88bd3 100644 --- a/convex/consolidation.ts +++ b/convex/consolidation.ts @@ -1,5 +1,6 @@ -import { mutation, query } from "./_generated/server"; +import { internalMutation, query } from "./_generated/server"; import { v } from "convex/values"; +import { requireUser } from "./auth.js"; const statusV = v.union( v.literal("running"), @@ -7,7 +8,7 @@ const statusV = v.union( v.literal("failed"), ); -export const createRun = mutation({ +export const createRun = internalMutation({ args: { runId: v.string(), trigger: v.string() }, handler: async (ctx, args) => { return await ctx.db.insert("consolidationRuns", { @@ -21,7 +22,7 @@ export const createRun = mutation({ }, }); -export const updateRun = mutation({ +export const updateRun = internalMutation({ args: { runId: v.string(), status: v.optional(statusV), @@ -47,6 +48,7 @@ export const updateRun = mutation({ export const listRuns = query({ args: { limit: v.optional(v.number()) }, handler: async (ctx, args) => { + await requireUser(ctx); return await ctx.db.query("consolidationRuns").order("desc").take(args.limit ?? 25); }, }); diff --git a/convex/conversations.ts b/convex/conversations.ts index 302ce70d..819cb117 100644 --- a/convex/conversations.ts +++ b/convex/conversations.ts @@ -1,9 +1,11 @@ import { query } from "./_generated/server"; import { v } from "convex/values"; +import { requireUser } from "./auth.js"; export const list = query({ args: {}, handler: async (ctx) => { + await requireUser(ctx); return await ctx.db.query("conversations").order("desc").take(50); }, }); @@ -11,6 +13,7 @@ export const list = query({ export const get = query({ args: { conversationId: v.string() }, handler: async (ctx, args) => { + await requireUser(ctx); return await ctx.db .query("conversations") .withIndex("by_conversation", (q) => q.eq("conversationId", args.conversationId)) diff --git a/convex/dashboard.ts b/convex/dashboard.ts index cb58a707..096132c1 100644 --- a/convex/dashboard.ts +++ b/convex/dashboard.ts @@ -1,4 +1,5 @@ import { query } from "./_generated/server"; +import { requireUser } from "./auth.js"; // Cap per-table scans so a long-lived install doesn't hit Convex's 16,384 // .collect() ceiling and break the dashboard. Metrics reflect the most @@ -8,6 +9,7 @@ const METRICS_SCAN_LIMIT = 5000; export const metrics = query({ args: {}, handler: async (ctx) => { + await requireUser(ctx); const [messages, memories, agents, automationRuns] = await Promise.all([ ctx.db.query("messages").order("desc").take(METRICS_SCAN_LIMIT), ctx.db.query("memoryRecords").order("desc").take(METRICS_SCAN_LIMIT), diff --git a/convex/drafts.ts b/convex/drafts.ts index cc42bd62..48b132d5 100644 --- a/convex/drafts.ts +++ b/convex/drafts.ts @@ -1,5 +1,6 @@ -import { mutation, query } from "./_generated/server"; +import { internalMutation, internalQuery, query } from "./_generated/server"; import { v } from "convex/values"; +import { requireUser } from "./auth.js"; const statusV = v.union( v.literal("pending"), @@ -8,7 +9,7 @@ const statusV = v.union( v.literal("expired"), ); -export const create = mutation({ +export const create = internalMutation({ args: { draftId: v.string(), conversationId: v.string(), @@ -28,6 +29,7 @@ export const create = mutation({ export const get = query({ args: { draftId: v.string() }, handler: async (ctx, args) => { + await requireUser(ctx); return await ctx.db .query("drafts") .withIndex("by_draft_id", (q) => q.eq("draftId", args.draftId)) @@ -38,6 +40,7 @@ export const get = query({ export const pendingByConversation = query({ args: { conversationId: v.string() }, handler: async (ctx, args) => { + await requireUser(ctx); return await ctx.db .query("drafts") .withIndex("by_conversation_status", (q) => @@ -51,11 +54,35 @@ export const pendingByConversation = query({ export const recent = query({ args: { limit: v.optional(v.number()) }, handler: async (ctx, args) => { + await requireUser(ctx); return await ctx.db.query("drafts").order("desc").take(args.limit ?? 50); }, }); -export const setStatus = mutation({ +export const getInternal = internalQuery({ + args: { draftId: v.string() }, + handler: async (ctx, args) => { + return await ctx.db + .query("drafts") + .withIndex("by_draft_id", (q) => q.eq("draftId", args.draftId)) + .unique(); + }, +}); + +export const pendingByConversationInternal = internalQuery({ + args: { conversationId: v.string() }, + handler: async (ctx, args) => { + return await ctx.db + .query("drafts") + .withIndex("by_conversation_status", (q) => + q.eq("conversationId", args.conversationId).eq("status", "pending"), + ) + .order("desc") + .take(25); + }, +}); + +export const setStatus = internalMutation({ args: { draftId: v.string(), status: statusV }, handler: async (ctx, args) => { const draft = await ctx.db diff --git a/convex/memoryEvents.ts b/convex/memoryEvents.ts index 90b73bb2..02740fc2 100644 --- a/convex/memoryEvents.ts +++ b/convex/memoryEvents.ts @@ -1,7 +1,8 @@ -import { mutation, query } from "./_generated/server"; +import { internalMutation, query } from "./_generated/server"; import { v } from "convex/values"; +import { requireUser } from "./auth.js"; -export const emit = mutation({ +export const emit = internalMutation({ args: { eventType: v.string(), conversationId: v.optional(v.string()), @@ -17,6 +18,7 @@ export const emit = mutation({ export const recent = query({ args: { limit: v.optional(v.number()) }, handler: async (ctx, args) => { + await requireUser(ctx); return await ctx.db.query("memoryEvents").order("desc").take(args.limit ?? 100); }, }); @@ -24,6 +26,7 @@ export const recent = query({ export const byConversation = query({ args: { conversationId: v.string(), limit: v.optional(v.number()) }, handler: async (ctx, args) => { + await requireUser(ctx); return await ctx.db .query("memoryEvents") .withIndex("by_conversation", (q) => q.eq("conversationId", args.conversationId)) diff --git a/convex/memoryRecords.ts b/convex/memoryRecords.ts index 256152ee..b37f6500 100644 --- a/convex/memoryRecords.ts +++ b/convex/memoryRecords.ts @@ -1,7 +1,8 @@ -import { action, mutation, query } from "./_generated/server"; +import { internalAction, internalMutation, internalQuery, query } from "./_generated/server"; import { v } from "convex/values"; -import { api } from "./_generated/api"; +import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; +import { requireUser } from "./auth.js"; const tierV = v.union(v.literal("short"), v.literal("long"), v.literal("permanent")); const segmentV = v.union( @@ -15,7 +16,7 @@ const segmentV = v.union( ); const lifecycleV = v.union(v.literal("active"), v.literal("archived"), v.literal("pruned")); -export const upsert = mutation({ +export const upsert = internalMutation({ args: { memoryId: v.string(), content: v.string(), @@ -77,7 +78,7 @@ export const upsert = mutation({ }, }); -export const getByIds = query({ +export const getByIds = internalQuery({ args: { ids: v.array(v.id("memoryRecords")) }, handler: async (ctx, args) => { const out = []; @@ -89,7 +90,7 @@ export const getByIds = query({ }, }); -export const vectorSearch = action({ +export const vectorSearch = internalAction({ args: { embedding: v.array(v.float64()), limit: v.optional(v.number()) }, handler: async (ctx, args): Promise; score: number; record: any }>> => { const results = await ctx.vectorSearch("memoryRecords", "by_embedding", { @@ -97,7 +98,7 @@ export const vectorSearch = action({ limit: args.limit ?? 20, filter: (q) => q.eq("lifecycle", "active"), }); - const records = await ctx.runQuery(api.memoryRecords.getByIds, { + const records = await ctx.runQuery(internal.memoryRecords.getByIds, { ids: results.map((r) => r._id), }); const byId = new Map(records.map((r: any) => [r._id, r])); @@ -115,6 +116,7 @@ export const list = query({ limit: v.optional(v.number()), }, handler: async (ctx, args) => { + await requireUser(ctx); const limit = args.limit ?? 100; let results; if (args.tier) { @@ -132,6 +134,7 @@ export const list = query({ export const search = query({ args: { query: v.string(), limit: v.optional(v.number()) }, handler: async (ctx, args) => { + await requireUser(ctx); const limit = args.limit ?? 20; const q = args.query.toLowerCase(); // Filter on the index BEFORE the 500 cap — otherwise archived/pruned @@ -151,7 +154,46 @@ export const search = query({ }, }); -export const markAccessed = mutation({ +export const listInternal = internalQuery({ + args: { + tier: v.optional(tierV), + segment: v.optional(segmentV), + lifecycle: v.optional(lifecycleV), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const limit = args.limit ?? 100; + let results; + if (args.tier) { + results = await ctx.db.query("memoryRecords").withIndex("by_tier", (q) => q.eq("tier", args.tier!)).order("desc").take(limit * 2); + } else if (args.segment) { + results = await ctx.db.query("memoryRecords").withIndex("by_segment", (q) => q.eq("segment", args.segment!)).order("desc").take(limit * 2); + } else { + results = await ctx.db.query("memoryRecords").order("desc").take(limit * 2); + } + const lifecycle = args.lifecycle ?? "active"; + return results.filter((r) => r.lifecycle === lifecycle).slice(0, limit); + }, +}); + +export const searchInternal = internalQuery({ + args: { query: v.string(), limit: v.optional(v.number()) }, + handler: async (ctx, args) => { + const limit = args.limit ?? 20; + const q = args.query.toLowerCase(); + const active = await ctx.db + .query("memoryRecords") + .withIndex("by_lifecycle", (idx) => idx.eq("lifecycle", "active")) + .order("desc") + .take(500); + return active + .filter((m) => m.content.toLowerCase().includes(q)) + .sort((a, b) => b.importance - a.importance) + .slice(0, limit); + }, +}); + +export const markAccessed = internalMutation({ args: { memoryId: v.string() }, handler: async (ctx, args) => { const mem = await ctx.db @@ -167,7 +209,7 @@ export const markAccessed = mutation({ }, }); -export const setLifecycle = mutation({ +export const setLifecycle = internalMutation({ args: { memoryId: v.string(), lifecycle: lifecycleV }, handler: async (ctx, args) => { const mem = await ctx.db @@ -185,6 +227,7 @@ const COUNTS_SCAN_LIMIT = 5000; export const countsByTier = query({ args: {}, handler: async (ctx) => { + await requireUser(ctx); const all = await ctx.db.query("memoryRecords").order("desc").take(COUNTS_SCAN_LIMIT); const active = all.filter((m) => m.lifecycle === "active"); return { diff --git a/convex/messages.ts b/convex/messages.ts index 578050a1..ca7aecc7 100644 --- a/convex/messages.ts +++ b/convex/messages.ts @@ -1,7 +1,8 @@ -import { mutation, query } from "./_generated/server"; +import { internalMutation, internalQuery, query } from "./_generated/server"; import { v } from "convex/values"; +import { requireUser } from "./auth.js"; -export const send = mutation({ +export const send = internalMutation({ args: { conversationId: v.string(), role: v.union(v.literal("user"), v.literal("assistant"), v.literal("system")), @@ -36,6 +37,7 @@ export const send = mutation({ export const list = query({ args: { conversationId: v.string(), limit: v.optional(v.number()) }, handler: async (ctx, args) => { + await requireUser(ctx); return await ctx.db .query("messages") .withIndex("by_conversation", (q) => q.eq("conversationId", args.conversationId)) @@ -45,6 +47,19 @@ export const list = query({ }); export const recent = query({ + args: { conversationId: v.string(), limit: v.optional(v.number()) }, + handler: async (ctx, args) => { + await requireUser(ctx); + const msgs = await ctx.db + .query("messages") + .withIndex("by_conversation", (q) => q.eq("conversationId", args.conversationId)) + .order("desc") + .take(args.limit ?? 20); + return msgs.reverse(); + }, +}); + +export const recentInternal = internalQuery({ args: { conversationId: v.string(), limit: v.optional(v.number()) }, handler: async (ctx, args) => { const msgs = await ctx.db diff --git a/convex/schema.ts b/convex/schema.ts index 02e63c25..055172c2 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -1,7 +1,10 @@ import { defineSchema, defineTable } from "convex/server"; +import { authTables } from "@convex-dev/auth/server"; import { v } from "convex/values"; export default defineSchema({ + ...authTables, + messages: defineTable({ conversationId: v.string(), role: v.union(v.literal("user"), v.literal("assistant"), v.literal("system")), diff --git a/convex/sendblueDedup.ts b/convex/sendblueDedup.ts index b3486522..491f96d1 100644 --- a/convex/sendblueDedup.ts +++ b/convex/sendblueDedup.ts @@ -1,7 +1,7 @@ -import { mutation } from "./_generated/server"; +import { internalMutation } from "./_generated/server"; import { v } from "convex/values"; -export const claim = mutation({ +export const claim = internalMutation({ args: { handle: v.string() }, handler: async (ctx, args) => { const existing = await ctx.db diff --git a/convex/usageRecords.ts b/convex/usageRecords.ts index 6c1860a3..0a674768 100644 --- a/convex/usageRecords.ts +++ b/convex/usageRecords.ts @@ -1,5 +1,6 @@ -import { mutation, query } from "./_generated/server"; +import { internalMutation, query } from "./_generated/server"; import { v } from "convex/values"; +import { requireUser } from "./auth.js"; const sourceV = v.union( v.literal("dispatcher"), @@ -10,7 +11,7 @@ const sourceV = v.union( v.literal("consolidation-judge"), ); -export const record = mutation({ +export const record = internalMutation({ args: { source: sourceV, conversationId: v.optional(v.string()), @@ -33,6 +34,7 @@ export const record = mutation({ export const byConversation = query({ args: { conversationId: v.string(), limit: v.optional(v.number()) }, handler: async (ctx, args) => { + await requireUser(ctx); return await ctx.db .query("usageRecords") .withIndex("by_conversation", (q) => q.eq("conversationId", args.conversationId)) @@ -44,6 +46,7 @@ export const byConversation = query({ export const recent = query({ args: { limit: v.optional(v.number()) }, handler: async (ctx, args) => { + await requireUser(ctx); return await ctx.db.query("usageRecords").order("desc").take(args.limit ?? 100); }, }); @@ -51,6 +54,7 @@ export const recent = query({ export const summary = query({ args: { conversationId: v.optional(v.string()), limit: v.optional(v.number()) }, handler: async (ctx, args) => { + await requireUser(ctx); // Cap the scan. Convex's hard .collect() ceiling is 16,384 docs; this // keeps the summary query from silently breaking once the append-only // log grows past that. Conversation-scoped queries use the index. diff --git a/convex/users.ts b/convex/users.ts new file mode 100644 index 00000000..3fd9ce53 --- /dev/null +++ b/convex/users.ts @@ -0,0 +1,82 @@ +import { internalAction, internalQuery, internalMutation } from "./_generated/server.js"; +import { internal } from "./_generated/api.js"; +import { createAccount } from "@convex-dev/auth/server"; + +const ADMIN_EMAIL = "admin@boop.local"; + +export const countUsers = internalQuery({ + args: {}, + handler: async (ctx) => { + const users = await ctx.db.query("users").take(2); + return users.length; + }, +}); + +export const deleteAllUsersAndAccounts = internalMutation({ + args: {}, + handler: async (ctx) => { + for await (const user of ctx.db.query("users")) { + await ctx.db.delete(user._id); + } + for await (const acc of ctx.db.query("authAccounts")) { + await ctx.db.delete(acc._id); + } + for await (const sess of ctx.db.query("authSessions")) { + await ctx.db.delete(sess._id); + } + }, +}); + +// Idempotent: safe to run on every deploy. The countUsers/createAccount +// pair isn't transactional (actions don't span mutations), so we also catch +// the duplicate-account error from createAccount in case a concurrent +// invocation wins the race after we've checked. +export const bootstrap = internalAction({ + args: {}, + handler: async (ctx) => { + const existing = await ctx.runQuery(internal.users.countUsers, {}); + if (existing > 0) return { created: false, reason: "user already exists" }; + + const password = process.env.BOOP_ADMIN_PASSWORD; + if (!password) { + throw new Error( + "BOOP_ADMIN_PASSWORD is not set in Convex env — cannot bootstrap admin user.", + ); + } + + try { + await createAccount(ctx, { + provider: "password", + account: { id: ADMIN_EMAIL, secret: password }, + profile: { email: ADMIN_EMAIL }, + }); + return { created: true }; + } catch (err) { + // @convex-dev/auth throws `Account ${id} already exists` from + // createAccountFromCredentials when the credential row already exists. + if (err instanceof Error && err.message.includes("already exists")) { + return { created: false, reason: "user already exists (concurrent bootstrap)" }; + } + throw err; + } + }, +}); + +// Rotation: wipe and recreate from the current BOOP_ADMIN_PASSWORD env var. +// Old sessions invalidate naturally on next token expiry. +export const setPassword = internalAction({ + args: {}, + handler: async (ctx) => { + await ctx.runMutation(internal.users.deleteAllUsersAndAccounts, {}); + const password = process.env.BOOP_ADMIN_PASSWORD; + if (!password) { + throw new Error("BOOP_ADMIN_PASSWORD is not set"); + } + await createAccount(ctx, { + provider: "password", + account: { id: ADMIN_EMAIL, secret: password }, + profile: { email: ADMIN_EMAIL }, + }); + return { rotated: true }; + }, +}); diff --git a/debug/src/App.tsx b/debug/src/App.tsx index 6c7f59a7..e1eb4244 100644 --- a/debug/src/App.tsx +++ b/debug/src/App.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; -import { useQuery } from "convex/react"; +import { useQuery, Authenticated, Unauthenticated, AuthLoading } from "convex/react"; +import { LoginForm } from "./auth.js"; import { HugeiconsIcon } from "@hugeicons/react"; import { MachineRobotIcon, @@ -60,6 +61,22 @@ function getStoredTheme(): Theme { } export function App() { + return ( + <> + +

Loading…
+ + + + + + + + + ); +} + +function AppInner() { const [view, setView] = useState("dashboard"); const [theme, setTheme] = useState(getStoredTheme); const { connected } = useSocket(); diff --git a/debug/src/api-client.ts b/debug/src/api-client.ts new file mode 100644 index 00000000..67c71803 --- /dev/null +++ b/debug/src/api-client.ts @@ -0,0 +1,15 @@ +import { useCallback } from "react"; +import { useAuthToken } from "@convex-dev/auth/react"; + +export function useApiClient() { + const token = useAuthToken(); + + return useCallback( + async (input: string, init: RequestInit = {}): Promise => { + const headers = new Headers(init.headers); + if (token) headers.set("authorization", `Bearer ${token}`); + return fetch(input, { ...init, headers }); + }, + [token], + ); +} diff --git a/debug/src/auth.tsx b/debug/src/auth.tsx new file mode 100644 index 00000000..53d919b5 --- /dev/null +++ b/debug/src/auth.tsx @@ -0,0 +1,54 @@ +import { useState } from "react"; +import { useAuthActions } from "@convex-dev/auth/react"; + +export function LoginForm() { + const { signIn } = useAuthActions(); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setSubmitting(true); + setError(null); + try { + await signIn("password", { + email: "admin@boop.local", + password, + flow: "signIn", + }); + } catch (err) { + setError("Wrong password."); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+
+ Boop +

Boop Debug

+
+ setPassword(e.target.value)} + placeholder="Password" + required + autoFocus + className="w-full bg-slate-900 border border-slate-800 rounded px-3 py-2 text-sm" + /> + {error &&
{error}
} + +
+
+ ); +} diff --git a/debug/src/components/ComposioSection.tsx b/debug/src/components/ComposioSection.tsx index 98ab37e3..d4ea88c1 100644 --- a/debug/src/components/ComposioSection.tsx +++ b/debug/src/components/ComposioSection.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { IntegrationLogo } from "../lib/branding.js"; +import { useApiClient } from "../api-client.js"; type AuthMode = "managed" | "byo"; @@ -80,6 +81,7 @@ interface NeedsAuthConfigInfo { } export function ComposioSection({ isDark }: { isDark: boolean }) { + const apiClient = useApiClient(); const [data, setData] = useState(null); const [loaded, setLoaded] = useState(false); const [busy, setBusy] = useState(null); @@ -100,7 +102,7 @@ export function ComposioSection({ isDark }: { isDark: boolean }) { const fetchToolkits = useCallback(async () => { try { - const r = await fetch("/api/composio/toolkits"); + const r = await apiClient("/api/composio/toolkits"); const json = (await r.json()) as ToolkitsResponse; setData(json); } catch { @@ -108,7 +110,7 @@ export function ComposioSection({ isDark }: { isDark: boolean }) { } finally { setLoaded(true); } - }, []); + }, [apiClient]); useEffect(() => { fetchToolkits(); @@ -119,7 +121,7 @@ export function ComposioSection({ isDark }: { isDark: boolean }) { setBusy(slug); setNeedsAuthConfig(null); try { - const r = await fetch(`/api/composio/toolkits/${slug}/authorize`, { method: "POST" }); + const r = await apiClient(`/api/composio/toolkits/${slug}/authorize`, { method: "POST" }); if (!r.ok) { const err = await r.json().catch(() => ({})); if (err?.needsAuthConfig) { @@ -156,7 +158,7 @@ export function ComposioSection({ isDark }: { isDark: boolean }) { authPollRef.current = null; } try { - await fetch("/api/composio/refresh", { method: "POST" }); + await apiClient("/api/composio/refresh", { method: "POST" }); } catch { /* ignore */ } @@ -169,14 +171,14 @@ export function ComposioSection({ isDark }: { isDark: boolean }) { setBusy(null); } }, - [fetchToolkits], + [apiClient, fetchToolkits], ); const disconnect = useCallback( async (slug: string, connectionId: string) => { setBusy(`${slug}:${connectionId}`); try { - const r = await fetch(`/api/composio/toolkits/${slug}/disconnect`, { + const r = await apiClient(`/api/composio/toolkits/${slug}/disconnect`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ connectionId }), @@ -193,7 +195,7 @@ export function ComposioSection({ isDark }: { isDark: boolean }) { setBusy(null); } }, - [fetchToolkits], + [apiClient, fetchToolkits], ); const rename = useCallback( @@ -203,7 +205,7 @@ export function ComposioSection({ isDark }: { isDark: boolean }) { const alias = next.trim(); if (!alias || alias === current) return; try { - const r = await fetch(`/api/composio/connections/${connectionId}/rename`, { + const r = await apiClient(`/api/composio/connections/${connectionId}/rename`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ alias }), @@ -218,7 +220,7 @@ export function ComposioSection({ isDark }: { isDark: boolean }) { alert(`Rename failed: ${String(err)}`); } }, - [fetchToolkits], + [apiClient, fetchToolkits], ); const toggleTools = useCallback( @@ -229,7 +231,7 @@ export function ComposioSection({ isDark }: { isDark: boolean }) { if (toolsBySlug[slug] && toolsBySlug[slug] !== "error") return; setToolsBySlug((prev) => ({ ...prev, [slug]: "loading" })); try { - const r = await fetch(`/api/composio/toolkits/${slug}/tools`); + const r = await apiClient(`/api/composio/toolkits/${slug}/tools`); if (!r.ok) throw new Error(r.statusText); const json = (await r.json()) as { tools: ToolSummary[] }; setToolsBySlug((prev) => ({ ...prev, [slug]: json.tools })); @@ -237,7 +239,7 @@ export function ComposioSection({ isDark }: { isDark: boolean }) { setToolsBySlug((prev) => ({ ...prev, [slug]: "error" })); } }, - [expanded, toolsBySlug], + [apiClient, expanded, toolsBySlug], ); const cardBg = isDark ? "bg-slate-900/50 border-slate-800" : "bg-white border-slate-200"; diff --git a/debug/src/components/ConsolidationPanel.tsx b/debug/src/components/ConsolidationPanel.tsx index f46965fd..f738c7e9 100644 --- a/debug/src/components/ConsolidationPanel.tsx +++ b/debug/src/components/ConsolidationPanel.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { useQuery } from "convex/react"; import { api } from "../../../convex/_generated/api.js"; import { useSocket, type SocketEvent } from "../lib/useSocket.js"; +import { useApiClient } from "../api-client.js"; type Phase = | "loaded" @@ -71,6 +72,7 @@ function timeAgo(ts?: number): string { } export function ConsolidationPanel({ isDark }: { isDark: boolean }) { + const apiClient = useApiClient(); const runs = useQuery(api.consolidation.listRuns, { limit: 50 }); const [selectedId, setSelectedId] = useState(null); const [livePhases, setLivePhases] = useState>({}); @@ -105,7 +107,7 @@ export function ConsolidationPanel({ isDark }: { isDark: boolean }) { async function triggerManual() { setTriggering(true); try { - await fetch("/api/consolidate", { method: "POST" }); + await apiClient("/api/consolidate", { method: "POST" }); } finally { setTimeout(() => setTriggering(false), 1500); } diff --git a/debug/src/main.tsx b/debug/src/main.tsx index 0514c401..12b9dd69 100644 --- a/debug/src/main.tsx +++ b/debug/src/main.tsx @@ -1,6 +1,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import { ConvexProvider, ConvexReactClient } from "convex/react"; +import { ConvexReactClient } from "convex/react"; +import { ConvexAuthProvider } from "@convex-dev/auth/react"; import { App } from "./App.js"; import { ErrorBoundary } from "./ErrorBoundary.js"; import "./styles.css"; @@ -26,9 +27,9 @@ if (!convexUrl) { ReactDOM.createRoot(document.getElementById("root")!).render( - + - + , ); diff --git a/docs/deploying.md b/docs/deploying.md new file mode 100644 index 00000000..32c07bc8 --- /dev/null +++ b/docs/deploying.md @@ -0,0 +1,79 @@ +# Deploying Boop + +Boop runs as a single Fly.io machine with Convex as the backend. This doc walks through the one-shot deploy flow. + +## Prerequisites + +- Local dev set up — run `npm run setup` first if you haven't. +- A [Fly.io](https://fly.io) account and `fly` CLI installed (`curl -L https://fly.io/install.sh | sh`). +- Either: + - **A Claude subscription** (Pro/Max/Team/Enterprise) — generate a 1-year token via `claude setup-token` locally. Recommended. + - **An Anthropic API key** if you'd rather pay per token. +- Your Sendblue dashboard's **webhook signing secret** (Webhook Settings → Signing Secret). +- (Optional but recommended) `gh` CLI for auto-pushing GitHub Actions secrets. + +## One command + +```bash +npm run deploy +``` + +The interactive script walks you through: + +1. Verifying your local setup (Convex deployment, Sendblue keys). +2. Creating a Fly app and printing your stable public URL (`https://.fly.dev`). +3. Picking your LLM auth method (subscription token or API key) and a webhook signing secret. +4. Generating a strong dashboard password and storing it as both a Fly secret and a Convex env var. +5. Pushing all secrets to Fly. +6. Reminding you to set the Sendblue inbound webhook to `https://.fly.dev/sendblue/webhook`. +7. Setting GitHub Actions secrets (`FLY_API_TOKEN`, `FLY_APP_NAME`, `CONVEX_DEPLOY_KEY`) so future pushes to `main` auto-deploy. +8. Running the first deploy. + +After it finishes: + +- Visit `https://.fly.dev/` and log in with the dashboard password. +- Send yourself an iMessage. Watch the Events panel light up. +- Future deploys: `git push origin main` triggers GitHub Actions. + +## Operational tasks + +### Annual: rotate the Claude OAuth token + +The `CLAUDE_CODE_OAUTH_TOKEN` expires after 1 year. When it does, the agent will start replying "Sorry — I hit an error" to your messages. To rotate: + +```bash +claude setup-token # local — prints a new token +fly secrets set CLAUDE_CODE_OAUTH_TOKEN= --app +``` + +Fly restarts the machine automatically. + +### Rotate the dashboard password + +```bash +fly secrets set BOOP_ADMIN_PASSWORD= --app +npx convex env set BOOP_ADMIN_PASSWORD= +npx convex run users:setPassword +``` + +### Background loop constraint (single-replica) + +Boop's four background loops (cleanup, automation, heartbeat, consolidation) run in-process. The `fly.toml` sets `min_machines_running = 1` and `auto_stop_machines = false` so exactly one machine runs continuously. **Do not scale horizontally** — duplicate automations and consolidation runs would cost real money. + +### WebSocket token in logs + +The dashboard's live WebSocket connection authenticates via a `?token=` query parameter (browsers can't set custom headers on the WS handshake). That token will appear in Fly access logs and in any reverse proxy you put in front of Fly. If your logs ever leak, rotate the dashboard password to invalidate any captured token. Single-user severity is low, but worth knowing. + +## Alternative platforms + +The Dockerfile is platform-neutral. To deploy elsewhere: + +- **Coolify on Hetzner / your own VPS** — point Coolify at the repo, replace `fly.toml` with a Coolify service config. +- **PikaPods** — single Docker container, identical environment variable set. Drop `fly.toml`, configure the same secrets in the PikaPods dashboard. +- **Render / Railway / Fly Machines via API** — same shape. + +The thing you need to provide on any platform: a stable HTTPS URL with port 3456 reachable, all environment variables from `.env.example` set, and a single replica with persistent process. + +## Layering an SSO edge (optional) + +If you want SSO on top of the password gate (e.g., for a small team), put **Cloudflare Access** in front of the Fly app. Cloudflare Access enforces SSO at the edge before traffic reaches Fly; the password gate then becomes redundant but harmless. diff --git a/docs/superpowers/plans/2026-04-27-auto-deploy-pr.md b/docs/superpowers/plans/2026-04-27-auto-deploy-pr.md new file mode 100644 index 00000000..8d81b21d --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-auto-deploy-pr.md @@ -0,0 +1,2130 @@ +# Auto-Deploy + Auth PR Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the auto-deploy PR designed in `docs/superpowers/specs/2026-04-27-auto-deploy-pr-design.md`: ship Docker + Fly deployment for boop-agent coupled with single-user Convex Auth and Sendblue HMAC verification. + +**Architecture:** Two perimeters — (A) iMessage path uses HMAC + phone whitelist on the Sendblue webhook, (B) human path uses Convex Auth password provider issuing one JWT that gates both Express admin endpoints and direct Convex calls. Each Convex function becomes either `internal*` (server-only) or stays public with an explicit `ctx.auth.getUserIdentity()` check. The deploy itself is a single Fly machine running a multi-stage Debian Docker image with `min_machines_running = 1` (in-process loops require single-replica). + +**Tech Stack:** Node 22, Express 5, Convex, `@convex-dev/auth` (password provider), `jose` (JWT verification), `node:test` + `tsx --test` (test runner — zero new test framework deps), Docker (`node:22-slim`), Fly.io, GitHub Actions. + +**Source of truth:** Always read `docs/superpowers/specs/2026-04-27-auto-deploy-pr-design.md` if any task description seems ambiguous. The spec wins. + +--- + +## File Structure + +### New files + +| Path | Responsibility | +|---|---| +| `server/auth.ts` | JWT verification middleware + HMAC verify helper | +| `server/auth.test.ts` | Unit tests for `verifyHmac` and `requireAdmin` | +| `server/sendblue.test.ts` | Unit tests for HMAC + phone whitelist | +| `convex/auth.config.ts` | Convex Auth provider list (password) | +| `convex/auth.ts` | Re-exports from `@convex-dev/auth/server` | +| `convex/users.ts` | `users` table touch points: `bootstrap`, `setPassword` actions | +| `debug/src/auth.tsx` | Login form (single password field) | +| `debug/src/api-client.ts` | Authed `fetch` wrapper | +| `Dockerfile` | Multi-stage build (deps → build → runtime) | +| `.dockerignore` | Build context excludes | +| `fly.toml` | Fly app config (single machine, always-on) | +| `.github/workflows/deploy.yml` | Test → Convex deploy → bootstrap → Fly deploy → smoke | +| `scripts/deploy.ts` | Interactive deploy setup mirroring `scripts/setup.ts` | +| `docs/deploying.md` | Operator-facing deploy walkthrough | + +### Modified files + +| Path | Why | +|---|---| +| `package.json` | Add `jose`; add `npm test` script; add `npm run deploy` script | +| `convex/schema.ts` | Add `users` table | +| `convex/agents.ts` | Classify each function (internal vs public+auth) | +| `convex/automations.ts` | Classify each function | +| `convex/consolidation.ts` | Classify each function | +| `convex/conversations.ts` | Add `ctx.auth.getUserIdentity()` checks | +| `convex/dashboard.ts` | Add `ctx.auth.getUserIdentity()` check | +| `convex/drafts.ts` | Classify each function | +| `convex/memoryEvents.ts` | Classify each function | +| `convex/memoryRecords.ts` | Classify each function | +| `convex/messages.ts` | Classify each function | +| `convex/sendblueDedup.ts` | Convert `claim` to `internalMutation` | +| `convex/usageRecords.ts` | Classify each function | +| `server/index.ts` | Register `requireAdmin` middleware globally; auth WS upgrade; serve built debug UI | +| `server/sendblue.ts` | Wire HMAC + phone whitelist at top of webhook handler | +| `server/composio-routes.ts` | Whatever is needed if Convex calls move from `api.x` → `internal.x` (likely none — composio-routes calls Express helpers, not Convex directly) | +| Many server `.ts` files calling Convex | Switch `api.x.y` → `internal.x.y` for now-internal functions | +| `debug/src/main.tsx` | Wrap `` in `` | +| `debug/src/App.tsx` | Render `` when not authed | +| `debug/src/components/ConsolidationPanel.tsx` | Use `apiClient` instead of bare `fetch` | +| `debug/src/components/ComposioSection.tsx` | Use `apiClient` instead of bare `fetch` | +| `debug/package.json` | Add `@convex-dev/auth` | +| `.env.example` | Add `SENDBLUE_SIGNING_SECRET`, `BOOP_ADMIN_PASSWORD`, `CLAUDE_CODE_OAUTH_TOKEN` | +| `README.md` | Add link to `docs/deploying.md` | + +--- + +## Conventions + +- **Commit cadence:** Every task ends with a commit. The commit message uses the Conventional Commits style already used in the repo (`feat:`, `fix:`, `docs:`, `chore:`, `test:`, `refactor:`). +- **Type safety:** After every task, run `npx tsc --noEmit` and ensure it passes before committing. +- **Test runner:** Use `node:test` (built-in). Run with `npx tsx --test ''`. Globs are quoted because the shell would expand them otherwise. +- **TDD:** When the task adds a pure function or middleware, write the failing test first, run it to verify it fails, then implement. +- **No new top-level features.** Only what the spec lists. If a task seems to grow scope, stop and ask. +- **Production runtime uses `tsx`.** Boop already uses `tsx server/index.ts` via `npm start`. The Docker image runs the same way — no compile step. `tsx` moves from `devDependencies` to `dependencies`. + +--- + +## Task 1: Add `jose` dependency, `tsx` to dependencies, and `npm test` script + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Add the dependencies and the test script** + +Edit `package.json`: + +- Move `tsx` out of `devDependencies` and into `dependencies` (the production Docker image needs it at runtime). +- Add `"jose": "^5.9.0"` to `dependencies`. +- Add a `test` script and a `deploy` script: + +```json +"scripts": { + "setup": "tsx scripts/setup.ts", + "deploy": "tsx scripts/deploy.ts", + "test": "tsx --test 'server/**/*.test.ts' 'convex/**/*.test.ts'", + "...": "(everything else unchanged)" +} +``` + +- [ ] **Step 2: Install** + +Run: `npm install` +Expected: install succeeds, `package-lock.json` updates with `jose` and the move of `tsx`. + +- [ ] **Step 3: Verify the test script runs (with no tests yet)** + +Run: `npm test` +Expected: exit code 0 with `# tests 0` (no test files match the glob yet, that's fine). + +- [ ] **Step 4: Commit** + +```bash +git add package.json package-lock.json +git commit -m "chore: add jose, promote tsx to runtime dep, add test script" +``` + +--- + +## Task 2: Write `server/auth.ts` HMAC helper (TDD) + +**Files:** +- Create: `server/auth.ts` +- Create: `server/auth.test.ts` + +The file `server/auth.ts` will eventually export both `verifyHmac` and `requireAdmin`. We'll start with `verifyHmac` since it's a pure function and easy to TDD. + +- [ ] **Step 1: Write the failing test** + +Create `server/auth.test.ts`: + +```ts +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import { verifyHmac } from "./auth.ts"; + +const SECRET = "test-secret-abc-123"; + +function sign(body: string, secret: string = SECRET): string { + return createHmac("sha256", secret).update(body).digest("hex"); +} + +describe("verifyHmac", () => { + it("accepts a valid signature", () => { + const body = '{"hello":"world"}'; + const sig = sign(body); + assert.equal(verifyHmac(body, sig, SECRET), true); + }); + + it("rejects a wrong signature", () => { + const body = '{"hello":"world"}'; + assert.equal(verifyHmac(body, "deadbeef".repeat(8), SECRET), false); + }); + + it("rejects when signature is missing", () => { + const body = '{"hello":"world"}'; + assert.equal(verifyHmac(body, undefined, SECRET), false); + assert.equal(verifyHmac(body, "", SECRET), false); + }); + + it("rejects when secret is empty", () => { + const body = '{"hello":"world"}'; + const sig = sign(body, ""); + assert.equal(verifyHmac(body, sig, ""), false); + }); + + it("rejects on length mismatch (timing-safe)", () => { + const body = '{"hello":"world"}'; + const sig = sign(body); + assert.equal(verifyHmac(body, sig.slice(0, -2), SECRET), false); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npm test` +Expected: failure — `Cannot find module './auth.ts'` or similar (file doesn't exist yet). + +- [ ] **Step 3: Implement `verifyHmac` minimally** + +Create `server/auth.ts`: + +```ts +import { createHmac, timingSafeEqual } from "node:crypto"; + +export function verifyHmac( + body: string, + signature: string | undefined, + secret: string, +): boolean { + if (!signature || !secret) return false; + const expected = createHmac("sha256", secret).update(body).digest("hex"); + if (expected.length !== signature.length) return false; + return timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npm test` +Expected: PASS — all 5 `verifyHmac` cases pass. + +- [ ] **Step 5: Typecheck** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add server/auth.ts server/auth.test.ts +git commit -m "feat(auth): add verifyHmac helper with timing-safe comparison" +``` + +--- + +## Task 3: Add `requireAdmin` JWT middleware to `server/auth.ts` (TDD) + +**Files:** +- Modify: `server/auth.ts` +- Modify: `server/auth.test.ts` + +`requireAdmin` is Express middleware that: +- Lets the public allowlist through unconditionally (`/health`, `/sendblue/webhook`). +- For everything else, reads `Authorization: Bearer `, verifies it against Convex's JWKS endpoint via the `jose` library, and lets the request through if valid. Otherwise returns 401. + +Convex Auth issues JWTs. Convex exposes a JWKS endpoint at `${CONVEX_URL}/.well-known/jwks.json` (Convex Auth ships this for you). We use `jose`'s `createRemoteJWKSet` + `jwtVerify`. + +- [ ] **Step 1: Add the failing tests** + +Append to `server/auth.test.ts`: + +```ts +import { describe, it, mock, beforeEach } from "node:test"; +import { requireAdmin } from "./auth.ts"; +import type { Request, Response, NextFunction } from "express"; + +function mockReq(overrides: Partial = {}): Request { + return { + path: "/chat", + method: "POST", + headers: {}, + ...overrides, + } as Request; +} + +function mockRes(): { res: Response; status: number; body: unknown } { + const captured = { status: 200, body: undefined as unknown }; + const res = { + status(code: number) { + captured.status = code; + return this; + }, + json(payload: unknown) { + captured.body = payload; + return this; + }, + } as unknown as Response; + return { res, get status() { return captured.status; }, get body() { return captured.body; } } as any; +} + +describe("requireAdmin", () => { + it("lets /health through without a token", async () => { + const verify = mock.fn(async () => ({ payload: {} }) as any); + const middleware = requireAdmin({ verifyJwt: verify }); + const req = mockReq({ path: "/health", method: "GET" }); + const { res } = mockRes(); + let nextCalled = false; + const next: NextFunction = () => { + nextCalled = true; + }; + await middleware(req, res, next); + assert.equal(nextCalled, true); + assert.equal(verify.mock.callCount(), 0); + }); + + it("lets /sendblue/webhook through without a token", async () => { + const verify = mock.fn(async () => ({ payload: {} }) as any); + const middleware = requireAdmin({ verifyJwt: verify }); + const req = mockReq({ path: "/sendblue/webhook", method: "POST" }); + const { res } = mockRes(); + let nextCalled = false; + await middleware(req, res, () => { + nextCalled = true; + }); + assert.equal(nextCalled, true); + }); + + it("rejects an admin path with no Authorization header", async () => { + const verify = mock.fn(async () => ({ payload: {} }) as any); + const middleware = requireAdmin({ verifyJwt: verify }); + const req = mockReq(); + const captured = mockRes(); + let nextCalled = false; + await middleware(req, captured.res, () => { + nextCalled = true; + }); + assert.equal(nextCalled, false); + assert.equal((captured as any).status, 401); + }); + + it("rejects an admin path with a malformed Authorization header", async () => { + const verify = mock.fn(async () => ({ payload: {} }) as any); + const middleware = requireAdmin({ verifyJwt: verify }); + const req = mockReq({ headers: { authorization: "NotBearer foo" } }); + const captured = mockRes(); + let nextCalled = false; + await middleware(req, captured.res, () => { + nextCalled = true; + }); + assert.equal(nextCalled, false); + assert.equal((captured as any).status, 401); + }); + + it("calls next() when the JWT verifies", async () => { + const verify = mock.fn(async () => ({ payload: { sub: "user_123" } }) as any); + const middleware = requireAdmin({ verifyJwt: verify }); + const req = mockReq({ headers: { authorization: "Bearer good.jwt.value" } }); + const captured = mockRes(); + let nextCalled = false; + await middleware(req, captured.res, () => { + nextCalled = true; + }); + assert.equal(nextCalled, true); + assert.equal(verify.mock.callCount(), 1); + assert.equal(verify.mock.calls[0]!.arguments[0], "good.jwt.value"); + }); + + it("rejects when the JWT verifier throws", async () => { + const verify = mock.fn(async () => { + throw new Error("expired"); + }); + const middleware = requireAdmin({ verifyJwt: verify }); + const req = mockReq({ headers: { authorization: "Bearer bad.jwt.value" } }); + const captured = mockRes(); + let nextCalled = false; + await middleware(req, captured.res, () => { + nextCalled = true; + }); + assert.equal(nextCalled, false); + assert.equal((captured as any).status, 401); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npm test` +Expected: failure — `requireAdmin is not a function` or similar. + +- [ ] **Step 3: Implement `requireAdmin`** + +Append to `server/auth.ts`: + +```ts +import type { Request, Response, NextFunction, RequestHandler } from "express"; +import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; + +const PUBLIC_ALLOWLIST: Array<{ method?: string; pathPrefix: string }> = [ + { method: "GET", pathPrefix: "/health" }, + { method: "POST", pathPrefix: "/sendblue/webhook" }, +]; + +function isPublic(req: Request): boolean { + return PUBLIC_ALLOWLIST.some( + (rule) => + (!rule.method || rule.method === req.method) && + req.path.startsWith(rule.pathPrefix), + ); +} + +export type VerifyJwt = (token: string) => Promise<{ payload: JWTPayload }>; + +export interface RequireAdminOptions { + verifyJwt?: VerifyJwt; +} + +function defaultVerifier(): VerifyJwt { + const convexUrl = process.env.CONVEX_URL; + if (!convexUrl) { + throw new Error( + "CONVEX_URL not set — Express auth middleware requires it to fetch the Convex JWKS", + ); + } + const jwks = createRemoteJWKSet(new URL("/.well-known/jwks.json", convexUrl)); + return async (token) => jwtVerify(token, jwks, { issuer: convexUrl }); +} + +export function requireAdmin(opts: RequireAdminOptions = {}): RequestHandler { + const verify = opts.verifyJwt ?? defaultVerifier(); + return async (req: Request, res: Response, next: NextFunction) => { + if (isPublic(req)) { + next(); + return; + } + const header = req.headers.authorization; + if (!header || typeof header !== "string" || !header.startsWith("Bearer ")) { + res.status(401).json({ error: "missing or malformed Authorization header" }); + return; + } + const token = header.slice("Bearer ".length).trim(); + if (!token) { + res.status(401).json({ error: "empty bearer token" }); + return; + } + try { + await verify(token); + next(); + } catch (err) { + res.status(401).json({ error: "invalid token" }); + } + }; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npm test` +Expected: PASS — all 11 tests across `verifyHmac` and `requireAdmin` pass. + +- [ ] **Step 5: Typecheck** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add server/auth.ts server/auth.test.ts +git commit -m "feat(auth): add requireAdmin JWT middleware with public allowlist" +``` + +--- + +## Task 4: Wire HMAC + phone whitelist into `server/sendblue.ts` (TDD) + +**Files:** +- Modify: `server/sendblue.ts` +- Create: `server/sendblue.test.ts` + +The Sendblue webhook needs to: +1. Capture the raw body (Express's `express.json` already gave us the parsed object, but we need the raw bytes for HMAC). Use a body-parser verify hook to stash the raw buffer on the request. +2. HMAC-verify the raw body against `X-Sendblue-Signature` using `SENDBLUE_SIGNING_SECRET`. Reject with 401 on mismatch. +3. After parsing, reject with 403 if `from_number !== SENDBLUE_FROM_NUMBER`. + +**Important:** Express's global `express.json()` parser is configured at the top of `server/index.ts`. We want a per-route raw-body capture so the global JSON parsing still works for everything else. Pass a `verify` function on a route-scoped JSON middleware. + +- [ ] **Step 1: Refactor the webhook to be testable** + +Edit `server/sendblue.ts`. Replace the `router.post("/webhook", ...)` block so we can extract the auth check as a separate, callable handler. Insert at the top of the existing webhook handler — before the `req.body` destructure: + +```ts +import { verifyHmac } from "./auth.js"; +// ... (existing imports) + +export function createSendblueRouter(): express.Router { + const router = express.Router(); + + // Capture the raw body for HMAC verification. Stored as a Buffer on the + // request via the `verify` hook on a route-scoped JSON parser. We must + // NOT use the globally-installed express.json() parser — we need the raw + // bytes BEFORE JSON parsing happens. + const jsonWithRaw = express.json({ + limit: "2mb", + verify: (req, _res, buf) => { + (req as any).rawBody = buf; + }, + }); + + router.post("/webhook", jsonWithRaw, async (req, res) => { + const signingSecret = process.env.SENDBLUE_SIGNING_SECRET; + const expectedFrom = process.env.SENDBLUE_FROM_NUMBER; + + // Perimeter A check 1: HMAC signature. + if (signingSecret) { + const sig = req.header("x-sendblue-signature") ?? undefined; + const raw = (req as any).rawBody as Buffer | undefined; + const ok = raw && verifyHmac(raw.toString("utf8"), sig, signingSecret); + if (!ok) { + res.status(401).json({ error: "invalid signature" }); + return; + } + } else { + console.warn( + "[sendblue] SENDBLUE_SIGNING_SECRET not set — webhook accepts unsigned requests. " + + "Required for any non-localhost deployment.", + ); + } + + const { content, from_number, is_outbound, message_handle } = req.body ?? {}; + if (is_outbound || !content || !from_number) { + res.json({ ok: true, skipped: true }); + return; + } + + // Perimeter A check 2: phone whitelist. + if (expectedFrom && from_number !== expectedFrom) { + res.status(403).json({ error: "phone not allowed" }); + return; + } + + // (rest of the existing handler is unchanged from here down) + if (message_handle) { + // ... existing dedup + processing logic stays as-is + } + // ... etc + }); + + return router; +} +``` + +The replacement must keep all existing logic that follows the dedup check — only the top of the handler changes. Read the existing `server/sendblue.ts` to preserve every behavior below the new checks. + +Note: there's an existing globally-installed `express.json({ limit: "2mb" })` in `server/index.ts`. The route-scoped `jsonWithRaw` runs before the global parser sees this route, so the raw bytes are captured first. Express's matchers run middleware in registration order; route handlers' route-scoped middleware always runs in addition to global ones, but since both parse the same body once parsed, only the first one wins. To be safe in the test, the raw-body grab should happen on a route-mounted parser as written above. + +- [ ] **Step 2: Write the failing tests** + +Create `server/sendblue.test.ts`: + +```ts +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import express from "express"; +import type { Server } from "node:http"; +import { createSendblueRouter } from "./sendblue.ts"; + +const SIGNING_SECRET = "test-signing-secret"; +const FROM_NUMBER = "+15555550100"; +const OTHER_NUMBER = "+15555550999"; + +function sign(body: string): string { + return createHmac("sha256", SIGNING_SECRET).update(body).digest("hex"); +} + +let server: Server; +let baseUrl: string; + +before(async () => { + process.env.SENDBLUE_SIGNING_SECRET = SIGNING_SECRET; + process.env.SENDBLUE_FROM_NUMBER = FROM_NUMBER; + + const app = express(); + app.use("/sendblue", createSendblueRouter()); + await new Promise((resolve) => { + server = app.listen(0, () => resolve()); + }); + const addr = server.address(); + if (typeof addr === "object" && addr) { + baseUrl = `http://127.0.0.1:${addr.port}`; + } else { + throw new Error("server did not bind"); + } +}); + +after(async () => { + await new Promise((resolve) => server.close(() => resolve())); +}); + +describe("sendblue webhook auth", () => { + it("rejects with 401 on missing signature", async () => { + const body = JSON.stringify({ content: "hi", from_number: FROM_NUMBER }); + const res = await fetch(`${baseUrl}/sendblue/webhook`, { + method: "POST", + headers: { "content-type": "application/json" }, + body, + }); + assert.equal(res.status, 401); + }); + + it("rejects with 401 on mismatched signature", async () => { + const body = JSON.stringify({ content: "hi", from_number: FROM_NUMBER }); + const res = await fetch(`${baseUrl}/sendblue/webhook`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-sendblue-signature": "deadbeef".repeat(8), + }, + body, + }); + assert.equal(res.status, 401); + }); + + it("rejects with 403 on wrong from_number", async () => { + const body = JSON.stringify({ content: "hi", from_number: OTHER_NUMBER }); + const res = await fetch(`${baseUrl}/sendblue/webhook`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-sendblue-signature": sign(body), + }, + body, + }); + assert.equal(res.status, 403); + }); + + it("accepts an outbound echo with valid sig (skipped)", async () => { + // is_outbound=true short-circuits to skipped before phone check, so this + // path verifies a happy-case signature with no Convex side effects. + const body = JSON.stringify({ is_outbound: true, content: "hi", from_number: FROM_NUMBER }); + const res = await fetch(`${baseUrl}/sendblue/webhook`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-sendblue-signature": sign(body), + }, + body, + }); + assert.equal(res.status, 200); + const json = (await res.json()) as { skipped?: boolean }; + assert.equal(json.skipped, true); + }); +}); +``` + +- [ ] **Step 3: Run tests to verify they fail or pass as expected** + +Run: `npm test` +Expected: all 4 cases pass (the implementation in step 1 already covers them). If a case fails, fix the implementation; do not skip the failing test. + +- [ ] **Step 4: Typecheck** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add server/sendblue.ts server/sendblue.test.ts +git commit -m "feat(sendblue): verify HMAC signature and phone whitelist on inbound webhook" +``` + +--- + +## Task 5: Add `convex/auth.config.ts` and `convex/auth.ts`, install `@convex-dev/auth` + +**Files:** +- Modify: `package.json` +- Create: `convex/auth.config.ts` +- Create: `convex/auth.ts` +- Modify: `convex/schema.ts` + +We add the Convex Auth library (which manages the `users` and `authAccounts` tables) at the schema level so subsequent tasks can reference it. + +- [ ] **Step 1: Install `@convex-dev/auth` and `@auth/core` (peer)** + +Run: `npm install @convex-dev/auth @auth/core` +Expected: install succeeds. `@auth/core` is the peer dep used by Convex Auth's password provider. + +- [ ] **Step 2: Create `convex/auth.config.ts`** + +```ts +// Convex Auth provider config — single password provider, single user. +// See https://labs.convex.dev/auth for full docs. +export default { + providers: [ + { + domain: process.env.CONVEX_SITE_URL, + applicationID: "convex", + }, + ], +}; +``` + +- [ ] **Step 3: Create `convex/auth.ts`** + +```ts +import { Password } from "@convex-dev/auth/providers/Password"; +import { convexAuth } from "@convex-dev/auth/server"; + +export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({ + providers: [Password], +}); +``` + +- [ ] **Step 4: Update `convex/schema.ts`** + +Add the auth tables. Replace the top of `convex/schema.ts`: + +```ts +import { defineSchema, defineTable } from "convex/server"; +import { authTables } from "@convex-dev/auth/server"; +import { v } from "convex/values"; + +export default defineSchema({ + ...authTables, + + // ... (everything else unchanged) +}); +``` + +- [ ] **Step 5: Typecheck** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add package.json package-lock.json convex/auth.config.ts convex/auth.ts convex/schema.ts +git commit -m "feat(convex): add Convex Auth password provider" +``` + +--- + +## Task 6: Add `convex/users.ts` with `bootstrap` and `setPassword` actions + +**Files:** +- Create: `convex/users.ts` + +Two `internalAction`s: + +- `bootstrap` — checks if any user exists; if not, creates one using `BOOP_ADMIN_PASSWORD` from Convex env. Idempotent. +- `setPassword` — finds the single user, updates their password hash. Used for rotation. + +Both rely on Convex Auth's `signIn` action with the password provider's `flow: "signUp"` for creation. There's no clean public API to mutate password hashes without going through the provider; for rotation we can leverage `signIn` with `flow: "reset"` if the password provider supports it, or call the underlying account-store helpers. Convex Auth exposes a `Password` provider with `verify` and `crypto` hooks; the simplest reliable rotation is delete-and-recreate. + +- [ ] **Step 1: Create `convex/users.ts`** + +We use `createAccount` from `@convex-dev/auth/server` for headless bootstrap. This is the documented server-side path — calling `signIn` from an internalAction is not the right pattern (it's a public action surfaced via `api.auth.signIn`, intended for client → server flow). + +```ts +import { internalAction, internalQuery, internalMutation } from "./_generated/server.js"; +import { internal } from "./_generated/api.js"; +import { createAccount } from "@convex-dev/auth/server"; + +const ADMIN_EMAIL = "admin@boop.local"; + +export const countUsers = internalQuery({ + args: {}, + handler: async (ctx) => { + const users = await ctx.db.query("users").take(2); + return users.length; + }, +}); + +export const deleteAllUsersAndAccounts = internalMutation({ + args: {}, + handler: async (ctx) => { + for await (const user of ctx.db.query("users")) { + await ctx.db.delete(user._id); + } + for await (const acc of ctx.db.query("authAccounts")) { + await ctx.db.delete(acc._id); + } + for await (const sess of ctx.db.query("authSessions")) { + await ctx.db.delete(sess._id); + } + }, +}); + +// Idempotent: safe to run on every deploy. +export const bootstrap = internalAction({ + args: {}, + handler: async (ctx) => { + const existing = await ctx.runQuery(internal.users.countUsers, {}); + if (existing > 0) return { created: false, reason: "user already exists" }; + + const password = process.env.BOOP_ADMIN_PASSWORD; + if (!password) { + throw new Error( + "BOOP_ADMIN_PASSWORD is not set in Convex env — cannot bootstrap admin user.", + ); + } + + await createAccount(ctx, { + provider: "password", + account: { id: ADMIN_EMAIL, secret: password }, + profile: { email: ADMIN_EMAIL }, + }); + + return { created: true }; + }, +}); + +// Rotation: wipe and recreate from the current BOOP_ADMIN_PASSWORD env var. +// Old sessions invalidate naturally on next token expiry. +export const setPassword = internalAction({ + args: {}, + handler: async (ctx) => { + await ctx.runMutation(internal.users.deleteAllUsersAndAccounts, {}); + const password = process.env.BOOP_ADMIN_PASSWORD; + if (!password) { + throw new Error("BOOP_ADMIN_PASSWORD is not set"); + } + await createAccount(ctx, { + provider: "password", + account: { id: ADMIN_EMAIL, secret: password }, + profile: { email: ADMIN_EMAIL }, + }); + return { rotated: true }; + }, +}); +``` + +If `createAccount`'s exact argument shape differs in the installed version, check `node_modules/@convex-dev/auth/dist/server/index.d.ts` for the type signature and adjust. The function is the documented server-side bootstrap path. + +- [ ] **Step 2: Typecheck** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add convex/users.ts +git commit -m "feat(convex): add bootstrap + setPassword admin actions" +``` + +--- + +## Task 7: Classify Convex functions and migrate Express callers (combined) + +**Files modified:** all `convex/*.ts` source files except `auth.ts`, `auth.config.ts`, `schema.ts`, `users.ts`. **Also:** every `server/*.ts` file that calls a Convex function which becomes internal. + +This task is intentionally larger than the others — splitting it would commit a non-compiling state to `main` (broken Express types). Do the Convex side, then immediately fix the Express callers, then commit once. + +For each function in each Convex source file, decide: + +- **Public + auth-checked** (`query` / `mutation`) — called by the browser dashboard. Must add `await ctx.auth.getUserIdentity()` and `throw new Error("unauthenticated")` if null at the top of the handler. +- **Internal** (`internalQuery` / `internalMutation` / `internalAction`) — called only by Express server-side. Convert the export, no auth check needed. + +Use the classification below. It is the source of truth for this task. After classification, the Express server will need to update its imports from `api.x.y` → `internal.x.y` for everything that became internal. That follow-up happens in Task 8. + +**Classification table** (read carefully — picking the wrong category breaks the dashboard or the server): + +| File | Function | Category | +|---|---|---| +| `agents.ts` | `create` | internal | +| `agents.ts` | `update` | internal | +| `agents.ts` | `addLog` | internal | +| `agents.ts` | `list` | public+auth | +| `agents.ts` | `get` | public+auth | +| `agents.ts` | `getLogs` | public+auth | +| `automations.ts` | `create` | internal | +| `automations.ts` | `list` | public+auth | +| `automations.ts` | `get` | public+auth | +| `automations.ts` | `setEnabled` | public+auth | +| `automations.ts` | `remove` | public+auth | +| `automations.ts` | `markRan` | internal | +| `automations.ts` | `createRun` | internal | +| `automations.ts` | `updateRun` | internal | +| `automations.ts` | `recentRuns` | public+auth | +| `consolidation.ts` | `createRun` | internal | +| `consolidation.ts` | `updateRun` | internal | +| `consolidation.ts` | `listRuns` | public+auth | +| `conversations.ts` | `list` | public+auth | +| `conversations.ts` | `get` | public+auth | +| `dashboard.ts` | `metrics` | public+auth | +| `drafts.ts` | `create` | internal | +| `drafts.ts` | `get` | public+auth | +| `drafts.ts` | `pendingByConversation` | public+auth | +| `drafts.ts` | `recent` | public+auth | +| `drafts.ts` | `setStatus` | internal | +| `memoryEvents.ts` | `emit` | internal | +| `memoryEvents.ts` | `recent` | public+auth | +| `memoryEvents.ts` | `byConversation` | public+auth | +| `memoryRecords.ts` | `upsert` | internal | +| `memoryRecords.ts` | `getByIds` | internal | +| `memoryRecords.ts` | `vectorSearch` | internal (action) | +| `memoryRecords.ts` | `list` | public+auth | +| `memoryRecords.ts` | `search` | public+auth | +| `memoryRecords.ts` | `markAccessed` | internal | +| `memoryRecords.ts` | `setLifecycle` | internal | +| `memoryRecords.ts` | `countsByTier` | public+auth | +| `messages.ts` | `send` | internal | +| `messages.ts` | `list` | public+auth | +| `messages.ts` | `recent` | public+auth | +| `sendblueDedup.ts` | `claim` | internal | +| `usageRecords.ts` | `record` | internal | +| `usageRecords.ts` | `byConversation` | public+auth | +| `usageRecords.ts` | `recent` | public+auth | +| `usageRecords.ts` | `summary` | public+auth | + +- [ ] **Step 0: Verify the classification table against actual usage** + +Before changing any function visibility, sanity-check the table by grepping the dashboard's Convex usage: + +Run: `grep -rn "api\.[a-zA-Z]*\." debug/src/` +Expected: every `api.X.Y` reference in the output corresponds to a row marked **public+auth** in the table below. If you find a usage that the table marks as **internal**, the dashboard would break — stop and surface the discrepancy before proceeding. (Reference: at the time the plan was written, the dashboard uses `api.memoryRecords.{countsByTier,list}`, `api.memoryEvents.recent`, `api.automations.{list,get,recentRuns,setEnabled,remove}`, `api.agents.{list,get,getLogs}`, `api.dashboard.metrics`, `api.consolidation.listRuns`. All of these are public+auth in the table.) + +- [ ] **Step 1: Define a shared auth-check helper** + +Append to `convex/auth.ts`: + +```ts +import type { GenericQueryCtx, GenericMutationCtx } from "convex/server"; +import type { DataModel } from "./_generated/dataModel.js"; + +export async function requireUser( + ctx: GenericQueryCtx | GenericMutationCtx, +): Promise { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + throw new Error("unauthenticated"); + } +} +``` + +- [ ] **Step 2: Convert each file per the classification table** + +For each file in the table: + +For **internal** rows: change the import — `mutation` → `internalMutation`, `query` → `internalQuery`, `action` → `internalAction`. Adjust each affected `export const X = mutation(...)` → `export const X = internalMutation(...)`, etc. + +For **public+auth** rows: keep the `mutation` / `query` import. Add `import { requireUser } from "./auth.js";` (only once per file). At the top of the `handler` (immediately inside the `async (ctx, args) => {` line), add `await requireUser(ctx);`. + +Example — before: + +```ts +import { query } from "./_generated/server"; +import { v } from "convex/values"; + +export const list = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db.query("conversations").order("desc").take(50); + }, +}); +``` + +After (this function is public+auth): + +```ts +import { query } from "./_generated/server"; +import { v } from "convex/values"; +import { requireUser } from "./auth.js"; + +export const list = query({ + args: {}, + handler: async (ctx) => { + await requireUser(ctx); + return await ctx.db.query("conversations").order("desc").take(50); + }, +}); +``` + +Example — before (function is internal): + +```ts +import { mutation } from "./_generated/server"; + +export const send = mutation({ + args: { /* ... */ }, + handler: async (ctx, args) => { /* ... */ }, +}); +``` + +After: + +```ts +import { internalMutation } from "./_generated/server"; + +export const send = internalMutation({ + args: { /* ... */ }, + handler: async (ctx, args) => { /* ... */ }, +}); +``` + +A file may contain a mix — import both `mutation` and `internalMutation` as needed. + +- [ ] **Step 3: For files containing both internal and public functions** + +Update the imports at the top of the file to bring in only what's used. E.g., a file that has only public-with-auth functions keeps `mutation`/`query` imports; a file that has only internal functions imports only `internalMutation`/`internalQuery`; a mixed file imports both. + +- [ ] **Step 4: Find Express callers that need migrating to `internal.X`** + +After step 3 the Convex side compiles, but Express files still reference `api.X.Y` for things that just became internal — those won't typecheck. + +Run: `npx tsc --noEmit 2>&1 | grep -i "Property .* does not exist"` (or simply read the typecheck output). + +- [ ] **Step 5: Update each broken Express caller** + +For every broken `convex.mutation(api.X.Y, ...)` / `convex.query(api.X.Y, ...)` / `convex.action(api.X.Y, ...)`, swap `api` → `internal` and update the import at the top of the file. Files calling a mix of public + internal need both imports: + +```ts +import { api, internal } from "../convex/_generated/api.js"; +``` + +The `convex` client (`server/convex-client.ts`) supports both shapes — only the function reference changes. + +- [ ] **Step 6: Typecheck and test** + +Run: `npx tsc --noEmit` +Expected: zero errors anywhere. + +Run: `npm test` +Expected: all tests still pass. + +- [ ] **Step 7: Commit (Convex + Express together)** + +```bash +git add convex/ server/ +git commit -m "refactor: classify Convex functions as internal vs public+auth" +``` + +--- + +## Task 8: Wire `requireAdmin` middleware globally + auth WS upgrade + serve debug UI from `server/index.ts` + +**Files:** +- Modify: `server/index.ts` + +Wire the auth middleware into the main Express app and add WebSocket upgrade authentication. Also serve the built debug UI as static assets from `debug/dist`. Static assets must be served BEFORE `requireAdmin` mounts — otherwise the SPA can't even load the login page. Data fetches from the SPA still go through JWT-gated APIs because the data routes are mounted AFTER `requireAdmin`. + +- [ ] **Step 1: Update `server/index.ts` middleware ordering** + +Add imports near the top of the file: + +```ts +import { requireAdmin } from "./auth.js"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +``` + +(Some imports may already exist — keep the file's existing import block and add only the missing ones.) + +Inside `main()`, replace the middleware-and-route registrations from `app.use(cors())` through `app.use("/composio", ...)` so the final ordering looks like this: + +```ts +app.use(cors()); +app.use(express.json({ limit: "2mb" })); + +// PUBLIC: health check. +app.get("/health", (_req, res) => { + res.json({ ok: true, service: "boop-agent" }); +}); + +// PUBLIC (in production): the built debug UI bundle. Static assets must +// load before the SPA can render the login form, so they're served +// BEFORE requireAdmin gates the API surface. +if (process.env.NODE_ENV === "production") { + const here = path.dirname(fileURLToPath(import.meta.url)); + const debugDist = path.resolve(here, "../../debug/dist"); + app.use(express.static(debugDist)); + app.get("/debug/*", (_req, res) => { + res.sendFile(path.join(debugDist, "index.html")); + }); +} + +// AUTH GATE: every route below requires a valid Convex Auth JWT, except +// the explicit allowlist inside requireAdmin() (/sendblue/webhook + /health). +app.use(requireAdmin()); + +app.use("/sendblue", createSendblueRouter()); +app.use("/composio", createComposioRouter()); +// ... existing /agents/:id/cancel, /consolidate, /agents/:id/retry, +// /chat routes follow unchanged +``` + +- [ ] **Step 2: Add WebSocket upgrade auth** + +Replace the existing WebSocket setup at the bottom of `main()`: + +```ts +const server = createServer(app); +const wss = new WebSocketServer({ noServer: true }); + +server.on("upgrade", async (req, socket, head) => { + if (req.url !== "/ws") { + socket.destroy(); + return; + } + // Token is passed as a ?token= query param. The browser EventSource / + // WebSocket APIs can't set custom headers on the handshake, so query is + // the standard workaround. + const url = new URL(req.url, `http://${req.headers.host}`); + const token = url.searchParams.get("token"); + if (!token) { + socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); + socket.destroy(); + return; + } + try { + const { jwtVerify, createRemoteJWKSet } = await import("jose"); + const convexUrl = process.env.CONVEX_URL!; + const jwks = createRemoteJWKSet(new URL("/.well-known/jwks.json", convexUrl)); + await jwtVerify(token, jwks, { issuer: convexUrl }); + } catch { + socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); + socket.destroy(); + return; + } + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit("connection", ws, req); + }); +}); + +wss.on("connection", (ws) => { + addClient(ws); + ws.send(JSON.stringify({ event: "hello", data: { ok: true }, at: Date.now() })); +}); +``` + +- [ ] **Step 3: Typecheck** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 4: Smoke test locally** + +Run: `npm start` +- In another terminal: `curl -i http://localhost:3456/health` → expect `200 OK`. +- `curl -i http://localhost:3456/chat -X POST -H 'content-type: application/json' -d '{}'` → expect `401`. +- `curl -i http://localhost:3456/sendblue/webhook -X POST -H 'content-type: application/json' -d '{}'` → expect `401` (signing secret missing in env triggers unsigned-request rejection in production-like flow; in local dev with no `SENDBLUE_SIGNING_SECRET` set, it falls through and returns 200 with `skipped:true`. Both are valid.) + +Stop the server. + +- [ ] **Step 5: Commit** + +```bash +git add server/index.ts +git commit -m "feat(server): wire requireAdmin middleware, auth WS upgrade, serve debug UI" +``` + +--- + +## Task 9: Wrap debug UI in `` and add login form + +**Files:** +- Modify: `debug/package.json` +- Modify: `debug/src/main.tsx` +- Modify: `debug/src/App.tsx` +- Create: `debug/src/auth.tsx` + +- [ ] **Step 1: Install `@convex-dev/auth` in the debug workspace** + +The repo uses a single root `package.json`, but `@convex-dev/auth/react` will be resolved from there. Verify it's listed in the root `package.json` `dependencies` from Task 5; if not, add it. The debug build via `vite` will pick it up automatically. + +Run: `npm ls @convex-dev/auth` +Expected: shows the package installed. + +- [ ] **Step 2: Wrap the app in `ConvexAuthProvider`** + +Edit `debug/src/main.tsx`. Replace the `` wrapper: + +```tsx +import { ConvexAuthProvider } from "@convex-dev/auth/react"; +// ... rest of imports + +// inside the ReactDOM.createRoot(...).render block: + + + +``` + +(Removing the bare `` — `` is a superset.) + +- [ ] **Step 3: Create the login form** + +Create `debug/src/auth.tsx`: + +```tsx +import { useState } from "react"; +import { useAuthActions } from "@convex-dev/auth/react"; + +export function LoginForm() { + const { signIn } = useAuthActions(); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setSubmitting(true); + setError(null); + try { + await signIn("password", { + email: "admin@boop.local", + password, + flow: "signIn", + }); + } catch (err) { + setError("Wrong password."); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+
+ Boop +

Boop Debug

+
+ setPassword(e.target.value)} + placeholder="Password" + required + autoFocus + className="w-full bg-slate-900 border border-slate-800 rounded px-3 py-2 text-sm" + /> + {error &&
{error}
} + +
+
+ ); +} +``` + +- [ ] **Step 4: Gate `` on auth state** + +Edit `debug/src/App.tsx`. At the top of the existing `export function App()` body: + +```tsx +import { Authenticated, Unauthenticated, AuthLoading } from "convex/react"; +import { LoginForm } from "./auth.js"; + +export function App() { + return ( + <> + +
Loading…
+
+ + + + + + + + ); +} + +function AppInner() { + // ... the original App body goes here, unchanged +} +``` + +The split keeps the original `App` body intact — only renamed to `AppInner`. + +- [ ] **Step 5: Build the debug UI** + +Run: `npm run build:debug` +Expected: `debug/dist/` is created with `index.html` and built assets. + +- [ ] **Step 6: Typecheck** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 7: Commit** + +```bash +git add debug/src/main.tsx debug/src/App.tsx debug/src/auth.tsx debug/package.json +git commit -m "feat(debug): wrap UI in ConvexAuthProvider and add login form" +``` + +--- + +## Task 10: Add `debug/src/api-client.ts` and migrate `fetch` calls + +**Files:** +- Create: `debug/src/api-client.ts` +- Modify: `debug/src/components/ConsolidationPanel.tsx` +- Modify: `debug/src/components/ComposioSection.tsx` + +`@convex-dev/auth/react` exposes a way to read the current JWT via the `useAuthToken` hook (or fetched via the auth client). The `apiClient` wraps `fetch` to attach the token automatically. + +- [ ] **Step 1: Write `debug/src/api-client.ts`** + +```ts +import { useCallback } from "react"; +import { useAuthToken } from "@convex-dev/auth/react"; + +export function useApiClient() { + const token = useAuthToken(); + + return useCallback( + async (input: string, init: RequestInit = {}): Promise => { + const headers = new Headers(init.headers); + if (token) headers.set("authorization", `Bearer ${token}`); + return fetch(input, { ...init, headers }); + }, + [token], + ); +} +``` + +- [ ] **Step 2: Migrate `ConsolidationPanel.tsx`** + +Find the existing `fetch("/api/consolidate", { method: "POST" })` call (line ~108). Convert the component to use `useApiClient`: + +```tsx +import { useApiClient } from "../api-client.js"; + +// inside the component: +const apiClient = useApiClient(); +// inside the relevant async handler: +await apiClient("/api/consolidate", { method: "POST" }); +``` + +- [ ] **Step 3: Migrate `ComposioSection.tsx`** + +There are 6 `fetch(...)` calls in `debug/src/components/ComposioSection.tsx` (lines 103, 122, 159, 179, 206, 232). Wire `useApiClient()` once at the top of the component and replace each `fetch(` with `apiClient(`. Argument shapes are identical. + +- [ ] **Step 4: Build the debug UI** + +Run: `npm run build:debug` +Expected: build succeeds. + +- [ ] **Step 5: Typecheck** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add debug/src/api-client.ts debug/src/components/ConsolidationPanel.tsx debug/src/components/ComposioSection.tsx +git commit -m "feat(debug): add authed apiClient wrapper and migrate fetch calls" +``` + +--- + +## Task 11: Add `Dockerfile` and `.dockerignore` + +**Files:** +- Create: `Dockerfile` +- Create: `.dockerignore` + +The Dockerfile uses `node:22-slim` (Debian Bookworm slim) — Debian for SSH-friendliness when operators `fly ssh console` in, slim to keep the image small. We **don't** compile TS to JS; we run with `tsx` at runtime, matching the existing `npm start` script. + +- [ ] **Step 1: Create `Dockerfile`** + +```dockerfile +# ---- Stage 1: install deps ---- +FROM node:22-slim AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +# ---- Stage 2: build debug UI bundle ---- +FROM node:22-slim AS build +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +# convex/_generated is gitignored, so it's not in the build context. +# Generate it inside the image so the debug UI build (which imports types +# from ../convex/_generated/api) can resolve them. +RUN npx convex codegen --typecheck=disable +RUN npm run build:debug + +# ---- Stage 3: runtime ---- +FROM node:22-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production +COPY --from=deps /app/node_modules ./node_modules +COPY --from=build /app/server ./server +COPY --from=build /app/convex ./convex +COPY --from=build /app/debug/dist ./debug/dist +COPY --from=build /app/scripts/preflight.mjs ./scripts/preflight.mjs +COPY package.json tsconfig.json ./ +EXPOSE 3456 +USER node +CMD ["npx", "tsx", "server/index.ts"] +``` + +- [ ] **Step 2: Create `.dockerignore`** + +``` +node_modules +debug/dist +debug/node_modules +.env +.env.local +.env.*.local +.git +.github +.claude +.cursor +.idea +.vscode +docs +assets +*.md +tests +__tests__ +**/*.test.ts +``` + +- [ ] **Step 3: Build the image locally** + +Run: `docker build -t boop-agent .` +Expected: build succeeds; final image size < 300 MB. + +If `docker` is not available on the host, skip this step and run it in a later task or note in the PR description. + +- [ ] **Step 4: Commit** + +```bash +git add Dockerfile .dockerignore +git commit -m "feat(deploy): add multi-stage Dockerfile (node:22-slim, tsx runtime)" +``` + +--- + +## Task 12: Add `fly.toml` + +**Files:** +- Create: `fly.toml` + +- [ ] **Step 1: Write `fly.toml`** + +```toml +# Fly app config. Generated by scripts/deploy.ts on first deploy and +# checked in for reproducibility. +# +# IMPORTANT: min_machines_running = 1 + auto_stop_machines = false is the +# explicit "exactly one replica, always running" config required by boop's +# in-process background loops (cleanup, automation, heartbeat, consolidation). +# Scaling beyond one replica without a Convex-level coordination lock causes +# automation double-fire and consolidation duplicate-cost. + +app = "boop-agent" +primary_region = "iad" + +[build] + # uses Dockerfile + +[http_service] + internal_port = 3456 + force_https = true + auto_stop_machines = false + auto_start_machines = false + min_machines_running = 1 + processes = ["app"] + +[[http_service.checks]] + grace_period = "10s" + interval = "30s" + method = "GET" + path = "/health" + timeout = "5s" + +[[vm]] + size = "shared-cpu-1x" + memory = "512mb" +``` + +The `app` value will be overwritten per-fork by `scripts/deploy.ts` on first run via `fly apps create ` + `flyctl deploy --app `. We commit a default of `boop-agent` so `flyctl deploy` from the repo works once a fork has run `scripts/deploy.ts` and edited it (or set `FLY_APP_NAME` in CI). + +- [ ] **Step 2: Commit** + +```bash +git add fly.toml +git commit -m "feat(deploy): add fly.toml (single machine, always-on)" +``` + +--- + +## Task 13: Update `.env.example` + +**Files:** +- Modify: `.env.example` + +Add three new sections in Chris's existing comment style. Order follows the file's existing flow (Sendblue → Claude → Boop dashboard). + +- [ ] **Step 1: Add the new sections** + +Insert after the existing Sendblue section (after `SENDBLUE_FROM_NUMBER=`): + +```bash + +# ---- Sendblue webhook signing ---- +# Get this from your Sendblue dashboard under Webhook Settings → Signing Secret. +# Required when running on a public URL — the webhook handler verifies every +# incoming request's HMAC-SHA256 signature against this secret. +SENDBLUE_SIGNING_SECRET= +``` + +After the existing Claude section (after the `# ANTHROPIC_API_KEY=` line), append: + +```bash + +# When deploying to a server, prefer CLAUDE_CODE_OAUTH_TOKEN (subscription) +# over ANTHROPIC_API_KEY. Generate one locally with `claude setup-token`, +# paste it as a Fly secret. Token lasts 1 year, then regenerate. +# CLAUDE_CODE_OAUTH_TOKEN= +``` + +After the existing Server section (after the `# SENDBLUE_AUTO_WEBHOOK=true` line), append: + +```bash + +# ---- Boop dashboard / admin auth (deployment only) ---- +# The single password for the dashboard and admin endpoints when deployed. +# `npm run deploy` will offer to auto-generate a 32-char random value. +# Set as both a Fly secret AND a Convex env var (the dashboard auth +# verifies via Convex; the bootstrap action reads from Convex env). +BOOP_ADMIN_PASSWORD= +``` + +- [ ] **Step 2: Commit** + +```bash +git add .env.example +git commit -m "docs(env): document SENDBLUE_SIGNING_SECRET, BOOP_ADMIN_PASSWORD, CLAUDE_CODE_OAUTH_TOKEN" +``` + +--- + +## Task 14: Add `.github/workflows/deploy.yml` + +**Files:** +- Create: `.github/workflows/deploy.yml` + +- [ ] **Step 1: Write the workflow** + +```yaml +name: Deploy + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + concurrency: + group: deploy-${{ github.ref }} + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - name: Run unit tests + run: npm test + + - name: Push Convex backend + run: npx convex deploy --yes + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} + + - name: Bootstrap admin user (idempotent) + # CONVEX_DEPLOY_KEY scopes the call to the production deployment + # automatically. If the Convex CLI version in use rejects this, + # add `--prod` explicitly. + run: npx convex run users:bootstrap + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} + + - uses: superfly/flyctl-actions/setup-flyctl@master + + - name: Deploy to Fly + run: flyctl deploy --remote-only --app "$FLY_APP_NAME" + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + FLY_APP_NAME: ${{ secrets.FLY_APP_NAME }} + + - name: Smoke test + run: | + for i in {1..30}; do + if curl -fsS "https://${FLY_APP_NAME}.fly.dev/health"; then + exit 0 + fi + sleep 5 + done + echo "health check failed after 150s" + exit 1 + env: + FLY_APP_NAME: ${{ secrets.FLY_APP_NAME }} +``` + +- [ ] **Step 2: Commit** + +```bash +git add .github/workflows/deploy.yml +git commit -m "feat(ci): add deploy workflow (test → convex → bootstrap → fly → smoke)" +``` + +--- + +## Task 15: Add `scripts/deploy.ts` interactive deploy setup + +**Files:** +- Create: `scripts/deploy.ts` + +This script mirrors `scripts/setup.ts` patterns. It is **standalone** — no imports from `setup.ts`. Re-implement the helpers (`banner`, `hasBinary`, `runInherit`, `runCapture`, `openInBrowser`) inline. + +The script flow is the 9-step sequence in the design spec's "scripts/deploy.ts" section. ~280 lines. Do not write it from scratch in one go; build it section by section. + +- [ ] **Step 1: Scaffold the script with helpers** + +```ts +#!/usr/bin/env tsx +import prompts from "prompts"; +import { spawn } from "node:child_process"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { randomBytes } from "node:crypto"; + +const ROOT = resolve(new URL(".", import.meta.url).pathname, ".."); +const ENV_PATH = resolve(ROOT, ".env.local"); + +function banner(s: string) { + console.log("\n" + "━".repeat(60)); + console.log(" " + s); + console.log("━".repeat(60)); +} + +function readEnv(path: string): Record { + if (!existsSync(path)) return {}; + const env: Record = {}; + for (const line of readFileSync(path, "utf8").split("\n")) { + const m = line.match(/^([A-Z0-9_]+)=(.*)$/); + if (m) env[m[1]] = m[2]; + } + return env; +} + +function hasBinary(name: string): Promise { + return new Promise((ok) => { + const lookup = process.platform === "win32" ? "where" : "which"; + const child = spawn(lookup, [name], { stdio: "ignore" }); + child.on("exit", (code) => ok(code === 0)); + child.on("error", () => ok(false)); + }); +} + +function openInBrowser(url: string): void { + const cmd = + process.platform === "darwin" + ? "open" + : process.platform === "win32" + ? "start" + : "xdg-open"; + try { + spawn(cmd, [url], { stdio: "ignore", detached: true }).unref(); + } catch { + /* ignore */ + } +} + +function runInherit(cmd: string, args: string[]): Promise { + return new Promise((ok, fail) => { + const child = spawn(cmd, args, { stdio: "inherit", cwd: ROOT }); + child.on("exit", (code) => + code === 0 ? ok() : fail(new Error(`${cmd} ${args.join(" ")} exited ${code}`)), + ); + child.on("error", fail); + }); +} + +function runCapture(cmd: string, args: string[]): Promise { + return new Promise((ok, fail) => { + const child = spawn(cmd, args, { stdio: ["inherit", "pipe", "pipe"], cwd: ROOT }); + let out = ""; + child.stdout.on("data", (d) => { + const s = d.toString(); + out += s; + process.stdout.write(s); + }); + child.stderr.on("data", (d) => process.stderr.write(d)); + child.on("exit", (code) => + code === 0 ? ok(out) : fail(new Error(`${cmd} exited ${code}`)), + ); + child.on("error", fail); + }); +} + +function genPassword(): string { + return randomBytes(24).toString("base64url"); +} + +async function main() { + banner("Boop deploy — sets up Fly + Convex production deploy"); + + // ... (filled in by following steps) +} + +main().catch((err) => { + console.error("\n[deploy] failed:", err.message); + process.exit(1); +}); +``` + +- [ ] **Step 2: Step 1 of the deploy flow — verify dev setup** + +Inside `main()`, after the banner: + +```ts +banner("1. Verifying local setup"); +const env = readEnv(ENV_PATH); +if (!env.CONVEX_DEPLOYMENT) { + const { runSetup } = await prompts({ + type: "confirm", + name: "runSetup", + message: "No CONVEX_DEPLOYMENT in .env.local — run `npm run setup` first?", + initial: true, + }); + if (runSetup) { + await runInherit("npm", ["run", "setup"]); + Object.assign(env, readEnv(ENV_PATH)); + } else { + throw new Error("CONVEX_DEPLOYMENT must be set before deploying."); + } +} +console.log("✓ Local Convex deployment configured."); +``` + +- [ ] **Step 3: Step 2 — Fly account and app creation** + +```ts +banner("2. Fly.io app"); +if (!(await hasBinary("fly"))) { + console.log( + "Fly CLI not found. Install with: curl -L https://fly.io/install.sh | sh", + ); + throw new Error("Install fly CLI and re-run."); +} +try { + await runCapture("fly", ["auth", "whoami"]); +} catch { + console.log("Not logged in. Running `fly auth login`..."); + await runInherit("fly", ["auth", "login"]); +} + +const { appName } = await prompts({ + type: "text", + name: "appName", + message: "Fly app name (must be globally unique):", + initial: env.FLY_APP_NAME ?? "", + validate: (v: string) => /^[a-z0-9-]{3,40}$/.test(v) || "lowercase letters, digits, dashes", +}); + +let appExists = false; +try { + await runCapture("fly", ["apps", "list", "--json"]).then((out) => { + const apps = JSON.parse(out); + appExists = apps.some((a: { Name: string }) => a.Name === appName); + }); +} catch { + /* fall through — try to create */ +} + +if (!appExists) { + await runInherit("fly", ["apps", "create", appName]); +} + +const PUBLIC_URL = `https://${appName}.fly.dev`; +console.log(`✓ App ready: ${PUBLIC_URL}`); +``` + +- [ ] **Step 4: Step 3 — generate secrets** + +```ts +banner("3. Generate secrets for production"); + +let llmAuth: { name: string; value: string }; +const { llmChoice } = await prompts({ + type: "select", + name: "llmChoice", + message: "Which LLM auth?", + choices: [ + { title: "Claude Code subscription token (recommended)", value: "oauth" }, + { title: "Anthropic API key (per-token billing)", value: "api" }, + ], + initial: 0, +}); + +if (llmChoice === "oauth") { + console.log("\nIn another terminal, run: claude setup-token"); + console.log("It will print a token. Paste it below."); + const { token } = await prompts({ + type: "password", + name: "token", + message: "CLAUDE_CODE_OAUTH_TOKEN:", + }); + llmAuth = { name: "CLAUDE_CODE_OAUTH_TOKEN", value: token }; +} else { + const { key } = await prompts({ + type: "password", + name: "key", + message: "ANTHROPIC_API_KEY:", + }); + llmAuth = { name: "ANTHROPIC_API_KEY", value: key }; +} + +const { signingSecret } = await prompts({ + type: "password", + name: "signingSecret", + message: "SENDBLUE_SIGNING_SECRET (Sendblue dashboard → Webhook → Signing Secret):", +}); + +const adminPassword = env.BOOP_ADMIN_PASSWORD || genPassword(); +console.log(`\nGenerated BOOP_ADMIN_PASSWORD: ${adminPassword}`); +console.log("(Save this — you'll use it to log into the dashboard.)"); +``` + +- [ ] **Step 5: Step 4 — push secrets to Fly** + +```ts +banner("4. Pushing secrets to Fly"); + +const flySecrets: Record = { + [llmAuth.name]: llmAuth.value, + SENDBLUE_API_KEY: env.SENDBLUE_API_KEY ?? "", + SENDBLUE_API_SECRET: env.SENDBLUE_API_SECRET ?? "", + SENDBLUE_FROM_NUMBER: env.SENDBLUE_FROM_NUMBER ?? "", + SENDBLUE_SIGNING_SECRET: signingSecret, + CONVEX_DEPLOYMENT: env.CONVEX_DEPLOYMENT ?? "", + CONVEX_URL: env.CONVEX_URL ?? "", + COMPOSIO_API_KEY: env.COMPOSIO_API_KEY ?? "", + BOOP_ADMIN_PASSWORD: adminPassword, + PUBLIC_URL, + NODE_ENV: "production", +}; + +const setArgs = ["secrets", "set", "--app", appName]; +for (const [k, v] of Object.entries(flySecrets)) { + if (v) setArgs.push(`${k}=${v}`); +} +await runInherit("fly", setArgs); +``` + +- [ ] **Step 6: Step 5 — Convex env** + +```ts +banner("5. Configuring Convex env"); +console.log("Setting BOOP_ADMIN_PASSWORD on the production Convex deployment..."); +await runInherit("npx", [ + "convex", + "env", + "set", + "BOOP_ADMIN_PASSWORD", + adminPassword, +]); +``` + +- [ ] **Step 7: Step 6 — Sendblue webhook** + +```ts +banner("6. Sendblue webhook"); +console.log(`Open the Sendblue dashboard and set the INBOUND webhook to:`); +console.log(` ${PUBLIC_URL}/sendblue/webhook`); +openInBrowser("https://app.sendblue.com/settings/webhooks"); +const { webhookSet } = await prompts({ + type: "confirm", + name: "webhookSet", + message: "Done?", + initial: true, +}); +if (!webhookSet) { + console.log("⚠️ Skipping for now — you must set this before iMessages reach the server."); +} +``` + +- [ ] **Step 8: Step 7 — GitHub repo secrets** + +```ts +banner("7. GitHub Actions secrets"); +if (await hasBinary("gh")) { + const { useGh } = await prompts({ + type: "confirm", + name: "useGh", + message: "Push secrets to GitHub via `gh secret set`?", + initial: true, + }); + if (useGh) { + const flyToken = await runCapture("fly", ["auth", "token"]).then((s) => s.trim()); + const convexDeployKey = await prompts({ + type: "password", + name: "k", + message: "Convex deploy key (https://dashboard.convex.dev → project → Deploy Keys):", + }).then((r) => r.k); + await runInherit("gh", ["secret", "set", "FLY_API_TOKEN", "--body", flyToken]); + await runInherit("gh", ["secret", "set", "FLY_APP_NAME", "--body", appName]); + await runInherit("gh", [ + "secret", + "set", + "CONVEX_DEPLOY_KEY", + "--body", + convexDeployKey, + ]); + } +} else { + console.log("Install `gh` CLI to auto-set secrets, or set them manually:"); + console.log(" - FLY_API_TOKEN (run `fly auth token`)"); + console.log(` - FLY_APP_NAME = ${appName}`); + console.log(" - CONVEX_DEPLOY_KEY (Convex dashboard → Deploy Keys)"); +} +``` + +- [ ] **Step 9: Step 8 — first deploy** + +```ts +banner("8. First deploy"); +const { deployNow } = await prompts({ + type: "confirm", + name: "deployNow", + message: "Run `fly deploy --remote-only` now?", + initial: true, +}); +if (deployNow) { + await runInherit("fly", ["deploy", "--remote-only", "--app", appName]); + console.log("\nBootstrapping admin user..."); + await runInherit("npx", ["convex", "run", "users:bootstrap"]); +} +``` + +- [ ] **Step 10: Step 9 — closing footer** + +```ts +banner("Done!"); +console.log(`Dashboard: ${PUBLIC_URL}/`); +console.log(`Health: ${PUBLIC_URL}/health`); +console.log(`Webhook: ${PUBLIC_URL}/sendblue/webhook`); +console.log(""); +console.log("Future deploys: `git push origin main` triggers GitHub Actions."); +console.log("Annual: rotate CLAUDE_CODE_OAUTH_TOKEN by re-running `claude setup-token`."); +``` + +- [ ] **Step 11: Test that the script at least loads (no execution)** + +Run: `npx tsx --no-warnings -e 'import("./scripts/deploy.ts").catch((e) => { console.error(e); process.exit(1); })'` +Expected: imports without throwing (the script's `main()` only runs when invoked, so this just typechecks the imports). + +Alternatively just typecheck: + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 12: Commit** + +```bash +git add scripts/deploy.ts +git commit -m "feat(deploy): add interactive scripts/deploy.ts (mirrors setup.ts patterns)" +``` + +--- + +## Task 16: Add `docs/deploying.md` and link from README + +**Files:** +- Create: `docs/deploying.md` +- Modify: `README.md` + +- [ ] **Step 1: Write `docs/deploying.md`** + +```markdown +# Deploying Boop + +Boop runs as a single Fly.io machine with Convex as the backend. This doc walks through the one-shot deploy flow. + +## Prerequisites + +- Local dev set up — run `npm run setup` first if you haven't. +- A [Fly.io](https://fly.io) account and `fly` CLI installed (`curl -L https://fly.io/install.sh | sh`). +- Either: + - **A Claude subscription** (Pro/Max/Team/Enterprise) — generate a 1-year token via `claude setup-token` locally. Recommended. + - **An Anthropic API key** if you'd rather pay per token. +- Your Sendblue dashboard's **webhook signing secret** (Webhook Settings → Signing Secret). +- (Optional but recommended) `gh` CLI for auto-pushing GitHub Actions secrets. + +## One command + +```bash +npm run deploy +``` + +The interactive script walks you through: + +1. Verifying your local setup (Convex deployment, Sendblue keys). +2. Creating a Fly app and printing your stable public URL (`https://.fly.dev`). +3. Picking your LLM auth method (subscription token or API key) and a webhook signing secret. +4. Generating a strong dashboard password and storing it as both a Fly secret and a Convex env var. +5. Pushing all secrets to Fly. +6. Reminding you to set the Sendblue inbound webhook to `https://.fly.dev/sendblue/webhook`. +7. Setting GitHub Actions secrets (`FLY_API_TOKEN`, `FLY_APP_NAME`, `CONVEX_DEPLOY_KEY`) so future pushes to `main` auto-deploy. +8. Running the first deploy. + +After it finishes: + +- Visit `https://.fly.dev/` and log in with the dashboard password. +- Send yourself an iMessage. Watch the Events panel light up. +- Future deploys: `git push origin main` triggers GitHub Actions. + +## Operational tasks + +### Annual: rotate the Claude OAuth token + +The `CLAUDE_CODE_OAUTH_TOKEN` expires after 1 year. When it does, the agent will start replying "Sorry — I hit an error" to your messages. To rotate: + +```bash +claude setup-token # local — prints a new token +fly secrets set CLAUDE_CODE_OAUTH_TOKEN= --app +``` + +Fly restarts the machine automatically. + +### Rotate the dashboard password + +```bash +fly secrets set BOOP_ADMIN_PASSWORD= --app +npx convex env set BOOP_ADMIN_PASSWORD= +npx convex run users:setPassword +``` + +### Background loop constraint (single-replica) + +Boop's four background loops (cleanup, automation, heartbeat, consolidation) run in-process. The `fly.toml` sets `min_machines_running = 1` and `auto_stop_machines = false` so exactly one machine runs continuously. **Do not scale horizontally** — duplicate automations and consolidation runs would cost real money. + +### WebSocket token in logs + +The dashboard's live WebSocket connection authenticates via a `?token=` query parameter (browsers can't set custom headers on the WS handshake). That token will appear in Fly access logs and in any reverse proxy you put in front of Fly. If your logs ever leak, rotate the dashboard password to invalidate any captured token. Single-user severity is low, but worth knowing. + +## Alternative platforms + +The Dockerfile is platform-neutral. To deploy elsewhere: + +- **Coolify on Hetzner / your own VPS** — point Coolify at the repo, replace `fly.toml` with a Coolify service config. +- **PikaPods** — single Docker container, identical environment variable set. Drop `fly.toml`, configure the same secrets in the PikaPods dashboard. +- **Render / Railway / Fly Machines via API** — same shape. + +The thing you need to provide on any platform: a stable HTTPS URL with port 3456 reachable, all environment variables from `.env.example` set, and a single replica with persistent process. + +## Layering an SSO edge (optional) + +If you want SSO on top of the password gate (e.g., for a small team), put **Cloudflare Access** in front of the Fly app. Cloudflare Access enforces SSO at the edge before traffic reaches Fly; the password gate then becomes redundant but harmless. +``` + +- [ ] **Step 2: Add a link to `README.md`** + +Find a sensible spot in the existing README (likely in a "Run it" or "What you get" section) and insert one line: + +```markdown +- **Deploy** — one-command production setup with `npm run deploy`. See [`docs/deploying.md`](docs/deploying.md). +``` + +- [ ] **Step 3: Commit** + +```bash +git add docs/deploying.md README.md +git commit -m "docs: add deploying.md and link from README" +``` + +--- + +## Task 17: Final verification pass + +**Files:** none (verification only) + +- [ ] **Step 1: Full typecheck** + +Run: `npx tsc --noEmit` +Expected: zero errors. + +- [ ] **Step 2: Full test suite** + +Run: `npm test` +Expected: all tests pass. Should be ~15 tests across `auth.test.ts` and `sendblue.test.ts`. + +- [ ] **Step 3: Build the debug UI** + +Run: `npm run build:debug` +Expected: succeeds, `debug/dist/` contains `index.html` + assets. + +- [ ] **Step 4: Build the Docker image** + +Run: `docker build -t boop-agent .` +Expected: succeeds. (If Docker isn't available locally, skip and note in PR description that this still needs verification.) + +- [ ] **Step 5: Sanity-check the Convex schema and codegen** + +Run: `npx convex codegen` +Expected: `convex/_generated/` regenerates without error. This validates the schema, function signatures, and auth provider config without actually deploying. Real deploy validation happens in CI when the PR is opened against a branch with `CONVEX_DEPLOY_KEY` configured. + +- [ ] **Step 6: Final commit (if anything was tweaked)** + +If the verification surfaced any small fixes, commit them with `chore: post-verification cleanup`. + +- [ ] **Step 7: Open the draft PR** + +Out of scope for this plan — done by the user when they're ready. The PR description should follow the spec's "Author's testing posture" — explicitly state what was and wasn't verified by the contributor. + +--- + +## Self-Review Notes + +**Spec coverage check:** every section of the design spec maps to one or more tasks above: + +| Spec section | Task(s) | +|---|---| +| Two auth perimeters → Perimeter A (HMAC + phone) | 4 | +| Two auth perimeters → Perimeter B (Convex Auth) | 5, 6, 8, 9 | +| Route allowlist | 3, 8 | +| Single-user model + bootstrap | 6, 14 | +| Password rotation | 6, 16 | +| LLM auth (OAuth token vs API key) | 15, 16 | +| Convex function classification + Express migration | 7 | +| Deployment shape (fly.toml) | 12 | +| Express server changes | 2, 3, 4, 8 | +| Convex layer changes | 5, 6, 7 | +| Debug UI changes | 9, 10 | +| Dockerfile + .dockerignore | 11 | +| GitHub Actions workflow | 14 | +| scripts/deploy.ts | 15 | +| .env.example additions | 13 | +| docs/deploying.md | 16 | +| README link | 16 | +| Testing (unit tests, node:test, npm test) | 1, 2, 3, 4 | +| Operational notes | 16 | + +**Type consistency:** the `requireUser` helper signature in Task 7 matches its caller usage. The `verifyHmac` signature `(body, signature, secret) => boolean` is identical between Tasks 2 and 4. The `requireAdmin` signature `() => RequestHandler` (with optional verifier override) is identical between Tasks 3 and 8. + +**Out-of-scope guard:** no task adds rate limiting, body size hardening, multi-user, or any other expressly out-of-scope concern from the spec. + +**Risks called out:** +- Task 6: Bootstrap uses `createAccount` from `@convex-dev/auth/server`. If the installed package version's argument shape differs, check the type definitions and adjust — this is the documented headless-bootstrap path but versions move. +- Task 8: WebSocket upgrade auth uses `?token=` query param because browsers can't set custom headers on the handshake. The token leaks to Fly access logs and any reverse-proxy logs. Documented in `docs/deploying.md`. +- Task 11: The Dockerfile runs with `tsx` at runtime (not a compiled `dist/`). The runtime image carries devDependency-style tooling. Acceptable trade-off for keeping changes minimal — matches Chris's existing `npm start = tsx server/index.ts` pattern. diff --git a/docs/superpowers/specs/2026-04-27-auto-deploy-pr-design.md b/docs/superpowers/specs/2026-04-27-auto-deploy-pr-design.md new file mode 100644 index 00000000..2ba432f2 --- /dev/null +++ b/docs/superpowers/specs/2026-04-27-auto-deploy-pr-design.md @@ -0,0 +1,554 @@ +# Auto-Deploy + Auth PR — Design + +**Date:** 2026-04-27 +**Target repo:** `boop-agent` (upstream: `raroque/boop-agent`) +**PR shape:** Single coupled PR — deploy infrastructure + authentication layer +**Estimated size:** ~860 lines, ~14 new files, ~24 modified files + +## Summary + +Make boop-agent safely deployable to a stable public URL on Fly.io, with single-user authentication on every endpoint exposed to the public internet. The deploy and the auth ship together in one PR because they are inseparable: the deploy is the *reason* the auth has to exist, and the auth is the *prerequisite* for the deploy not being dangerous. + +The core change is three coupled pieces: + +1. **Deploy infrastructure** — Dockerfile, fly.toml, a GitHub Actions workflow, and `scripts/deploy.ts` (interactive setup mirroring `scripts/setup.ts`'s patterns). +2. **Authentication layer** — Convex Auth (single-user password provider) covering both browser dashboard access and the Express admin endpoints with one JWT. +3. **Webhook hardening** — Sendblue HMAC signature validation and phone-number whitelist on `/sendblue/webhook`. + +The deploy target is Fly.io as the documented turnkey path. The Dockerfile is platform-neutral; forks can swap to Coolify/Hetzner/PikaPods/Render by replacing `fly.toml` and the deploy step in the workflow. + +## Background + +In the open-sourcing video, Chris explicitly invited a server-deploy PR: + +> "If you do know what you're doing, make those modifications. Run it on a server. It gets so much more powerful when it works with your laptop closed... feel free to open a PR. That is a PR that I would probably merge in if you do it correctly. I want to take a moment to thank you guys..." + +He also stated why he didn't ship deploy himself: + +> "I purposely did not add this to the repo because I needed a little bit more time if I wanted to do that right... I don't know if I'm doing it in the most secure way, which is why it is not in the repo right now." + +This PR addresses both concerns: it ships deploy, and it makes the deploy safe. + +### Problem: the security gap when boop goes public + +The current security model is "URL is the password" at every layer: + +- **Sendblue webhook**: `POST /sendblue/webhook` accepts any body with `from_number` and `content`. No HMAC verification, no source check. +- **Express admin endpoints**: `/chat`, `/consolidate`, `/agents/:id/cancel`, `/agents/:id/retry`, `/composio/*` all accept unauthenticated POSTs. +- **Convex layer**: Every Convex function is exported as `mutation`/`query`/`action` (none use `internalMutation`/`internalQuery`). Zero `ctx.auth.getUserIdentity()` checks. Anyone with the Convex deployment URL can call any function, including reading the entire memory store and conversation history. +- **WebSocket `/ws`**: Open subscription, broadcasts memory events and agent state to anyone who connects. + +This is fine for boop's current model (single user, laptop, ngrok tunnel that rotates URLs). It collapses the moment the app is on a stable public URL. + +### Why the upstream owner accepted this gap + +Chris built boop as a personal tool, never intended for public deployment. The unauthenticated layers are *the same* as a Flask app behind `localhost:5000` — fine when no one else can reach it. The architectural debt only manifests at deploy time. + +This PR is the deploy time. + +## Goals + +In scope: + +- Deploy boop to Fly.io with a reproducible, repeatable workflow +- Add a single-user authentication layer covering both browser access and Express admin endpoints +- Verify Sendblue webhook signatures (HMAC + phone whitelist) +- Convert all Convex functions to either `internalQuery`/`internalMutation` (server-only) or `query`/`mutation` with explicit `ctx.auth.getUserIdentity()` checks (browser-callable but authenticated) +- Provide a `scripts/deploy.ts` interactive deploy setup matching the patterns in `scripts/setup.ts` +- Document the deploy in `docs/deploying.md` +- Unit tests for new authentication code + +Success criteria: + +- Code reviewable by an experienced TypeScript developer +- Type system clean (`tsc --noEmit` passes) +- Unit tests pass (`npm test`) +- Docker image builds (`docker build .`) +- A maintainer with the necessary accounts can complete the runtime smoke test in under 30 minutes +- After deploy, boop is reachable at `https://.fly.dev`, the dashboard at `/debug` requires login, and the iMessage flow works end-to-end without a laptop tether + +## Non-Goals + +Explicitly out of scope: + +- **Multi-user support.** Single user, single row in the `users` table. Multi-tenant is a separate project. +- **Convex Auth provider beyond password.** No magic link, no OAuth/SSO. Forks can swap the provider in `convex/auth.config.ts` if desired. +- **General security hardening.** Body size limits, rate limiting, input validation on memory content, bounded execution-agent buffers — all real issues, all separate PRs. +- **Background loop sharding.** The four in-process loops (cleanup, automation, heartbeat, consolidation) still require single-replica deployment. Documented as a constraint, not fixed. +- **Convex Auth on Express → Convex calls.** Express continues to call Convex with the deploy key. The auth perimeter is at Express's edge, not on every internal Convex call. +- **Composio webhook signature validation.** Existing behavior preserved. +- **Auto-rotation of `CLAUDE_CODE_OAUTH_TOKEN`.** Documented as a yearly manual task. +- **Production debug UI features beyond what exists in dev.** Same UI, just login-gated. +- **Changes to `scripts/setup.ts` or `npm run dev`.** Local dev behavior is identical to today. +- **CI infrastructure beyond a single test step.** No test workflow, no matrix, no coverage tooling. +- **Documentation overhaul.** Adds `docs/deploying.md` and a README link; everything else untouched. + +## Architecture + +### Two auth perimeters + +There are two completely separate auth perimeters by necessity. They share zero credentials because they protect different things. + +#### Perimeter A: iMessage path + +``` +iMessage → Sendblue → Express webhook → Convex (internal) +``` + +Three checks, in order, on `POST /sendblue/webhook`: + +1. **HMAC signature** — Compute `HMAC-SHA256(body, SENDBLUE_SIGNING_SECRET)`, compare to `X-Sendblue-Signature` header using `crypto.timingSafeEqual`. Reject mismatch with 401. +2. **Phone whitelist** — After parsing, `from_number` must equal `SENDBLUE_FROM_NUMBER`. Reject mismatch with 403. +3. **Process** — Hand off to existing dispatcher logic. + +iMessage cannot carry JWTs. The "credential" is the cryptographic Sendblue signature plus the operator's own phone number. After these checks, Express writes to Convex via internal mutations using the deploy key (server-side service call, no JWT). + +#### Perimeter B: Human path + +``` + ┌─ Convex Auth (issues JWTs) ─┐ + │ single password user │ + └──────────────────────────────┘ + ▲ + │ same JWT verifies in both consumers + ┌─────────────┼─────────────────┐ + ▼ ▼ ▼ + Browser /debug → Express admin Browser → Convex + (live subscriptions) +``` + +Single Convex Auth password login. One row in a `users` table, one password, one identity. The browser logs in once, gets a JWT, and uses the same JWT to call Express (which verifies via Convex's JWKS endpoint) and Convex directly (which verifies natively). One login, one token, two consumers. + +#### Route allowlist (Express) + +``` +PUBLIC (no JWT) +├── GET /health +└── POST /sendblue/webhook (HMAC + phone whitelist instead) + +REQUIRE JWT (Authorization: Bearer ) +├── POST /chat +├── POST /consolidate +├── POST /agents/:id/cancel +├── POST /agents/:id/retry +├── * /composio/* +├── WS /ws +└── * /debug/* (the dashboard SPA itself) +``` + +Implemented as deny-by-default middleware with an explicit allowlist for the two public routes. Future endpoints are admin-by-default. + +### Single-user model + +One `users` table row, one password. Convex Auth's password provider stores the credential as a hashed entry in its `authAccounts` table. + +#### Bootstrap + +The chicken-and-egg problem (the dashboard requires login but no user exists yet) is solved by a one-shot Convex action triggered by CI: + +``` +convex/users.ts (new file) + + internalAction bootstrap: + if any user already exists in `users` table → return early (no-op) + read BOOP_ADMIN_PASSWORD from Convex env + if not set → throw "BOOP_ADMIN_PASSWORD not configured" + create one user via Convex Auth's password provider + return { created: true } +``` + +The action is idempotent — running it twice is a no-op because of the "user already exists" guard. The CI workflow runs `npx convex run users:bootstrap` after every `npx convex deploy`. First deploy creates the user; subsequent deploys are no-ops. + +#### Password rotation + +``` +1. fly secrets set BOOP_ADMIN_PASSWORD= +2. npx convex env set BOOP_ADMIN_PASSWORD= +3. npx convex run users:setPassword (new internalAction) + — looks up the single user, updates the password hash via Convex Auth +4. Old sessions invalidate naturally on next token expiry. +``` + +#### Why not other patterns + +- **First-visit registration**: wide-open window during deploy. Anyone scanning fly.dev during the bootstrap window could claim the account. Rejected. +- **Manual SSH-and-run**: brittle, more steps, harder to document, fragile across deploys. Rejected. + +### LLM authentication + +Recommended default: `CLAUDE_CODE_OAUTH_TOKEN` (subscription path), matching Chris's framing in the video. + +| | Detail | +|---|---| +| Generation | `claude setup-token` locally → outputs token to stdout. Operator copies it manually. | +| Server use | Set `CLAUDE_CODE_OAUTH_TOKEN=` as a Fly secret. Agent SDK auto-detects. | +| Lifespan | 1 year, then expires | +| Refresh | None. Manual rotation via re-running `claude setup-token` and updating the Fly secret. | +| Failure mode | HTTP 401 from Anthropic. **Boop's existing error swallowing currently presents this to users as "Sorry — I hit an error."** Documented as a known operational note. | +| Plan requirement | Pro / Max / Team / Enterprise | +| Anthropic support | First-class — explicitly intended for "CI pipelines, scripts, or other environments where interactive browser login isn't available" | + +Alternative: `ANTHROPIC_API_KEY`. Use this if predictable per-token billing is preferred over the 1-year rotation cost. The PR supports both; the deploy script asks which. + +### Convex function classification + +Every existing Convex function is classified as one of: + +- **Internal** (`internalQuery` / `internalMutation` / `internalAction`) — called only by Express server-side. Not callable from browsers. The Express server uses these via the deploy key. +- **Public + auth-checked** (`query` / `mutation`) — called by the browser dashboard for live subscriptions and reads. Each function starts with `await ctx.auth.getUserIdentity()` and throws if null. + +Estimated split (pending file-by-file walkthrough during implementation): ~60% become internal, ~40% remain public with auth checks. + +After this split, **the Convex deployment URL alone is no longer a master credential.** Even if the URL leaks, an attacker cannot call any function without either the user password or the deploy key. + +### Deployment shape + +Single Fly app, single machine, always on: + +```toml +app = "boop-agent-" +primary_region = "iad" + +[build] + # uses Dockerfile + +[http_service] + internal_port = 3456 + force_https = true + auto_stop_machines = false # Sendblue webhook needs always-on + auto_start_machines = false + min_machines_running = 1 # exactly one (in-process loop constraint) + processes = ["app"] + +[[http_service.checks]] + grace_period = "10s" + interval = "30s" + method = "GET" + path = "/health" + timeout = "5s" + +[[vm]] + size = "shared-cpu-1x" + memory = "512mb" +``` + +The `min_machines_running = 1` + `auto_stop_machines = false` combination is the explicit "exactly one replica, always running" config required by boop's in-process background loops. Documented inline. + +## Code Changes by Layer + +### Express server (`server/`) + +| File | Change | ~Lines | +|---|---|---| +| `server/auth.ts` | **NEW.** JWT verification using `jose` against Convex's JWKS endpoint. Exports `requireAdmin` (deny-by-default middleware with public allowlist), `verifyHmac` for Sendblue. | ~80 | +| `server/index.ts` | Register `requireAdmin` middleware globally. Add WebSocket upgrade auth check. Serve built debug UI as static assets from `debug/dist`. | ~25 modified | +| `server/sendblue.ts` | Wire HMAC verification at the top of the webhook handler. Phone whitelist check after parsing. | ~25 modified | +| `server/composio-routes.ts` | No change — protected by global `requireAdmin`. | 0 | +| `package.json` | Add `jose` dependency (~3KB lightweight JWT library). | ~3 modified | + +**Subtotal: ~135 lines, 1 new file, 3 modified files.** + +### Convex layer (`convex/`) + +| File | Change | ~Lines | +|---|---|---| +| `convex/auth.config.ts` | **NEW.** Convex Auth provider configuration (password provider). | ~10 | +| `convex/auth.ts` | **NEW.** Re-exports from `@convex-dev/auth/server`, custom helpers. | ~20 | +| `convex/users.ts` | **NEW.** `users` table validator, `bootstrap` internalAction (create-if-missing-with-password from env), `setPassword` internalAction (rotation). | ~50 | +| `convex/schema.ts` | Add `users` table reference. | ~5 modified | +| All 12 existing function files | Classify each function: keep public + add `await ctx.auth.getUserIdentity()` guard, OR convert to `internalQuery`/`internalMutation`. ~3 lines per function. | ~80 modified | + +**Subtotal: ~165 lines, 3 new files, 13 modified files.** + +The function classification is the largest mechanical chunk. It is not bloat — it is what fixing the data layer requires. + +### Debug UI (`debug/`) + +| File | Change | ~Lines | +|---|---|---| +| `debug/src/main.tsx` | Wrap `` in `` from `@convex-dev/auth/react`. | ~8 modified | +| `debug/src/auth.tsx` | **NEW.** Login form (single password field) using `useAuthActions().signIn("password", ...)`. Renders before `` if not authenticated. | ~50 | +| `debug/src/api-client.ts` | **NEW.** Wrapper around `fetch` that pulls the Convex JWT and sets `Authorization: Bearer ...`. Used by all existing fetch calls to Express. | ~30 | +| Existing fetch call sites | Migrate ~5–8 `fetch(...)` calls to use `api-client.ts`. | ~20 modified | +| `debug/package.json` | Add `@convex-dev/auth` dependency. | ~2 | + +**Subtotal: ~110 lines, 2 new files, ~6 modified files.** + +## Deploy Infrastructure + +### `Dockerfile` (root) + +Multi-stage build using `node:22-slim` (Debian Bookworm slim) — chosen over Alpine because operators may `fly ssh console` in to debug, and Debian's familiar toolset (bash, apt-get, standard `ps`/`top`/`curl`) is friendlier than Alpine's busybox/musl environment. + +```dockerfile +# ---- Stage 1: deps ---- +FROM node:22-slim AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +# ---- Stage 2: build server + debug UI ---- +FROM node:22-slim AS build +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build # compile TS server +RUN npm run build:debug # build Vite debug UI to debug/dist + +# ---- Stage 3: runtime ---- +FROM node:22-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production +COPY --from=deps /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +COPY --from=build /app/debug/dist ./debug/dist +COPY --from=build /app/convex/_generated ./convex/_generated +COPY package.json ./ +EXPOSE 3456 +USER node +CMD ["node", "dist/server/index.js"] +``` + +~25 lines. + +### `.dockerignore` (root) + +Standard list excluding `node_modules`, `.env*`, `.git`, `dist`, `debug/dist`, `convex/_generated`, `.claude`, `scripts`, `docs`, `tests`. ~15 lines. + +### `fly.toml` (root) + +Configuration shown in the Architecture section. ~30 lines. + +### `.github/workflows/deploy.yml` + +```yaml +name: Deploy + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + concurrency: + group: deploy-${{ github.ref }} + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: 22, cache: npm } + - run: npm ci + + - name: Run unit tests + run: npm test + + - name: Push Convex backend + run: npx convex deploy --yes + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} + + - name: Bootstrap admin user (idempotent) + run: npx convex run users:bootstrap + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} + + - uses: superfly/flyctl-actions/setup-flyctl@master + - name: Deploy to Fly + run: flyctl deploy --remote-only + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + + - name: Smoke test + run: | + for i in {1..30}; do + if curl -fsS https://${{ secrets.FLY_APP_NAME }}.fly.dev/health; then + exit 0 + fi + sleep 5 + done + echo "health check failed after 150s" + exit 1 +``` + +Sequencing: tests → Convex push → bootstrap (no-op after first run) → Fly deploy → smoke test. Required GitHub Actions secrets: `CONVEX_DEPLOY_KEY`, `FLY_API_TOKEN`, `FLY_APP_NAME`. All other secrets live in `fly secrets`, not GitHub Actions. ~50 lines. + +### `scripts/deploy.ts` + +Interactive deploy setup script mirroring `scripts/setup.ts`'s patterns: same `prompts` library, same `banner()` helper, same `hasBinary()`/`openInBrowser()`/`runInherit()`/`runCapture()` utilities, same defensive parsing of CLI tool output, same section-by-section interactive flow. **Standalone** — does not import from or modify `setup.ts`. + +Behavior: + +1. **Verify dev setup is done.** Read `.env.local`. If missing or `CONVEX_DEPLOYMENT` not set, offer to spawn `npm run setup` inline. Re-read env after. +2. **Fly account + app.** Check `fly` binary, check auth status. Prompt for app name (suggested: `boop-`). Run `fly apps create ` if not exists. Compute `PUBLIC_URL = https://.fly.dev`. +3. **Generate the new secrets.** Prompt to run `claude setup-token` interactively (or fall back to `ANTHROPIC_API_KEY`). Prompt for `SENDBLUE_SIGNING_SECRET` (manual paste from Sendblue dashboard). Prompt for `BOOP_ADMIN_PASSWORD` (auto-generate 32-char random by default). +4. **Push secrets to Fly.** Single `fly secrets set ...` batch with all values from `.env.local` plus the new ones plus `PUBLIC_URL`. +5. **Push Convex env.** Prompt for Convex deploy key (open browser to dashboard URL). `npx convex env set BOOP_ADMIN_PASSWORD=...`. +6. **Configure Sendblue webhook URL.** Print clear instructions: "Open Sendblue dashboard, set INBOUND webhook to `https://.fly.dev/sendblue/webhook`." Prompt to confirm done. +7. **Set GitHub repo secrets.** If `gh` CLI installed, offer to run `gh secret set` for `FLY_API_TOKEN`, `FLY_APP_NAME`, `CONVEX_DEPLOY_KEY` automatically. Otherwise print exact UI steps. +8. **Optional first deploy.** Prompt to run `fly deploy --remote-only` now or wait for next push to main. +9. **Print "you're deployed" footer.** Test instructions, operational notes (annual OAuth rotation), future-deploy command (`git push origin main`). + +~280 lines following Chris's exact patterns. Most of the helper code is copy-pastable from `setup.ts`. + +### `.env.example` additions + +Three new sections following Chris's existing comment style (~15 lines added): + +```bash +# ---- Sendblue webhook signing ---- +# Get this from your Sendblue dashboard under Webhook Settings → Signing Secret. +# Required when running on a public URL — the webhook handler verifies every +# incoming request's HMAC-SHA256 signature against this secret. +SENDBLUE_SIGNING_SECRET= + +# ---- Boop dashboard / admin auth ---- +# The single password for the dashboard and admin endpoints when deployed. +# Pick a long random string and set it as a Fly secret AND a Convex env var. +BOOP_ADMIN_PASSWORD= + +# ---- Claude (server deploy) ---- +# Run `claude setup-token` locally and paste the output here. The token lasts +# 1 year, then you regenerate. ANTHROPIC_API_KEY is the alternative. +# CLAUDE_CODE_OAUTH_TOKEN= +``` + +### `docs/deploying.md` + +Operator-facing deploy documentation, ~50 lines. Most of the heavy lifting is in `scripts/deploy.ts`, so the doc is mostly orientation: + +- Prerequisite (`npm run setup` first, or let `npm run deploy` offer to run it) +- Walkthrough of `npm run deploy`'s interactive flow +- After deployment: visit `.fly.dev/debug`, log in, verify dashboard +- Operational tasks: annual `CLAUDE_CODE_OAUTH_TOKEN` rotation, password rotation procedure +- Optional: layering Cloudflare Access in front of Fly for SSO at the edge (one-line note) +- Alternative platforms (informational): Coolify on Hetzner, PikaPods, Render, Railway — same Dockerfile, replace `fly.toml` and the deploy step + +### `README.md` + +A single new line linking to `docs/deploying.md` from the existing structure. + +### Subtotal for deploy infrastructure + +| | Count | Lines | +|---|---|---| +| New files | 5 (Dockerfile, .dockerignore, fly.toml, deploy workflow, scripts/deploy.ts) | ~400 | +| New docs | 1 (docs/deploying.md) | ~50 | +| Modified | 3 (.env.example, package.json, README.md) | ~25 | +| **Subtotal** | | **~475 lines** | + +## Testing & Verification + +### What is tested + +Only the new logic. Pure functions and middleware introduced in this PR. + +| Test file | Tests | ~Lines | +|---|---|---| +| `server/auth.test.ts` | `verifyHmac()` — valid sig, invalid sig, missing header, timing-safe behavior. `requireAdmin()` — public path passes, admin path with valid JWT passes, admin path without auth → 401, admin path with malformed JWT → 401, expired JWT → 401. | ~60 | +| `server/sendblue.test.ts` (new) | Perimeter checks: HMAC mismatch → 401, phone whitelist mismatch → 403, both pass → handler runs. | ~30 | +| `convex/users.test.ts` | `bootstrap` action: no users → creates one; user exists → no-op. `setPassword` action: updates the single user's hash. | ~30 | + +**Test code subtotal: ~120 lines, 3 new files.** + +### Test runner: `node:test` + +Chris has zero existing test infrastructure. Adding vitest/jest is unnecessary new tooling. Node 22's built-in test runner needs no config and no dependency. + +```bash +npx tsx --test 'server/**/*.test.ts' 'convex/**/*.test.ts' +``` + +Added as `npm test` in `package.json`. Zero new dependencies — `tsx` is already in the repo. + +### CI integration + +One step in the GitHub workflow before deploy: + +```yaml +- name: Run unit tests + run: npm test +``` + +Test failure halts deploy. + +### What is NOT tested + +Stated explicitly so the PR description can be honest: + +- **No `convex-test` integration tests.** Would require pulling in the `convex-test` package, wiring up a simulator harness. Skipped to keep tooling minimal. +- **No tests for existing untouched code** (interaction-agent, execution-agent, memory system, consolidation, automations). Out of scope. +- **No E2E tests, no smoke tests in CI** beyond the post-deploy curl. Those need accounts. +- **No type-checking-as-test.** `tsc --noEmit` already enforces this through the existing TypeScript build step. + +### Author's testing posture (PR description) + +The contributor opening this PR will not have all five external accounts (Convex, Sendblue, Composio, Anthropic, Fly). Verification depth at the contributor's end: + +- ✅ TypeScript compiles +- ✅ Unit tests pass (no external services needed) +- ✅ Docker image builds (`docker build .`) +- ✅ Auth middleware verified manually with curl + synthetic HMAC payloads +- ⚠️ Not tested: full E2E iMessage flow (no Sendblue account) +- ⚠️ Not tested: actual Fly deploy (no Fly account) +- 🙏 Asks the maintainer or another contributor with full setup to run the runtime smoke test + +This is an explicit, honest test-status banner in the PR description. The maintainer (Chris) does the last-mile verification with his existing accounts. + +## Operational Notes + +Documented in `docs/deploying.md`: + +- **Annual `CLAUDE_CODE_OAUTH_TOKEN` rotation.** The token expires after 1 year. Regenerate via `claude setup-token`, update the Fly secret. Currently presents to users as "Sorry — I hit an error" replies until rotated; surfacing this failure mode to the dashboard is a separate PR. +- **Password rotation.** Three-step process documented in the Architecture section. +- **Single-replica constraint.** Boop's in-process background loops mean `min_machines_running = 1` is mandatory. Scaling beyond one replica without a Convex-level coordination lock causes automation double-fire and consolidation duplicate-cost. Documented as a hard constraint. +- **Convex URL is still a top-tier secret.** Even after this PR, the Convex deployment URL plus the deploy key gives full data-layer access. Treat both as secrets. Future hardening: add Convex Auth checks on the deploy-key code path too (separate PR). + +## Contribution Flow + +To minimize wasted work and account-creation pain for the contributor, the recommended sequence: + +1. **Open a GitHub issue** on the boop repo proposing this design (or pasting a link to this spec). Five-minute conversation that surfaces any constraints Chris has in mind before code is written. +2. **Open a draft PR** with code, unit tests passing, Docker image building locally. Test status banner clearly states what was and wasn't verified by the author. +3. **Maintainer or another contributor** runs the runtime smoke test using their accounts. +4. **Iterate** based on review feedback; switch from draft to ready-for-review when everything passes. +5. **Merge.** + +This frames the contribution as a respectful, well-formed proposal rather than a fait accompli. + +## Out of Scope + +Repeated here as the bullet list for clarity: + +- Multi-user support +- Convex Auth provider beyond password (no magic link, no OAuth/SSO) +- General security hardening (body limits, rate limits, input validation, buffer bounds) +- Background loop sharding +- Convex Auth on Express → Convex calls (deploy key remains the trust) +- Composio webhook signature validation +- Auto-rotation of OAuth token +- Production-only debug UI features +- Changes to `scripts/setup.ts` or `npm run dev` +- Changes to `/chat` semantics +- CI infrastructure beyond a single test step +- Documentation overhaul beyond the new deploy doc + +## Total Scope + +| Layer | New files | Modified files | Lines added/changed | +|---|---|---|---| +| Express | 1 | 3 | ~135 | +| Convex | 3 | 13 | ~165 | +| Debug UI | 2 | ~6 | ~110 | +| Deploy infrastructure | 5 | 3 | ~475 | +| Tests | 3 | — | ~120 | +| **Total** | **~14 new** | **~24 modified** | **~860 lines** | + +## Why this is one PR, not two + +Splitting into a "deploy only" PR followed by an "auth only" PR was considered and rejected: + +- A deploy-only PR ships a knowingly-vulnerable public URL. Chris's stated reason for not shipping deploy himself is security; he would not merge a deploy that ignores the auth gap. +- An auth-only PR has no consumer until deploy ships. The auth changes need a real deploy story to justify their existence. + +The two halves are coupled by the same problem ("make this safe to host on a public URL"). Together they answer it. Apart, neither does. The size cost is real but justified. diff --git a/fly.toml b/fly.toml new file mode 100644 index 00000000..b1012054 --- /dev/null +++ b/fly.toml @@ -0,0 +1,33 @@ +# Fly app config. Generated by scripts/deploy.ts on first deploy and +# checked in for reproducibility. +# +# IMPORTANT: min_machines_running = 1 + auto_stop_machines = false is the +# explicit "exactly one replica, always running" config required by boop's +# in-process background loops (cleanup, automation, heartbeat, consolidation). +# Scaling beyond one replica without a Convex-level coordination lock causes +# automation double-fire and consolidation duplicate-cost. + +app = "boop-agent" +primary_region = "iad" + +[build] + # uses Dockerfile + +[http_service] + internal_port = 3456 + force_https = true + auto_stop_machines = false + auto_start_machines = false + min_machines_running = 1 + processes = ["app"] + +[[http_service.checks]] + grace_period = "10s" + interval = "30s" + method = "GET" + path = "/health" + timeout = "5s" + +[[vm]] + size = "shared-cpu-1x" + memory = "512mb" diff --git a/package-lock.json b/package-lock.json index 28c33860..0ebfff8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,10 @@ "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.1.0", + "@auth/core": "^0.37.4", "@composio/claude-agent-sdk": "^0.6.10", "@composio/core": "^0.6.10", + "@convex-dev/auth": "^0.0.91", "@hugeicons/core-free-icons": "^1.0.0", "@hugeicons/react": "^1.0.0", "@modelcontextprotocol/sdk": "^1.0.0", @@ -20,7 +22,9 @@ "croner": "^9.0.0", "dotenv": "^16.4.0", "express": "^5.0.0", + "jose": "^5.9.0", "react-force-graph-2d": "^1.27.0", + "tsx": "^4.19.0", "ws": "^8.18.0", "zod": "^3.23.0" }, @@ -39,7 +43,6 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "tailwindcss": "^4.0.0", - "tsx": "^4.19.0", "typescript": "^5.6.0", "vite": "^6.0.0" }, @@ -69,6 +72,45 @@ "zod": "^3.25.0 || ^4.0.0" } }, + "node_modules/@auth/core": { + "version": "0.37.4", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.37.4.tgz", + "integrity": "sha512-HOXJwXWXQRhbBDHlMU0K/6FT1v+wjtzdKhsNg0ZN7/gne6XPsIrjZ4daMcFnbq0Z/vsAbYBinQhhua0d77v7qw==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^5.9.6", + "oauth4webapi": "^3.1.1", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "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 + } + } + }, + "node_modules/@auth/core/node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -418,6 +460,56 @@ "zod": ">=3.25.76 <5" } }, + "node_modules/@convex-dev/auth": { + "version": "0.0.91", + "resolved": "https://registry.npmjs.org/@convex-dev/auth/-/auth-0.0.91.tgz", + "integrity": "sha512-wLD4hszo3IhhMkwPs6ozWf0cUauwmhOvjUVn0g//kC338n/jApOjeDYWKCrn/qYUkveyDsbag5zrY8mVzA09Qg==", + "license": "Apache-2.0", + "dependencies": { + "@oslojs/crypto": "^1.0.1", + "@oslojs/encoding": "^1.1.0", + "cookie": "^1.0.1", + "is-network-error": "^1.1.0", + "jose": "^5.2.2", + "jwt-decode": "^4.0.0", + "lucia": "^3.2.0", + "oauth4webapi": "^3.1.2", + "path-to-regexp": "^6.3.0", + "server-only": "^0.0.1" + }, + "bin": { + "auth": "dist/bin.cjs" + }, + "peerDependencies": { + "@auth/core": "^0.37.0", + "convex": "^1.17.0", + "react": "^18.2.0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, + "node_modules/@convex-dev/auth/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@convex-dev/auth/node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", @@ -1160,6 +1252,55 @@ } } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@oslojs/asn1": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@oslojs/asn1/-/asn1-1.0.0.tgz", + "integrity": "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==", + "license": "MIT", + "dependencies": { + "@oslojs/binary": "1.0.0" + } + }, + "node_modules/@oslojs/binary": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@oslojs/binary/-/binary-1.0.0.tgz", + "integrity": "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==", + "license": "MIT" + }, + "node_modules/@oslojs/crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@oslojs/crypto/-/crypto-1.0.1.tgz", + "integrity": "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==", + "license": "MIT", + "dependencies": { + "@oslojs/asn1": "1.0.0", + "@oslojs/binary": "1.0.0" + } + }, + "node_modules/@oslojs/encoding": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", + "license": "MIT" + }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -3385,7 +3526,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -3515,7 +3655,6 @@ "version": "4.14.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", - "dev": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -3964,6 +4103,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-network-error": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.1.tgz", + "integrity": "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-number-object": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", @@ -4165,9 +4316,9 @@ } }, "node_modules/jose": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", - "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -4224,6 +4375,15 @@ "node": ">=6" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/kapsule": { "version": "1.16.3", "resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz", @@ -4551,6 +4711,17 @@ "yallist": "^3.0.2" } }, + "node_modules/lucia": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/lucia/-/lucia-3.2.2.tgz", + "integrity": "sha512-P1FlFBGCMPMXu+EGdVD9W4Mjm0DqsusmKgO7Xc33mI5X1bklmsQb0hfzPhXomQr9waWIBDsiOjvr1e6BTaUqpA==", + "deprecated": "This package has been deprecated. Please see https://lucia-auth.com/lucia-v3/migrate.", + "license": "MIT", + "dependencies": { + "@oslojs/crypto": "^1.0.1", + "@oslojs/encoding": "^1.1.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4876,6 +5047,15 @@ "which": "bin/which" } }, + "node_modules/oauth4webapi": { + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", + "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5151,6 +5331,15 @@ "url": "https://opencollective.com/preact" } }, + "node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prettier": { "version": "3.8.3", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", @@ -5416,7 +5605,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" @@ -5608,6 +5796,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -6015,7 +6209,6 @@ "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "~0.27.0", diff --git a/package.json b/package.json index ddc51ff0..55fc1eea 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ "type": "module", "scripts": { "setup": "tsx scripts/setup.ts", + "deploy": "tsx scripts/deploy.ts", + "pretest": "node scripts/create-test-stubs.mjs", + "test": "CONVEX_URL=http://localhost:0 tsx --test 'server/**/*.test.ts' 'convex/**/*.test.ts'", "sendblue:sync": "node scripts/sendblue-sync.mjs", "sendblue:webhook": "node scripts/sendblue-webhook.mjs", "preflight": "node scripts/preflight.mjs", @@ -22,8 +25,10 @@ }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.1.0", + "@auth/core": "^0.37.4", "@composio/claude-agent-sdk": "^0.6.10", "@composio/core": "^0.6.10", + "@convex-dev/auth": "^0.0.91", "@hugeicons/core-free-icons": "^1.0.0", "@hugeicons/react": "^1.0.0", "@modelcontextprotocol/sdk": "^1.0.0", @@ -32,7 +37,9 @@ "croner": "^9.0.0", "dotenv": "^16.4.0", "express": "^5.0.0", + "jose": "^5.9.0", "react-force-graph-2d": "^1.27.0", + "tsx": "^4.19.0", "ws": "^8.18.0", "zod": "^3.23.0" }, @@ -51,7 +58,6 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "tailwindcss": "^4.0.0", - "tsx": "^4.19.0", "typescript": "^5.6.0", "vite": "^6.0.0" }, diff --git a/scripts/create-test-stubs.mjs b/scripts/create-test-stubs.mjs new file mode 100644 index 00000000..2cefde95 --- /dev/null +++ b/scripts/create-test-stubs.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node +/** + * Creates minimal stubs for files that are gitignored (generated by Convex + * codegen) so that tests can import server modules without a live Convex + * deployment. Safe to re-run; it is idempotent. + */ +import { mkdirSync, writeFileSync, existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const generatedDir = resolve(root, "convex/_generated"); + +mkdirSync(generatedDir, { recursive: true }); + +const apiStub = resolve(generatedDir, "api.js"); +if (!existsSync(apiStub)) { + writeFileSync( + apiStub, + `// Minimal stub for test environments where Convex codegen has not run. +const makeProxy = () => + new Proxy( + {}, + { + get(_target, ns) { + return new Proxy( + {}, + { + get(_t, fn) { + return String(ns) + ":" + String(fn); + }, + }, + ); + }, + }, + ); + +export const api = makeProxy(); +export const internal = makeProxy(); +`, + ); + console.log("[test-stubs] created convex/_generated/api.js stub"); +} diff --git a/scripts/deploy.ts b/scripts/deploy.ts new file mode 100644 index 00000000..f53e2d82 --- /dev/null +++ b/scripts/deploy.ts @@ -0,0 +1,305 @@ +#!/usr/bin/env tsx +import prompts from "prompts"; +import { spawn } from "node:child_process"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { randomBytes } from "node:crypto"; + +const ROOT = resolve(new URL(".", import.meta.url).pathname, ".."); +const ENV_PATH = resolve(ROOT, ".env.local"); + +function banner(s: string) { + console.log("\n" + "━".repeat(60)); + console.log(" " + s); + console.log("━".repeat(60)); +} + +function readEnv(path: string): Record { + if (!existsSync(path)) return {}; + const env: Record = {}; + for (const line of readFileSync(path, "utf8").split("\n")) { + const m = line.match(/^([A-Z0-9_]+)=(.*)$/); + if (m) env[m[1]] = m[2]; + } + return env; +} + +function hasBinary(name: string): Promise { + return new Promise((ok) => { + const lookup = process.platform === "win32" ? "where" : "which"; + const child = spawn(lookup, [name], { stdio: "ignore" }); + child.on("exit", (code) => ok(code === 0)); + child.on("error", () => ok(false)); + }); +} + +function openInBrowser(url: string): void { + const cmd = + process.platform === "darwin" + ? "open" + : process.platform === "win32" + ? "start" + : "xdg-open"; + try { + spawn(cmd, [url], { stdio: "ignore", detached: true }).unref(); + } catch { + /* ignore */ + } +} + +function runInherit(cmd: string, args: string[]): Promise { + return new Promise((ok, fail) => { + const child = spawn(cmd, args, { stdio: "inherit", cwd: ROOT }); + child.on("exit", (code) => + code === 0 ? ok() : fail(new Error(`${cmd} ${args.join(" ")} exited ${code}`)), + ); + child.on("error", fail); + }); +} + +function runCapture(cmd: string, args: string[]): Promise { + return new Promise((ok, fail) => { + const child = spawn(cmd, args, { stdio: ["inherit", "pipe", "pipe"], cwd: ROOT }); + let out = ""; + child.stdout.on("data", (d) => { + const s = d.toString(); + out += s; + process.stdout.write(s); + }); + child.stderr.on("data", (d) => process.stderr.write(d)); + child.on("exit", (code) => + code === 0 ? ok(out) : fail(new Error(`${cmd} exited ${code}`)), + ); + child.on("error", fail); + }); +} + +function genPassword(): string { + return randomBytes(24).toString("base64url"); +} + +async function main() { + banner("Boop deploy — sets up Fly + Convex production deploy"); + + // ── Step 1: Verify local setup ────────────────────────────────────────────── + banner("1. Verifying local setup"); + const env = readEnv(ENV_PATH); + if (!env.CONVEX_DEPLOYMENT) { + const { runSetup } = await prompts({ + type: "confirm", + name: "runSetup", + message: "No CONVEX_DEPLOYMENT in .env.local — run `npm run setup` first?", + initial: true, + }); + if (runSetup) { + await runInherit("npm", ["run", "setup"]); + Object.assign(env, readEnv(ENV_PATH)); + } else { + throw new Error("CONVEX_DEPLOYMENT must be set before deploying."); + } + } + console.log("✓ Local Convex deployment configured."); + + // ── Step 2: Fly.io app ────────────────────────────────────────────────────── + banner("2. Fly.io app"); + if (!(await hasBinary("fly"))) { + console.log( + "Fly CLI not found. Install with: curl -L https://fly.io/install.sh | sh", + ); + throw new Error("Install fly CLI and re-run."); + } + try { + await runCapture("fly", ["auth", "whoami"]); + } catch { + console.log("Not logged in. Running `fly auth login`..."); + await runInherit("fly", ["auth", "login"]); + } + + const { appName } = await prompts({ + type: "text", + name: "appName", + message: "Fly app name (must be globally unique):", + initial: env.FLY_APP_NAME ?? "", + validate: (v: string) => /^[a-z0-9-]{3,40}$/.test(v) || "lowercase letters, digits, dashes", + }); + + let appExists = false; + try { + await runCapture("fly", ["apps", "list", "--json"]).then((out) => { + const apps = JSON.parse(out); + appExists = apps.some((a: { Name: string }) => a.Name === appName); + }); + } catch { + /* fall through — try to create */ + } + + if (!appExists) { + await runInherit("fly", ["apps", "create", appName]); + } + + const PUBLIC_URL = `https://${appName}.fly.dev`; + console.log(`✓ App ready: ${PUBLIC_URL}`); + + // ── Step 3: Generate secrets for production ───────────────────────────────── + banner("3. Generate secrets for production"); + + let llmAuth: { name: string; value: string }; + const { llmChoice } = await prompts({ + type: "select", + name: "llmChoice", + message: "Which LLM auth?", + choices: [ + { title: "Claude Code subscription token (recommended)", value: "oauth" }, + { title: "Anthropic API key (per-token billing)", value: "api" }, + ], + initial: 0, + }); + + if (llmChoice === "oauth") { + console.log("\nIn another terminal, run: claude setup-token"); + console.log("It will print a token. Paste it below."); + const { token } = await prompts({ + type: "password", + name: "token", + message: "CLAUDE_CODE_OAUTH_TOKEN:", + }); + llmAuth = { name: "CLAUDE_CODE_OAUTH_TOKEN", value: token }; + } else { + const { key } = await prompts({ + type: "password", + name: "key", + message: "ANTHROPIC_API_KEY:", + }); + llmAuth = { name: "ANTHROPIC_API_KEY", value: key }; + } + + const { signingSecret } = await prompts({ + type: "password", + name: "signingSecret", + message: "SENDBLUE_SIGNING_SECRET (Sendblue dashboard → Webhook → Signing Secret):", + }); + + const adminPassword = env.BOOP_ADMIN_PASSWORD || genPassword(); + console.log(`\nGenerated BOOP_ADMIN_PASSWORD: ${adminPassword}`); + console.log("(Save this — you'll use it to log into the dashboard.)"); + + // ── Step 4: Push secrets to Fly ───────────────────────────────────────────── + banner("4. Pushing secrets to Fly"); + + // CONVEX_SITE_URL hosts /.well-known/jwks.json (the .convex.site domain). + // The Express auth middleware needs it to verify Convex Auth JWTs. Convex + // sets it automatically in .env.local; fall back to deriving it from + // CONVEX_URL by swapping .convex.cloud → .convex.site if it's missing. + const convexSiteUrl = + env.CONVEX_SITE_URL ?? + env.CONVEX_URL?.replace(".convex.cloud", ".convex.site") ?? + ""; + + const flySecrets: Record = { + [llmAuth.name]: llmAuth.value, + SENDBLUE_API_KEY: env.SENDBLUE_API_KEY ?? "", + SENDBLUE_API_SECRET: env.SENDBLUE_API_SECRET ?? "", + SENDBLUE_FROM_NUMBER: env.SENDBLUE_FROM_NUMBER ?? "", + SENDBLUE_SIGNING_SECRET: signingSecret, + CONVEX_DEPLOYMENT: env.CONVEX_DEPLOYMENT ?? "", + CONVEX_URL: env.CONVEX_URL ?? "", + CONVEX_SITE_URL: convexSiteUrl, + COMPOSIO_API_KEY: env.COMPOSIO_API_KEY ?? "", + BOOP_ADMIN_PASSWORD: adminPassword, + PUBLIC_URL, + NODE_ENV: "production", + }; + + const setArgs = ["secrets", "set", "--app", appName]; + for (const [k, v] of Object.entries(flySecrets)) { + if (v) setArgs.push(`${k}=${v}`); + } + await runInherit("fly", setArgs); + + // ── Step 5: Convex env ────────────────────────────────────────────────────── + banner("5. Configuring Convex env"); + console.log("Setting BOOP_ADMIN_PASSWORD on the production Convex deployment..."); + await runInherit("npx", [ + "convex", + "env", + "set", + "BOOP_ADMIN_PASSWORD", + adminPassword, + ]); + + // ── Step 6: Sendblue webhook ──────────────────────────────────────────────── + banner("6. Sendblue webhook"); + console.log(`Open the Sendblue dashboard and set the INBOUND webhook to:`); + console.log(` ${PUBLIC_URL}/sendblue/webhook`); + openInBrowser("https://app.sendblue.com/settings/webhooks"); + const { webhookSet } = await prompts({ + type: "confirm", + name: "webhookSet", + message: "Done?", + initial: true, + }); + if (!webhookSet) { + console.log("⚠️ Skipping for now — you must set this before iMessages reach the server."); + } + + // ── Step 7: GitHub Actions secrets ───────────────────────────────────────── + banner("7. GitHub Actions secrets"); + if (await hasBinary("gh")) { + const { useGh } = await prompts({ + type: "confirm", + name: "useGh", + message: "Push secrets to GitHub via `gh secret set`?", + initial: true, + }); + if (useGh) { + const flyToken = await runCapture("fly", ["auth", "token"]).then((s) => s.trim()); + const convexDeployKey = await prompts({ + type: "password", + name: "k", + message: "Convex deploy key (https://dashboard.convex.dev → project → Deploy Keys):", + }).then((r) => r.k); + await runInherit("gh", ["secret", "set", "FLY_API_TOKEN", "--body", flyToken]); + await runInherit("gh", ["secret", "set", "FLY_APP_NAME", "--body", appName]); + await runInherit("gh", [ + "secret", + "set", + "CONVEX_DEPLOY_KEY", + "--body", + convexDeployKey, + ]); + } + } else { + console.log("Install `gh` CLI to auto-set secrets, or set them manually:"); + console.log(" - FLY_API_TOKEN (run `fly auth token`)"); + console.log(` - FLY_APP_NAME = ${appName}`); + console.log(" - CONVEX_DEPLOY_KEY (Convex dashboard → Deploy Keys)"); + } + + // ── Step 8: First deploy ──────────────────────────────────────────────────── + banner("8. First deploy"); + const { deployNow } = await prompts({ + type: "confirm", + name: "deployNow", + message: "Run `fly deploy --remote-only` now?", + initial: true, + }); + if (deployNow) { + await runInherit("fly", ["deploy", "--remote-only", "--app", appName]); + console.log("\nBootstrapping admin user..."); + await runInherit("npx", ["convex", "run", "users:bootstrap"]); + } + + // ── Done ──────────────────────────────────────────────────────────────────── + banner("Done!"); + console.log(`Dashboard: ${PUBLIC_URL}/`); + console.log(`Health: ${PUBLIC_URL}/health`); + console.log(`Webhook: ${PUBLIC_URL}/sendblue/webhook`); + console.log(""); + console.log("Future deploys: `git push origin main` triggers GitHub Actions."); + console.log("Annual: rotate CLAUDE_CODE_OAUTH_TOKEN by re-running `claude setup-token`."); +} + +main().catch((err) => { + console.error("\n[deploy] failed:", err.message); + process.exit(1); +}); diff --git a/server/auth.test.ts b/server/auth.test.ts new file mode 100644 index 00000000..82e7e4e8 --- /dev/null +++ b/server/auth.test.ts @@ -0,0 +1,149 @@ +import { describe, it, mock } from "node:test"; +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import { verifyHmac, requireAdmin } from "./auth.js"; +import type { Request, Response, NextFunction } from "express"; + +const SECRET = "test-secret-abc-123"; + +function sign(body: string, secret: string = SECRET): string { + return createHmac("sha256", secret).update(body).digest("hex"); +} + +describe("verifyHmac", () => { + it("accepts a valid signature", () => { + const body = '{"hello":"world"}'; + const sig = sign(body); + assert.equal(verifyHmac(body, sig, SECRET), true); + }); + + it("rejects a wrong signature", () => { + const body = '{"hello":"world"}'; + assert.equal(verifyHmac(body, "deadbeef".repeat(8), SECRET), false); + }); + + it("rejects when signature is missing", () => { + const body = '{"hello":"world"}'; + assert.equal(verifyHmac(body, undefined, SECRET), false); + assert.equal(verifyHmac(body, "", SECRET), false); + }); + + it("rejects when secret is empty", () => { + const body = '{"hello":"world"}'; + const sig = sign(body, ""); + assert.equal(verifyHmac(body, sig, ""), false); + }); + + it("rejects on length mismatch (timing-safe)", () => { + const body = '{"hello":"world"}'; + const sig = sign(body); + assert.equal(verifyHmac(body, sig.slice(0, -2), SECRET), false); + }); +}); + +function mockReq(overrides: Partial = {}): Request { + return { + path: "/chat", + method: "POST", + headers: {}, + ...overrides, + } as Request; +} + +function mockRes(): { res: Response; status: number; body: unknown } { + const captured = { status: 200, body: undefined as unknown }; + const res = { + status(code: number) { + captured.status = code; + return this; + }, + json(payload: unknown) { + captured.body = payload; + return this; + }, + } as unknown as Response; + return { res, get status() { return captured.status; }, get body() { return captured.body; } } as any; +} + +describe("requireAdmin", () => { + it("lets /health through without a token", async () => { + const verify = mock.fn(async () => ({ payload: {} }) as any); + const middleware = requireAdmin({ verifyJwt: verify }); + const req = mockReq({ path: "/health", method: "GET" }); + const { res } = mockRes(); + let nextCalled = false; + const next: NextFunction = () => { + nextCalled = true; + }; + await middleware(req, res, next); + assert.equal(nextCalled, true); + assert.equal(verify.mock.callCount(), 0); + }); + + it("lets /sendblue/webhook through without a token", async () => { + const verify = mock.fn(async () => ({ payload: {} }) as any); + const middleware = requireAdmin({ verifyJwt: verify }); + const req = mockReq({ path: "/sendblue/webhook", method: "POST" }); + const { res } = mockRes(); + let nextCalled = false; + await middleware(req, res, () => { + nextCalled = true; + }); + assert.equal(nextCalled, true); + }); + + it("rejects an admin path with no Authorization header", async () => { + const verify = mock.fn(async () => ({ payload: {} }) as any); + const middleware = requireAdmin({ verifyJwt: verify }); + const req = mockReq(); + const captured = mockRes(); + let nextCalled = false; + await middleware(req, captured.res, () => { + nextCalled = true; + }); + assert.equal(nextCalled, false); + assert.equal((captured as any).status, 401); + }); + + it("rejects an admin path with a malformed Authorization header", async () => { + const verify = mock.fn(async () => ({ payload: {} }) as any); + const middleware = requireAdmin({ verifyJwt: verify }); + const req = mockReq({ headers: { authorization: "NotBearer foo" } }); + const captured = mockRes(); + let nextCalled = false; + await middleware(req, captured.res, () => { + nextCalled = true; + }); + assert.equal(nextCalled, false); + assert.equal((captured as any).status, 401); + }); + + it("calls next() when the JWT verifies", async () => { + const verify = mock.fn(async () => ({ payload: { sub: "user_123" } }) as any); + const middleware = requireAdmin({ verifyJwt: verify }); + const req = mockReq({ headers: { authorization: "Bearer good.jwt.value" } }); + const captured = mockRes(); + let nextCalled = false; + await middleware(req, captured.res, () => { + nextCalled = true; + }); + assert.equal(nextCalled, true); + assert.equal(verify.mock.callCount(), 1); + assert.equal((verify.mock.calls[0]! as any).arguments[0], "good.jwt.value"); + }); + + it("rejects when the JWT verifier throws", async () => { + const verify = mock.fn(async () => { + throw new Error("expired"); + }); + const middleware = requireAdmin({ verifyJwt: verify }); + const req = mockReq({ headers: { authorization: "Bearer bad.jwt.value" } }); + const captured = mockRes(); + let nextCalled = false; + await middleware(req, captured.res, () => { + nextCalled = true; + }); + assert.equal(nextCalled, false); + assert.equal((captured as any).status, 401); + }); +}); diff --git a/server/auth.ts b/server/auth.ts new file mode 100644 index 00000000..01e9719d --- /dev/null +++ b/server/auth.ts @@ -0,0 +1,73 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import type { Request, Response, NextFunction, RequestHandler } from "express"; +import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; + +export function verifyHmac( + body: string, + signature: string | undefined, + secret: string, +): boolean { + if (!signature || !secret) return false; + const expected = createHmac("sha256", secret).update(body).digest("hex"); + if (expected.length !== signature.length) return false; + return timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); +} + +const PUBLIC_ALLOWLIST: Array<{ method?: string; pathPrefix: string }> = [ + { method: "GET", pathPrefix: "/health" }, + { method: "POST", pathPrefix: "/sendblue/webhook" }, +]; + +function isPublic(req: Request): boolean { + return PUBLIC_ALLOWLIST.some( + (rule) => + (!rule.method || rule.method === req.method) && + req.path.startsWith(rule.pathPrefix), + ); +} + +export type VerifyJwt = (token: string) => Promise<{ payload: JWTPayload }>; + +export interface RequireAdminOptions { + verifyJwt?: VerifyJwt; +} + +// @convex-dev/auth signs JWTs with `CONVEX_SITE_URL` as issuer and serves +// /.well-known/jwks.json on the .convex.site host (see auth.config.ts). +// Using CONVEX_URL here would 401 every request in production. +export function defaultVerifier(): VerifyJwt { + const siteUrl = process.env.CONVEX_SITE_URL; + if (!siteUrl) { + throw new Error( + "CONVEX_SITE_URL not set — Express auth middleware requires it to fetch the Convex JWKS", + ); + } + const jwks = createRemoteJWKSet(new URL("/.well-known/jwks.json", siteUrl)); + return async (token) => jwtVerify(token, jwks, { issuer: siteUrl }); +} + +export function requireAdmin(opts: RequireAdminOptions = {}): RequestHandler { + const verify = opts.verifyJwt ?? defaultVerifier(); + return async (req: Request, res: Response, next: NextFunction) => { + if (isPublic(req)) { + next(); + return; + } + const header = req.headers.authorization; + if (!header || typeof header !== "string" || !header.startsWith("Bearer ")) { + res.status(401).json({ error: "missing or malformed Authorization header" }); + return; + } + const token = header.slice("Bearer ".length).trim(); + if (!token) { + res.status(401).json({ error: "empty bearer token" }); + return; + } + try { + await verify(token); + next(); + } catch (err) { + res.status(401).json({ error: "invalid token" }); + } + }; +} diff --git a/server/automation-tools.ts b/server/automation-tools.ts index d8ebae7e..46ec4b4d 100644 --- a/server/automation-tools.ts +++ b/server/automation-tools.ts @@ -1,6 +1,6 @@ import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk"; import { z } from "zod"; -import { api } from "../convex/_generated/api.js"; +import { internal } from "../convex/_generated/api.js"; import { convex } from "./convex-client.js"; import { availableIntegrations } from "./execution-agent.js"; import { nextRunFor, validateSchedule } from "./automations.js"; @@ -61,7 +61,7 @@ Integrations available: ${integrationHint}`, } const automationId = randomId("auto"); const nextRunAt = nextRunFor(args.schedule) ?? undefined; - await convex.mutation(api.automations.create, { + await convex.mutation(internal.automations.create, { automationId, name: args.name, task: args.task, @@ -88,7 +88,7 @@ Integrations available: ${integrationHint}`, "List all automations for this conversation.", { enabledOnly: z.boolean().optional().default(false) }, async (args) => { - const all = await convex.query(api.automations.list, { + const all = await convex.query(internal.automations.listInternal, { enabledOnly: args.enabledOnly, }); const mine = all.filter((a) => a.conversationId === conversationId); @@ -108,7 +108,7 @@ Integrations available: ${integrationHint}`, "Enable or disable an automation by id.", { automationId: z.string(), enabled: z.boolean() }, async (args) => { - const id = await convex.mutation(api.automations.setEnabled, args); + const id = await convex.mutation(internal.automations.setEnabledInternal, args); return { content: [ { @@ -125,7 +125,7 @@ Integrations available: ${integrationHint}`, "Permanently remove an automation.", { automationId: z.string() }, async (args) => { - const id = await convex.mutation(api.automations.remove, args); + const id = await convex.mutation(internal.automations.removeInternal, args); return { content: [ { diff --git a/server/automations.ts b/server/automations.ts index d68cad33..b50d31e2 100644 --- a/server/automations.ts +++ b/server/automations.ts @@ -1,5 +1,5 @@ import { Cron } from "croner"; -import { api } from "../convex/_generated/api.js"; +import { internal } from "../convex/_generated/api.js"; import { convex } from "./convex-client.js"; import { spawnExecutionAgent } from "./execution-agent.js"; import { sendImessage } from "./sendblue.js"; @@ -38,7 +38,7 @@ async function runAutomation(a: { notifyConversationId?: string; }): Promise { const runId = randomId("run"); - await convex.mutation(api.automations.createRun, { + await convex.mutation(internal.automations.createRun, { runId, automationId: a.automationId, }); @@ -51,7 +51,7 @@ async function runAutomation(a: { conversationId: a.conversationId, name: `auto:${a.name}`, }); - await convex.mutation(api.automations.updateRun, { + await convex.mutation(internal.automations.updateRun, { runId, status: res.status === "completed" ? "completed" : "failed", result: res.result, @@ -64,7 +64,7 @@ async function runAutomation(a: { const preamble = `[${a.name}]\n\n`; await sendImessage(number, preamble + res.result); } - await convex.mutation(api.messages.send, { + await convex.mutation(internal.messages.send, { conversationId: a.notifyConversationId, role: "assistant", content: `[${a.name}]\n\n${res.result}`, @@ -73,7 +73,7 @@ async function runAutomation(a: { broadcast("automation_completed", { automationId: a.automationId, runId }); } catch (err) { - await convex.mutation(api.automations.updateRun, { + await convex.mutation(internal.automations.updateRun, { runId, status: "failed", error: String(err), @@ -82,7 +82,7 @@ async function runAutomation(a: { } const next = nextRunFor(a.schedule); - await convex.mutation(api.automations.markRan, { + await convex.mutation(internal.automations.markRan, { automationId: a.automationId, lastRunAt: Date.now(), nextRunAt: next ?? undefined, @@ -90,7 +90,7 @@ async function runAutomation(a: { } export async function tickAutomations(): Promise { - const all = await convex.query(api.automations.list, { enabledOnly: true }); + const all = await convex.query(internal.automations.listInternal, { enabledOnly: true }); const now = Date.now(); const due = all.filter((a) => a.nextRunAt !== undefined && a.nextRunAt <= now); for (const a of due) { diff --git a/server/consolidation.ts b/server/consolidation.ts index 6c406b39..a654a097 100644 --- a/server/consolidation.ts +++ b/server/consolidation.ts @@ -1,5 +1,5 @@ import { query } from "@anthropic-ai/claude-agent-sdk"; -import { api } from "../convex/_generated/api.js"; +import { internal } from "../convex/_generated/api.js"; import { convex } from "./convex-client.js"; import { broadcast } from "./broadcast.js"; import { aggregateUsageFromResult, EMPTY_USAGE, type UsageTotals } from "./usage.js"; @@ -146,7 +146,7 @@ async function recordConsolidationUsage( durationMs: number, ): Promise { if (usage.costUsd <= 0 && usage.inputTokens <= 0) return; - await convex.mutation(api.usageRecords.record, { + await convex.mutation(internal.usageRecords.record, { source, runId, model: usage.model, @@ -176,20 +176,20 @@ export async function runConsolidation(trigger = "scheduled"): Promise<{ pruned: number; }> { const runId = randomId("cons"); - await convex.mutation(api.consolidation.createRun, { runId, trigger }); + await convex.mutation(internal.consolidation.createRun, { runId, trigger }); broadcast("consolidation_started", { runId, trigger }); let merged = 0; let pruned = 0; try { - const memories = await convex.query(api.memoryRecords.list, { + const memories = await convex.query(internal.memoryRecords.listInternal, { lifecycle: "active", limit: 150, }); broadcast("consolidation_phase", { runId, phase: "loaded", memoriesCount: memories.length }); if (memories.length < 6) { - await convex.mutation(api.consolidation.updateRun, { + await convex.mutation(internal.consolidation.updateRun, { runId, status: "completed", notes: "not enough memories to consolidate", @@ -244,13 +244,13 @@ export async function runConsolidation(trigger = "scheduled"): Promise<{ proposals, }); - await convex.mutation(api.consolidation.updateRun, { + await convex.mutation(internal.consolidation.updateRun, { runId, proposalsCount: proposals.length, }); if (proposals.length === 0) { - await convex.mutation(api.consolidation.updateRun, { + await convex.mutation(internal.consolidation.updateRun, { runId, status: "completed", notes: "no proposals", @@ -323,7 +323,7 @@ export async function runConsolidation(trigger = "scheduled"): Promise<{ if (p.type === "merge" && p.keep && p.absorb?.length && p.rewriteContent) { const keep = memories.find((m) => m.memoryId === p.keep); if (!keep) continue; - await convex.mutation(api.memoryRecords.upsert, { + await convex.mutation(internal.memoryRecords.upsert, { memoryId: keep.memoryId, content: p.rewriteContent, tier: keep.tier, @@ -341,7 +341,7 @@ export async function runConsolidation(trigger = "scheduled"): Promise<{ } else if (p.type === "supersede" && p.newer && p.older?.length) { const newer = memories.find((m) => m.memoryId === p.newer); if (!newer) continue; - await convex.mutation(api.memoryRecords.upsert, { + await convex.mutation(internal.memoryRecords.upsert, { memoryId: newer.memoryId, content: newer.content, tier: newer.tier, @@ -357,7 +357,7 @@ export async function runConsolidation(trigger = "scheduled"): Promise<{ summary: `${p.newer} supersedes ${p.older.length} older`, }); } else if (p.type === "prune" && p.memoryId) { - await convex.mutation(api.memoryRecords.setLifecycle, { + await convex.mutation(internal.memoryRecords.setLifecycle, { memoryId: p.memoryId, lifecycle: "pruned", }); @@ -373,7 +373,7 @@ export async function runConsolidation(trigger = "scheduled"): Promise<{ } } - await convex.mutation(api.consolidation.updateRun, { + await convex.mutation(internal.consolidation.updateRun, { runId, status: "completed", mergedCount: merged, @@ -386,14 +386,14 @@ export async function runConsolidation(trigger = "scheduled"): Promise<{ applied, }), }); - await convex.mutation(api.memoryEvents.emit, { + await convex.mutation(internal.memoryEvents.emit, { eventType: "memory.consolidated", data: JSON.stringify({ runId, proposals: proposals.length, merged, pruned }), }); broadcast("consolidation_completed", { runId, merged, pruned }); return { runId, proposals: proposals.length, merged, pruned }; } catch (err) { - await convex.mutation(api.consolidation.updateRun, { + await convex.mutation(internal.consolidation.updateRun, { runId, status: "failed", notes: String(err), diff --git a/server/draft-tools.ts b/server/draft-tools.ts index 23a0728a..3363b5e5 100644 --- a/server/draft-tools.ts +++ b/server/draft-tools.ts @@ -1,6 +1,6 @@ import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk"; import { z } from "zod"; -import { api } from "../convex/_generated/api.js"; +import { internal } from "../convex/_generated/api.js"; import { convex } from "./convex-client.js"; import { spawnExecutionAgent } from "./execution-agent.js"; @@ -32,7 +32,7 @@ ALWAYS call this instead of sending or creating something directly. The user wil }, async (args) => { const draftId = randomId("draft"); - await convex.mutation(api.drafts.create, { + await convex.mutation(internal.drafts.create, { draftId, conversationId, kind: args.kind, @@ -66,7 +66,7 @@ export function createDraftDecisionMcp(conversationId: string) { "List pending drafts in this conversation. Call this when the user says 'send it', 'yes', 'go ahead', etc. without a specific id.", {}, async () => { - const drafts = await convex.query(api.drafts.pendingByConversation, { + const drafts = await convex.query(internal.drafts.pendingByConversationInternal, { conversationId, }); if (drafts.length === 0) { @@ -84,7 +84,7 @@ export function createDraftDecisionMcp(conversationId: string) { "Approve and execute a draft. Spawns an execution agent to actually perform the action based on the stored payload.", { draftId: z.string(), integrations: z.array(z.string()) }, async (args) => { - const draft = await convex.query(api.drafts.get, { draftId: args.draftId }); + const draft = await convex.query(internal.drafts.getInternal, { draftId: args.draftId }); if (!draft || draft.status !== "pending") { return { content: [ @@ -95,7 +95,7 @@ export function createDraftDecisionMcp(conversationId: string) { ], }; } - await convex.mutation(api.drafts.setStatus, { + await convex.mutation(internal.drafts.setStatus, { draftId: args.draftId, status: "sent", }); @@ -125,7 +125,7 @@ payload JSON: ${draft.payload}`; "Cancel a pending draft when the user says 'no', 'cancel', or revises the request.", { draftId: z.string() }, async (args) => { - await convex.mutation(api.drafts.setStatus, { + await convex.mutation(internal.drafts.setStatus, { draftId: args.draftId, status: "rejected", }); diff --git a/server/execution-agent.ts b/server/execution-agent.ts index 49019ede..eab92379 100644 --- a/server/execution-agent.ts +++ b/server/execution-agent.ts @@ -1,5 +1,5 @@ import { query } from "@anthropic-ai/claude-agent-sdk"; -import { api } from "../convex/_generated/api.js"; +import { internal } from "../convex/_generated/api.js"; import { convex } from "./convex-client.js"; import { broadcast } from "./broadcast.js"; import { buildMcpServersForIntegrations, listIntegrations } from "./integrations/registry.js"; @@ -74,7 +74,7 @@ export async function spawnExecutionAgent(opts: SpawnOptions): Promise (c.type === "text" ? (c.text ?? "") : "")) .join("") : String(block.content ?? ""); - await convex.mutation(api.agents.addLog, { + await convex.mutation(internal.agents.addLog, { agentId, logType: "tool_result", content: text.slice(0, 2000), @@ -169,7 +169,7 @@ export async function spawnExecutionAgent(opts: SpawnOptions): Promise 0 || usage.inputTokens > 0) { - await convex.mutation(api.usageRecords.record, { + await convex.mutation(internal.usageRecords.record, { source: "execution", conversationId: opts.conversationId, agentId, @@ -226,7 +226,7 @@ export function runningAgentIds(): string[] { } export async function retryAgent(agentId: string): Promise { - const existing = await convex.query(api.agents.get, { agentId }); + const existing = await convex.query(internal.agents.getInternal, { agentId }); if (!existing) return null; return await spawnExecutionAgent({ task: existing.task, diff --git a/server/heartbeat.ts b/server/heartbeat.ts index 657dffc0..9096aba9 100644 --- a/server/heartbeat.ts +++ b/server/heartbeat.ts @@ -1,4 +1,4 @@ -import { api } from "../convex/_generated/api.js"; +import { internal } from "../convex/_generated/api.js"; import { convex } from "./convex-client.js"; import { cancelAgent, runningAgentIds } from "./execution-agent.js"; import { broadcast } from "./broadcast.js"; @@ -6,7 +6,7 @@ import { broadcast } from "./broadcast.js"; const STALE_MS = 15 * 60 * 1000; export async function sweepStaleAgents(): Promise { - const runningInDb = await convex.query(api.agents.list, { status: "running", limit: 100 }); + const runningInDb = await convex.query(internal.agents.listInternal, { status: "running", limit: 100 }); const now = Date.now(); const live = new Set(runningAgentIds()); @@ -17,7 +17,7 @@ export async function sweepStaleAgents(): Promise { if (live.has(a.agentId)) { cancelAgent(a.agentId); } - await convex.mutation(api.agents.update, { + await convex.mutation(internal.agents.update, { agentId: a.agentId, status: "failed", error: `Marked failed after ${Math.round(age / 1000)}s (stale heartbeat).`, diff --git a/server/index.ts b/server/index.ts index 2f3444ba..7d42e95a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -3,6 +3,8 @@ import express from "express"; import cors from "cors"; import { createServer } from "node:http"; import { WebSocketServer } from "ws"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { addClient } from "./broadcast.js"; import { createSendblueRouter } from "./sendblue.js"; import { handleUserMessage } from "./interaction-agent.js"; @@ -13,6 +15,7 @@ import { startHeartbeatLoop } from "./heartbeat.js"; import { startConsolidationLoop } from "./consolidation.js"; import { cancelAgent, retryAgent } from "./execution-agent.js"; import { createComposioRouter } from "./composio-routes.js"; +import { requireAdmin, defaultVerifier } from "./auth.js"; async function main() { await loadIntegrations(); @@ -25,10 +28,32 @@ async function main() { app.use(cors()); app.use(express.json({ limit: "2mb" })); + // PUBLIC: health check. app.get("/health", (_req, res) => { res.json({ ok: true, service: "boop-agent" }); }); + // PUBLIC (in production): the built debug UI bundle. Static assets must + // load before the SPA can render the login form, so they're served + // BEFORE requireAdmin gates the API surface. + if (process.env.NODE_ENV === "production") { + const here = path.dirname(fileURLToPath(import.meta.url)); + const debugDist = path.resolve(here, "../debug/dist"); + app.use(express.static(debugDist)); + app.get("/debug/*", (_req, res) => { + res.sendFile(path.join(debugDist, "index.html")); + }); + } + + // Single JWT verifier shared between HTTP middleware and the WS upgrade + // handler — `createRemoteJWKSet` keeps an in-memory JWKS cache per + // instance, so reusing one verifier avoids a fresh HTTP fetch per request. + const verifyJwt = defaultVerifier(); + + // AUTH GATE: every route below requires a valid Convex Auth JWT, except + // the explicit allowlist inside requireAdmin() (/sendblue/webhook + /health). + app.use(requireAdmin({ verifyJwt })); + app.use("/sendblue", createSendblueRouter()); app.use("/composio", createComposioRouter()); @@ -76,7 +101,35 @@ async function main() { }); const server = createServer(app); - const wss = new WebSocketServer({ server, path: "/ws" }); + const wss = new WebSocketServer({ noServer: true }); + + server.on("upgrade", async (req, socket, head) => { + const requestUrl = new URL(req.url ?? "", `http://${req.headers.host}`); + if (requestUrl.pathname !== "/ws") { + socket.destroy(); + return; + } + // Token is passed as a ?token= query param. The browser EventSource / + // WebSocket APIs can't set custom headers on the handshake, so query is + // the standard workaround. + const token = requestUrl.searchParams.get("token"); + if (!token) { + socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); + socket.destroy(); + return; + } + try { + await verifyJwt(token); + } catch { + socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); + socket.destroy(); + return; + } + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit("connection", ws, req); + }); + }); + wss.on("connection", (ws) => { addClient(ws); ws.send(JSON.stringify({ event: "hello", data: { ok: true }, at: Date.now() })); diff --git a/server/interaction-agent.ts b/server/interaction-agent.ts index 18ca3d36..09fe997b 100644 --- a/server/interaction-agent.ts +++ b/server/interaction-agent.ts @@ -1,6 +1,6 @@ import { query, tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk"; import { z } from "zod"; -import { api } from "../convex/_generated/api.js"; +import { internal } from "../convex/_generated/api.js"; import { convex } from "./convex-client.js"; import { createMemoryMcp } from "./memory/tools.js"; import { extractAndStore } from "./memory/extract.js"; @@ -104,7 +104,7 @@ export async function handleUserMessage(opts: HandleOpts): Promise { const turnId = randomId("turn"); const integrations = availableIntegrations(); - await convex.mutation(api.messages.send, { + await convex.mutation(internal.messages.send, { conversationId: opts.conversationId, role: "user", content: opts.content, @@ -137,7 +137,7 @@ export async function handleUserMessage(opts: HandleOpts): Promise { const number = opts.conversationId.slice(4); await sendImessage(number, text); } - await convex.mutation(api.messages.send, { + await convex.mutation(internal.messages.send, { conversationId: opts.conversationId, role: "assistant", content: text, @@ -192,7 +192,7 @@ export async function handleUserMessage(opts: HandleOpts): Promise { ], }); - const history = await convex.query(api.messages.recent, { + const history = await convex.query(internal.messages.recentInternal, { conversationId: opts.conversationId, limit: 10, }); @@ -289,7 +289,7 @@ export async function handleUserMessage(opts: HandleOpts): Promise { log( `cost: in/out ${usage.inputTokens}/${usage.outputTokens}, cache r/w ${usage.cacheReadTokens}/${usage.cacheCreationTokens}, $${usage.costUsd.toFixed(4)}`, ); - await convex.mutation(api.usageRecords.record, { + await convex.mutation(internal.usageRecords.record, { source: "dispatcher", conversationId: opts.conversationId, turnId, diff --git a/server/memory/clean.ts b/server/memory/clean.ts index 7357ee20..38c53139 100644 --- a/server/memory/clean.ts +++ b/server/memory/clean.ts @@ -1,4 +1,4 @@ -import { api } from "../../convex/_generated/api.js"; +import { internal } from "../../convex/_generated/api.js"; import { convex } from "../convex-client.js"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -51,7 +51,7 @@ export async function cleanMemories(): Promise<{ archived: number; pruned: number; }> { - const active = await convex.query(api.memoryRecords.list, { lifecycle: "active", limit: 500 }); + const active = await convex.query(internal.memoryRecords.listInternal, { lifecycle: "active", limit: 500 }); let archived = 0; let pruned = 0; @@ -59,13 +59,13 @@ export async function cleanMemories(): Promise<{ if (mem.tier === "permanent") continue; const score = effectiveScore(mem); if (score < PRUNE_THRESHOLD) { - await convex.mutation(api.memoryRecords.setLifecycle, { + await convex.mutation(internal.memoryRecords.setLifecycle, { memoryId: mem.memoryId, lifecycle: "pruned", }); pruned++; } else if (score < ARCHIVE_THRESHOLD && mem.tier !== "long") { - await convex.mutation(api.memoryRecords.setLifecycle, { + await convex.mutation(internal.memoryRecords.setLifecycle, { memoryId: mem.memoryId, lifecycle: "archived", }); @@ -73,7 +73,7 @@ export async function cleanMemories(): Promise<{ } } - await convex.mutation(api.memoryEvents.emit, { + await convex.mutation(internal.memoryEvents.emit, { eventType: "memory.cleaned", data: JSON.stringify({ scanned: active.length, archived, pruned }), }); diff --git a/server/memory/extract.ts b/server/memory/extract.ts index 669e4be1..0f5c78fd 100644 --- a/server/memory/extract.ts +++ b/server/memory/extract.ts @@ -1,5 +1,5 @@ import { query } from "@anthropic-ai/claude-agent-sdk"; -import { api } from "../../convex/_generated/api.js"; +import { internal } from "../../convex/_generated/api.js"; import { convex } from "../convex-client.js"; import { embed } from "../embeddings.js"; import { aggregateUsageFromResult, EMPTY_USAGE, type UsageTotals } from "../usage.js"; @@ -67,7 +67,7 @@ export async function extractAndStore(opts: { } if (usage.costUsd > 0 || usage.inputTokens > 0) { - await convex.mutation(api.usageRecords.record, { + await convex.mutation(internal.usageRecords.record, { source: "extract", conversationId: opts.conversationId, turnId: opts.turnId, @@ -101,7 +101,7 @@ export async function extractAndStore(opts: { f.segment === "correction" && f.corrects ? JSON.stringify({ corrects: f.corrects }) : undefined; - await convex.mutation(api.memoryRecords.upsert, { + await convex.mutation(internal.memoryRecords.upsert, { memoryId, content: f.content, tier: defaults.tier, @@ -114,7 +114,7 @@ export async function extractAndStore(opts: { }); } - await convex.mutation(api.memoryEvents.emit, { + await convex.mutation(internal.memoryEvents.emit, { eventType: "memory.extracted", conversationId: opts.conversationId, data: JSON.stringify({ turnId: opts.turnId, count: facts.length }), diff --git a/server/memory/tools.ts b/server/memory/tools.ts index 8444aca5..e71b7f95 100644 --- a/server/memory/tools.ts +++ b/server/memory/tools.ts @@ -1,6 +1,6 @@ import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk"; import { z } from "zod"; -import { api } from "../../convex/_generated/api.js"; +import { internal } from "../../convex/_generated/api.js"; import { convex } from "../convex-client.js"; import { embed, embeddingsAvailable } from "../embeddings.js"; import { DEFAULT_DECAY, SEGMENT_PREFERRED_TIER, makeMemoryId } from "./types.js"; @@ -39,7 +39,7 @@ export function createMemoryMcp(conversationId: string) { const tier = args.tier ?? SEGMENT_PREFERRED_TIER[args.segment]; const memoryId = makeMemoryId(); const embedding = (await embed(args.content)) ?? undefined; - await convex.mutation(api.memoryRecords.upsert, { + await convex.mutation(internal.memoryRecords.upsert, { memoryId, content: args.content, tier, @@ -49,7 +49,7 @@ export function createMemoryMcp(conversationId: string) { supersedes: args.supersedes, embedding, }); - await convex.mutation(api.memoryEvents.emit, { + await convex.mutation(internal.memoryEvents.emit, { eventType: "memory.written", conversationId, memoryId, @@ -80,7 +80,7 @@ export function createMemoryMcp(conversationId: string) { if (embeddingsAvailable()) { const queryVec = await embed(args.query); if (queryVec) { - const hits = await convex.action(api.memoryRecords.vectorSearch, { + const hits = await convex.action(internal.memoryRecords.vectorSearch, { embedding: queryVec, limit: args.limit, }); @@ -89,16 +89,16 @@ export function createMemoryMcp(conversationId: string) { } } if (results.length === 0) { - results = await convex.query(api.memoryRecords.search, { + results = await convex.query(internal.memoryRecords.searchInternal, { query: args.query, limit: args.limit, }); } for (const r of results) { - await convex.mutation(api.memoryRecords.markAccessed, { memoryId: r.memoryId }); + await convex.mutation(internal.memoryRecords.markAccessed, { memoryId: r.memoryId }); } - await convex.mutation(api.memoryEvents.emit, { + await convex.mutation(internal.memoryEvents.emit, { eventType: "memory.recalled", conversationId, data: JSON.stringify({ query: args.query, hits: results.length, mode }), diff --git a/server/sendblue.test.ts b/server/sendblue.test.ts new file mode 100644 index 00000000..4780eaed --- /dev/null +++ b/server/sendblue.test.ts @@ -0,0 +1,93 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import express from "express"; +import type { Server } from "node:http"; +import { createSendblueRouter } from "./sendblue.js"; + +const SIGNING_SECRET = "test-signing-secret"; +const FROM_NUMBER = "+15555550100"; +const OTHER_NUMBER = "+15555550999"; + +function sign(body: string): string { + return createHmac("sha256", SIGNING_SECRET).update(body).digest("hex"); +} + +let server: Server; +let baseUrl: string; + +before(async () => { + process.env.SENDBLUE_SIGNING_SECRET = SIGNING_SECRET; + process.env.SENDBLUE_FROM_NUMBER = FROM_NUMBER; + + const app = express(); + app.use("/sendblue", createSendblueRouter()); + await new Promise((resolve) => { + server = app.listen(0, () => resolve()); + }); + const addr = server.address(); + if (typeof addr === "object" && addr) { + baseUrl = `http://127.0.0.1:${addr.port}`; + } else { + throw new Error("server did not bind"); + } +}); + +after(async () => { + await new Promise((resolve) => server.close(() => resolve())); +}); + +describe("sendblue webhook auth", () => { + it("rejects with 401 on missing signature", async () => { + const body = JSON.stringify({ content: "hi", from_number: FROM_NUMBER }); + const res = await fetch(`${baseUrl}/sendblue/webhook`, { + method: "POST", + headers: { "content-type": "application/json" }, + body, + }); + assert.equal(res.status, 401); + }); + + it("rejects with 401 on mismatched signature", async () => { + const body = JSON.stringify({ content: "hi", from_number: FROM_NUMBER }); + const res = await fetch(`${baseUrl}/sendblue/webhook`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-sendblue-signature": "deadbeef".repeat(8), + }, + body, + }); + assert.equal(res.status, 401); + }); + + it("rejects with 403 on wrong from_number", async () => { + const body = JSON.stringify({ content: "hi", from_number: OTHER_NUMBER }); + const res = await fetch(`${baseUrl}/sendblue/webhook`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-sendblue-signature": sign(body), + }, + body, + }); + assert.equal(res.status, 403); + }); + + it("accepts an outbound echo with valid sig (skipped)", async () => { + // is_outbound=true short-circuits to skipped before phone check, so this + // path verifies a happy-case signature with no Convex side effects. + const body = JSON.stringify({ is_outbound: true, content: "hi", from_number: FROM_NUMBER }); + const res = await fetch(`${baseUrl}/sendblue/webhook`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-sendblue-signature": sign(body), + }, + body, + }); + assert.equal(res.status, 200); + const json = (await res.json()) as { skipped?: boolean }; + assert.equal(json.skipped, true); + }); +}); diff --git a/server/sendblue.ts b/server/sendblue.ts index ec6ddf3c..f5debeb7 100644 --- a/server/sendblue.ts +++ b/server/sendblue.ts @@ -1,8 +1,9 @@ import express from "express"; -import { api } from "../convex/_generated/api.js"; +import { internal } from "../convex/_generated/api.js"; import { convex } from "./convex-client.js"; import { handleUserMessage } from "./interaction-agent.js"; import { broadcast } from "./broadcast.js"; +import { verifyHmac } from "./auth.js"; const API_BASE = "https://api.sendblue.com/api"; const MAX_CHUNK = 2900; @@ -122,15 +123,51 @@ export function startTypingLoop(toNumber: string): () => void { export function createSendblueRouter(): express.Router { const router = express.Router(); - router.post("/webhook", async (req, res) => { + // Capture the raw body for HMAC verification. Stored as a Buffer on the + // request via the `verify` hook on a route-scoped JSON parser. We must + // NOT use the globally-installed express.json() parser — we need the raw + // bytes BEFORE JSON parsing happens. + const jsonWithRaw = express.json({ + limit: "2mb", + verify: (req, _res, buf) => { + (req as any).rawBody = buf; + }, + }); + + router.post("/webhook", jsonWithRaw, async (req, res) => { + const signingSecret = process.env.SENDBLUE_SIGNING_SECRET; + const expectedFrom = process.env.SENDBLUE_FROM_NUMBER; + + // Perimeter A check 1: HMAC signature. + if (signingSecret) { + const sig = req.header("x-sendblue-signature") ?? undefined; + const raw = (req as any).rawBody as Buffer | undefined; + const ok = raw && verifyHmac(raw.toString("utf8"), sig, signingSecret); + if (!ok) { + res.status(401).json({ error: "invalid signature" }); + return; + } + } else { + console.warn( + "[sendblue] SENDBLUE_SIGNING_SECRET not set — webhook accepts unsigned requests. " + + "Required for any non-localhost deployment.", + ); + } + const { content, from_number, is_outbound, message_handle } = req.body ?? {}; if (is_outbound || !content || !from_number) { res.json({ ok: true, skipped: true }); return; } + // Perimeter A check 2: phone whitelist. + if (expectedFrom && from_number !== expectedFrom) { + res.status(403).json({ error: "phone not allowed" }); + return; + } + if (message_handle) { - const { claimed } = await convex.mutation(api.sendblueDedup.claim, { + const { claimed } = await convex.mutation(internal.sendblueDedup.claim, { handle: message_handle, }); if (!claimed) { @@ -163,7 +200,7 @@ export function createSendblueRouter(): express.Router { `[turn ${turnTag}] → reply (${elapsed}s, ${reply.length} chars): ${JSON.stringify(replyPreview)}`, ); await sendImessage(from_number, reply); - await convex.mutation(api.messages.send, { + await convex.mutation(internal.messages.send, { conversationId, role: "assistant", content: reply,