From c769fd08b787eaeef9bfd43f1d8ad3501dd61dc8 Mon Sep 17 00:00:00 2001 From: Pycomet Date: Mon, 20 Jul 2026 08:34:14 +0100 Subject: [PATCH] feat(mcp): add MCP server for connecting external AI agents Lets users connect their own AI agent (Claude Code, Cursor, custom agents) to their GrindProof account over the Model Context Protocol. - Personal access tokens: SHA-256 hashed at rest, shown once, revocable, optional expiry (mcp_tokens table + RLS + fixed-window rate-limit RPC) - RLS preserved for external calls by minting a short-lived HS256 JWT (SUPABASE_JWT_SECRET) so auth.uid() scoping still applies - resolveCredential is the single credential->identity boundary, so OAuth can be layered on later without touching transport or tools - Shared transport-neutral tool registry (specs.ts + AI-SDK/MCP adapters): coach keeps its 10 tools; MCP exposes 16 (adds goal write + check-in logging). sanitizeForPrompt output hardening preserved for both surfaces - Goal/daily-check write logic extracted into src/lib/actions and reused by the tRPC routers (behavior-preserving; accountability side effects kept) - Remote Streamable-HTTP endpoint at /api/mcp/mcp (custom auth wrapper for proper 401 vs 429) + "Connect an agent" settings UI (generate/list/revoke) Migration is not yet applied to Supabase (needs `supabase db push`). SUPABASE_JWT_SECRET must be set in .env.local and Vercel. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ksr7x9xqCDazE3RqjCmcEX --- .env.example | 3 + .gitignore | 1 + package-lock.json | 1077 ++++++++++++++++- package.json | 3 + .../lib/actions-daily-checks.test.ts | 125 ++ src/__tests__/lib/actions-goals.test.ts | 88 ++ src/__tests__/lib/env.test.ts | 1 + src/__tests__/lib/mcp-auth.test.ts | 98 ++ src/__tests__/lib/mcp-scoped-client.test.ts | 53 + src/__tests__/lib/mcp-token-router.test.ts | 113 ++ src/__tests__/lib/mcp-token.test.ts | 50 + src/__tests__/lib/tool-specs.test.ts | 54 + src/app/api/mcp/[transport]/route.ts | 98 ++ src/app/dashboard/settings/page.tsx | 238 ++++ src/lib/actions/daily-checks.ts | 206 ++++ src/lib/actions/goals.ts | 106 ++ src/lib/ai/tools.ts | 538 +------- src/lib/env.ts | 5 + src/lib/mcp/auth.ts | 57 + src/lib/mcp/token.ts | 48 + src/lib/supabase/scoped.ts | 62 + src/lib/supabase/types.ts | 55 + src/lib/tools/specs.ts | 585 +++++++++ src/lib/tools/to-ai-sdk.ts | 22 + src/lib/tools/to-mcp.ts | 44 + src/server/trpc/routers/_app.ts | 2 + src/server/trpc/routers/dailyCheck.ts | 134 +- src/server/trpc/routers/goal.ts | 89 +- src/server/trpc/routers/mcpToken.ts | 87 ++ .../20260720000000_add_mcp_tokens.sql | 103 ++ 30 files changed, 3368 insertions(+), 777 deletions(-) create mode 100644 src/__tests__/lib/actions-daily-checks.test.ts create mode 100644 src/__tests__/lib/actions-goals.test.ts create mode 100644 src/__tests__/lib/mcp-auth.test.ts create mode 100644 src/__tests__/lib/mcp-scoped-client.test.ts create mode 100644 src/__tests__/lib/mcp-token-router.test.ts create mode 100644 src/__tests__/lib/mcp-token.test.ts create mode 100644 src/__tests__/lib/tool-specs.test.ts create mode 100644 src/app/api/mcp/[transport]/route.ts create mode 100644 src/lib/actions/daily-checks.ts create mode 100644 src/lib/actions/goals.ts create mode 100644 src/lib/mcp/auth.ts create mode 100644 src/lib/mcp/token.ts create mode 100644 src/lib/supabase/scoped.ts create mode 100644 src/lib/tools/specs.ts create mode 100644 src/lib/tools/to-ai-sdk.ts create mode 100644 src/lib/tools/to-mcp.ts create mode 100644 src/server/trpc/routers/mcpToken.ts create mode 100644 supabase/migrations/20260720000000_add_mcp_tokens.sql diff --git a/.env.example b/.env.example index 654cd7a..52defd0 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,9 @@ NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here +# Supabase → Project Settings → API → JWT Secret (legacy HS256). Used to mint +# short-lived user-scoped tokens for the MCP server so RLS applies. +SUPABASE_JWT_SECRET=your-supabase-jwt-secret-here NODE_ENV=development NEXT_PUBLIC_APP_URL=http://localhost:3000 NEXT_PUBLIC_POSTHOG_KEY= diff --git a/.gitignore b/.gitignore index f39d9b7..5053dfa 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ supabase/.branches/ # superpowers .superpowers/ +docs/superpowers/ # mcp config (contains tokens) .mcp.json diff --git a/package-lock.json b/package-lock.json index d0576f9..c1813db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@ai-sdk/google": "^3.0.56", "@ai-sdk/react": "^3.0.146", + "@modelcontextprotocol/sdk": "^1.29.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", @@ -30,7 +31,9 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "framer-motion": "^12.23.24", + "jose": "^6.2.3", "lucide-react": "^0.553.0", + "mcp-handler": "^1.1.0", "next": "^16.0.10", "posthog-js": "^1.373.5", "posthog-node": "^5.34.2", @@ -2612,6 +2615,18 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2838,6 +2853,85 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@next/env": { "version": "16.0.10", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz", @@ -4226,6 +4320,71 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", + "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -5960,6 +6119,15 @@ "neverthrow": "^7.0.1" } }, + "node_modules/@upstash/qstash/node_modules/jose": { + "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" + } + }, "node_modules/@vercel/oidc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz", @@ -6155,6 +6323,44 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -6742,6 +6948,59 @@ "integrity": "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==", "license": "MIT" }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -6825,6 +7084,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -6848,7 +7116,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6862,7 +7129,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -6991,6 +7257,15 @@ "node": ">=6" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -7055,6 +7330,28 @@ "dev": true, "license": "MIT" }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -7071,6 +7368,15 @@ "node": ">=18" } }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/copy-anything": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", @@ -7111,11 +7417,27 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -7461,6 +7783,15 @@ "node": ">=0.4.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -7532,7 +7863,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -7552,6 +7882,12 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -7592,6 +7928,15 @@ "node": ">= 4" } }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -7692,7 +8037,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7702,7 +8046,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7761,7 +8104,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -7869,6 +8211,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -8435,6 +8783,27 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/eventsource-parser": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", @@ -8454,33 +8823,128 @@ "node": ">=12.0.0" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" }, - "engines": { - "node": ">=8.6.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "node_modules/express-rate-limit": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", + "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, "license": "MIT" @@ -8502,7 +8966,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", - "dev": true, "funding": [ { "type": "github", @@ -8581,6 +9044,27 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-cache-dir": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", @@ -8670,6 +9154,15 @@ "node": ">= 6" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/framer-motion": { "version": "12.23.24", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz", @@ -8697,6 +9190,15 @@ } } }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -8739,7 +9241,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -8786,6 +9287,15 @@ "node": ">= 0.4" } }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -8800,7 +9310,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -8841,7 +9350,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -8996,7 +9504,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9075,7 +9582,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9104,7 +9610,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -9130,6 +9635,15 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hono": { + "version": "4.12.31", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -9159,6 +9673,26 @@ "node": ">=16" } }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -9286,6 +9820,24 @@ "node": ">= 0.4" } }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-arguments": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", @@ -9633,6 +10185,12 @@ "dev": true, "license": "MIT" }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -9824,7 +10382,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -9988,9 +10545,9 @@ } }, "node_modules/jose": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", - "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "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" @@ -10090,6 +10647,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -10631,12 +11194,60 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" } }, + "node_modules/mcp-handler": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mcp-handler/-/mcp-handler-1.1.0.tgz", + "integrity": "sha512-MVCES7g18gcoZy+R/3v5nadkUMzMAWdos8jRl6DyljOKvd2/ZKDmwlCjL6zp4vo+7FeCXOYL1uWinHWlkKAAUg==", + "license": "Apache-2.0", + "dependencies": { + "chalk": "^5.3.0", + "commander": "^11.1.0", + "redis": "^4.6.0" + }, + "bin": { + "create-mcp-route": "dist/cli/index.js", + "mcp-adapter": "dist/cli/index.js", + "mcp-handler": "dist/cli/index.js" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "1.26.0", + "next": ">=13.0.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": false + }, + "next": { + "optional": true + } + } + }, + "node_modules/mcp-handler/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/mcp-handler/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/mdn-data": { "version": "2.0.30", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", @@ -10644,6 +11255,27 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -10811,6 +11443,15 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/neverthrow": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/neverthrow/-/neverthrow-7.2.0.tgz", @@ -10929,7 +11570,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10939,7 +11579,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -11076,11 +11715,22 @@ ], "license": "MIT" }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -11229,6 +11879,15 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -11260,7 +11919,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11273,6 +11931,16 @@ "dev": true, "license": "MIT" }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -11342,6 +12010,15 @@ "node": ">=0.10.0" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -11563,6 +12240,19 @@ "node": ">=12.0.0" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -11586,6 +12276,22 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/query-selector-shadow-dom": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", @@ -11630,6 +12336,50 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", @@ -11765,6 +12515,23 @@ "node": ">=8" } }, + "node_modules/redis": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", + "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", + "license": "MIT", + "workspaces": [ + "./packages/*" + ], + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.6.1", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -11871,7 +12638,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12019,6 +12785,22 @@ "node": ">= 10.13.0" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/rrweb-cssom": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", @@ -12179,6 +12961,57 @@ "semver": "bin/semver.js" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/serialize-javascript": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", @@ -12189,6 +13022,25 @@ "randombytes": "^2.1.0" } }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "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", @@ -12244,6 +13096,12 @@ "node": ">= 0.4" } }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -12306,7 +13164,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -12319,22 +13176,20 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -12346,14 +13201,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -12366,7 +13220,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -12385,7 +13238,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -12502,6 +13354,15 @@ "fast-sha256": "^1.3.0" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -13085,6 +13946,15 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -13215,6 +14085,62 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -13423,6 +14349,15 @@ "node": ">= 10.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/unrs-resolver": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", @@ -13586,6 +14521,15 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", @@ -13946,7 +14890,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -14374,7 +15317,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/ws": { @@ -14430,6 +15372,15 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index 42403d4..2ab0d13 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dependencies": { "@ai-sdk/google": "^3.0.56", "@ai-sdk/react": "^3.0.146", + "@modelcontextprotocol/sdk": "^1.29.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", @@ -36,7 +37,9 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "framer-motion": "^12.23.24", + "jose": "^6.2.3", "lucide-react": "^0.553.0", + "mcp-handler": "^1.1.0", "next": "^16.0.10", "posthog-js": "^1.373.5", "posthog-node": "^5.34.2", diff --git a/src/__tests__/lib/actions-daily-checks.test.ts b/src/__tests__/lib/actions-daily-checks.test.ts new file mode 100644 index 0000000..fe87988 --- /dev/null +++ b/src/__tests__/lib/actions-daily-checks.test.ts @@ -0,0 +1,125 @@ +/** + * Tests for src/lib/actions/daily-checks.ts + * + * @vitest-environment node + * + * The load-bearing assertions here are the accountability side effects: an + * MCP-driven check-in MUST still fire the carry-over RPC, the daily_checks + * marker, the score recompute, and the pattern engine — otherwise streaks and + * scores silently drift. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/accountability/hooks", () => ({ + fireAndForgetScoreChange: vi.fn(), +})); +vi.mock("@/lib/ai/patterns", () => ({ + computeUserPatterns: vi.fn(() => Promise.resolve()), +})); + +import { + recordMorningCheckIn, + recordEveningReflections, + recordTaskReflection, +} from "@/lib/actions/daily-checks"; +import { fireAndForgetScoreChange } from "@/lib/accountability/hooks"; +import { computeUserPatterns } from "@/lib/ai/patterns"; + +function makeDb(rpcResult: { data?: unknown; error?: unknown } = { data: 0, error: null }) { + const builder: Record> = {}; + for (const m of ["insert", "update", "select", "eq"]) { + builder[m] = vi.fn(() => builder); + } + builder.maybeSingle = vi.fn(() => Promise.resolve({ data: { id: "ok" }, error: null })); + (builder as any).then = (onF: any, onR: any) => + Promise.resolve({ data: null, error: null }).then(onF, onR); + const rpc = vi.fn(() => Promise.resolve(rpcResult)); + const from = vi.fn(() => builder); + return { db: { from, rpc } as any, builder, rpc, from }; +} + +beforeEach(() => vi.clearAllMocks()); + +describe("recordMorningCheckIn", () => { + it("carries tasks over, records the marker, and nudges the score", async () => { + const { db, builder, rpc } = makeDb({ data: 2, error: null }); + + const res = await recordMorningCheckIn(db, "u1", { taskIds: ["t1", "t2"] }); + + expect(rpc).toHaveBeenCalledWith( + "carry_over_tasks", + expect.objectContaining({ p_task_ids: ["t1", "t2"] }) + ); + expect(builder.insert).toHaveBeenCalledWith({ user_id: "u1", type: "morning" }); + expect(fireAndForgetScoreChange).toHaveBeenCalledWith(db, "u1", "task_carried_over"); + expect(res).toEqual({ success: true, count: 2 }); + }); + + it("swallows a unique-violation on the marker (idempotent)", async () => { + const { db, builder } = makeDb(); + builder.insert.mockReturnValueOnce({ + then: (onF: any) => Promise.resolve({ error: { code: "23505" } }).then(onF), + } as any); + await expect(recordMorningCheckIn(db, "u1", { taskIds: [] })).resolves.toMatchObject({ + success: true, + }); + }); +}); + +describe("recordEveningReflections", () => { + it("buckets completed/skipped, carries skips, marks evening, recomputes, and runs patterns", async () => { + const { db, builder, rpc } = makeDb({ data: 1, error: null }); + + const res = await recordEveningReflections(db, "u1", { + reflections: [ + { taskId: "t1", status: "completed", reflection: "did it" }, + { taskId: "t2", status: "skipped", reflection: "no time" }, + ], + }); + + // Skipped task carried over via RPC + expect(rpc).toHaveBeenCalledWith( + "carry_over_tasks", + expect.objectContaining({ p_task_ids: ["t2"] }) + ); + // Task rows updated (completion + reflections) and evening marker inserted + expect(builder.update).toHaveBeenCalled(); + expect(builder.insert).toHaveBeenCalledWith({ user_id: "u1", type: "evening" }); + // Accountability side effects fired + expect(fireAndForgetScoreChange).toHaveBeenCalledWith(db, "u1", "evening_reflection"); + expect(computeUserPatterns).toHaveBeenCalledWith(db, "u1"); + expect(res).toEqual({ success: true, completedCount: 1, skippedCount: 1 }); + }); + + it("throws if carry-over fails", async () => { + const { db } = makeDb({ data: null, error: { message: "rpc down" } }); + await expect( + recordEveningReflections(db, "u1", { + reflections: [{ taskId: "t2", status: "skipped" }], + }) + ).rejects.toThrow("Failed to carry over tasks: rpc down"); + }); +}); + +describe("recordTaskReflection", () => { + it("updates a single task and nudges score when a terminal status is set", async () => { + const { db, builder } = makeDb(); + const res = await recordTaskReflection(db, "u1", { + taskId: "t1", + reflection: "done", + status: "completed", + }); + expect(builder.update).toHaveBeenCalledWith( + expect.objectContaining({ reflection: "done", status: "completed" }) + ); + expect(fireAndForgetScoreChange).toHaveBeenCalledWith(db, "u1", "evening_reflection"); + expect(res).toEqual({ success: true, taskId: "t1" }); + }); + + it("is a no-op (no write) when nothing is provided", async () => { + const { db, from } = makeDb(); + const res = await recordTaskReflection(db, "u1", { taskId: "t1" }); + expect(from).not.toHaveBeenCalled(); + expect(res).toEqual({ success: true, taskId: "t1" }); + }); +}); diff --git a/src/__tests__/lib/actions-goals.test.ts b/src/__tests__/lib/actions-goals.test.ts new file mode 100644 index 0000000..544ce44 --- /dev/null +++ b/src/__tests__/lib/actions-goals.test.ts @@ -0,0 +1,88 @@ +/** + * Tests for src/lib/actions/goals.ts + * + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createGoal, updateGoal, deleteGoal } from "@/lib/actions/goals"; + +/** Minimal chainable Supabase builder that resolves to `result`. */ +function makeDb(result: { data?: unknown; error?: unknown }) { + const builder: Record> = {}; + for (const m of ["insert", "update", "delete", "select", "eq", "order"]) { + builder[m] = vi.fn(() => builder); + } + builder.maybeSingle = vi.fn(() => Promise.resolve(result)); + // Make the builder awaitable for chains that end without maybeSingle (delete). + (builder as any).then = (onF: any, onR: any) => + Promise.resolve(result).then(onF, onR); + const from = vi.fn(() => builder); + return { db: { from } as any, builder, from }; +} + +const dbRow = { + id: "g1", + user_id: "u1", + title: "Ship MCP", + description: "desc", + status: "active", + priority: "high", + created_at: "2026-07-20T00:00:00Z", + updated_at: "2026-07-20T00:00:00Z", +}; + +beforeEach(() => vi.clearAllMocks()); + +describe("createGoal", () => { + it("inserts with user_id and returns the mapped goal", async () => { + const { db, builder, from } = makeDb({ data: dbRow, error: null }); + + const goal = await createGoal(db, "u1", { + title: "Ship MCP", + status: "active", + priority: "high", + } as any); + + expect(from).toHaveBeenCalledWith("goals"); + expect(builder.insert).toHaveBeenCalledWith( + expect.objectContaining({ user_id: "u1", title: "Ship MCP", status: "active", priority: "high" }) + ); + expect(goal).toMatchObject({ id: "g1", userId: "u1", title: "Ship MCP", status: "active" }); + expect(goal.createdAt).toBeInstanceOf(Date); + }); + + it("throws when the insert errors", async () => { + const { db } = makeDb({ data: null, error: { message: "nope" } }); + await expect(createGoal(db, "u1", { title: "x" } as any)).rejects.toThrow("Failed to create goal: nope"); + }); +}); + +describe("updateGoal", () => { + it("applies only provided fields, scoped by id + user_id", async () => { + const { db, builder } = makeDb({ data: dbRow, error: null }); + + await updateGoal(db, "u1", { id: "g1", title: "New" } as any); + + expect(builder.update).toHaveBeenCalledWith({ title: "New" }); + expect(builder.eq).toHaveBeenCalledWith("id", "g1"); + expect(builder.eq).toHaveBeenCalledWith("user_id", "u1"); + }); + + it("throws 'not found' when no row is returned", async () => { + const { db } = makeDb({ data: null, error: null }); + await expect(updateGoal(db, "u1", { id: "missing" } as any)).rejects.toThrow( + "Goal not found or access denied" + ); + }); +}); + +describe("deleteGoal", () => { + it("deletes by id + user_id and returns the id", async () => { + const { db, builder } = makeDb({ error: null }); + const res = await deleteGoal(db, "u1", "g1"); + expect(builder.delete).toHaveBeenCalled(); + expect(builder.eq).toHaveBeenCalledWith("id", "g1"); + expect(builder.eq).toHaveBeenCalledWith("user_id", "u1"); + expect(res).toEqual({ success: true, id: "g1" }); + }); +}); diff --git a/src/__tests__/lib/env.test.ts b/src/__tests__/lib/env.test.ts index 16e6b4b..ed7e82e 100644 --- a/src/__tests__/lib/env.test.ts +++ b/src/__tests__/lib/env.test.ts @@ -36,6 +36,7 @@ describe("env – server-side validation", () => { NEXT_PUBLIC_SUPABASE_URL: "https://abc.supabase.co", NEXT_PUBLIC_SUPABASE_ANON_KEY: "anon-key-value", SUPABASE_SERVICE_ROLE_KEY: "service-role-key-value", + SUPABASE_JWT_SECRET: "jwt-secret-value", NEXT_GOOGLE_GEMINI_API_KEY: "gemini-key-value", CRON_SECRET: "super-secret", RESEND_API_KEY: "re_test_key", diff --git a/src/__tests__/lib/mcp-auth.test.ts b/src/__tests__/lib/mcp-auth.test.ts new file mode 100644 index 0000000..d2257f9 --- /dev/null +++ b/src/__tests__/lib/mcp-auth.test.ts @@ -0,0 +1,98 @@ +/** + * Tests for src/lib/mcp/auth.ts — the credential→identity boundary. + * + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { generateMcpToken, hashToken } from "@/lib/mcp/token"; + +const maybeSingleMock = vi.fn(); +const selectEqMock = vi.fn(() => ({ maybeSingle: maybeSingleMock })); +const selectMock = vi.fn(() => ({ eq: selectEqMock })); +const updateEqMock = vi.fn(() => Promise.resolve({ data: null, error: null })); +const updateMock = vi.fn(() => ({ eq: updateEqMock })); +const fromMock = vi.fn((..._args: unknown[]) => ({ + select: selectMock, + update: updateMock, +})); + +vi.mock("@/lib/supabase/server", () => ({ + // Lazy indirection: the factory is hoisted above the const declarations, so + // it must not read `fromMock` until `.from()` is actually invoked at runtime. + supabaseAdmin: { from: (...args: unknown[]) => fromMock(...args) }, +})); + +import { resolveCredential } from "@/lib/mcp/auth"; + +const VALID = generateMcpToken().token; + +const activeRow = { + id: "tok-1", + user_id: "user-1", + scopes: ["*"], + expires_at: null, + revoked_at: null, +}; + +beforeEach(() => { + vi.clearAllMocks(); + updateEqMock.mockReturnValue(Promise.resolve({ data: null, error: null })); +}); + +describe("resolveCredential", () => { + it("returns identity for a valid active token and stamps last_used_at", async () => { + maybeSingleMock.mockResolvedValue({ data: activeRow, error: null }); + + const identity = await resolveCredential(VALID); + + expect(identity).toEqual({ userId: "user-1", tokenId: "tok-1", scopes: ["*"] }); + // Looked up by hash of the presented token + expect(selectEqMock).toHaveBeenCalledWith("token_hash", hashToken(VALID)); + // Fire-and-forget usage stamp issued + expect(updateMock).toHaveBeenCalledTimes(1); + expect(updateEqMock).toHaveBeenCalledWith("id", "tok-1"); + }); + + it("returns null for an unknown token (no row)", async () => { + maybeSingleMock.mockResolvedValue({ data: null, error: null }); + expect(await resolveCredential(VALID)).toBeNull(); + expect(updateMock).not.toHaveBeenCalled(); + }); + + it("returns null for a revoked token", async () => { + maybeSingleMock.mockResolvedValue({ + data: { ...activeRow, revoked_at: new Date().toISOString() }, + error: null, + }); + expect(await resolveCredential(VALID)).toBeNull(); + expect(updateMock).not.toHaveBeenCalled(); + }); + + it("returns null for an expired token", async () => { + maybeSingleMock.mockResolvedValue({ + data: { ...activeRow, expires_at: new Date(Date.now() - 1000).toISOString() }, + error: null, + }); + expect(await resolveCredential(VALID)).toBeNull(); + }); + + it("accepts a token whose expiry is in the future", async () => { + maybeSingleMock.mockResolvedValue({ + data: { ...activeRow, expires_at: new Date(Date.now() + 60_000).toISOString() }, + error: null, + }); + const identity = await resolveCredential(VALID); + expect(identity?.userId).toBe("user-1"); + }); + + it("returns null for a malformed token without hitting the DB", async () => { + expect(await resolveCredential("not-a-real-token")).toBeNull(); + expect(await resolveCredential(undefined)).toBeNull(); + expect(fromMock).not.toHaveBeenCalled(); + }); + + it("returns null when the lookup errors", async () => { + maybeSingleMock.mockResolvedValue({ data: null, error: { message: "boom" } }); + expect(await resolveCredential(VALID)).toBeNull(); + }); +}); diff --git a/src/__tests__/lib/mcp-scoped-client.test.ts b/src/__tests__/lib/mcp-scoped-client.test.ts new file mode 100644 index 0000000..c711020 --- /dev/null +++ b/src/__tests__/lib/mcp-scoped-client.test.ts @@ -0,0 +1,53 @@ +/** + * Tests for src/lib/supabase/scoped.ts + * + * @vitest-environment node + * + * The minted token is what makes RLS apply to MCP requests, so we assert its + * claims precisely: HS256, sub = userId, role/aud = "authenticated", and a + * short TTL. The signing secret is the env test-default. + */ +import { jwtVerify, decodeProtectedHeader, errors as joseErrors } from "jose"; +import { mintUserAccessToken } from "@/lib/supabase/scoped"; + +const TEST_SECRET = new TextEncoder().encode( + "test-jwt-secret-at-least-32-chars-long!!" +); + +describe("mintUserAccessToken", () => { + it("signs an HS256 token with the Supabase-authenticated claims", async () => { + const iat = 1_700_000_000; + const token = await mintUserAccessToken("user-123", iat); + + expect(decodeProtectedHeader(token).alg).toBe("HS256"); + + const { payload } = await jwtVerify(token, TEST_SECRET, { + audience: "authenticated", + currentDate: new Date(iat * 1000), + }); + expect(payload.sub).toBe("user-123"); + expect(payload.role).toBe("authenticated"); + expect(payload.aud).toBe("authenticated"); + expect(payload.iat).toBe(iat); + expect(payload.exp).toBe(iat + 300); + }); + + it("produces a token that fails verification under a different secret", async () => { + const token = await mintUserAccessToken("user-123"); + await expect( + jwtVerify(token, new TextEncoder().encode("a-totally-different-secret-value!!!")) + ).rejects.toBeInstanceOf(joseErrors.JWSSignatureVerificationFailed); + }); + + it("scopes the token to the given user (sub differs per user)", async () => { + const iat = 1_700_000_000; + const a = await mintUserAccessToken("user-a", iat); + const b = await mintUserAccessToken("user-b", iat); + expect(a).not.toEqual(b); + const opts = { currentDate: new Date(iat * 1000) }; + const { payload: pa } = await jwtVerify(a, TEST_SECRET, opts); + const { payload: pb } = await jwtVerify(b, TEST_SECRET, opts); + expect(pa.sub).toBe("user-a"); + expect(pb.sub).toBe("user-b"); + }); +}); diff --git a/src/__tests__/lib/mcp-token-router.test.ts b/src/__tests__/lib/mcp-token-router.test.ts new file mode 100644 index 0000000..518e630 --- /dev/null +++ b/src/__tests__/lib/mcp-token-router.test.ts @@ -0,0 +1,113 @@ +/** + * Tests for src/server/trpc/routers/mcpToken.ts + * + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mcpTokenRouter } from "@/server/trpc/routers/mcpToken"; + +function makeCaller(result: { single?: unknown; awaited?: unknown }) { + const builder: Record> = {}; + for (const m of ["insert", "update", "select", "eq", "is", "order"]) { + builder[m] = vi.fn(() => builder); + } + builder.single = vi.fn(() => + Promise.resolve(result.single ?? { data: null, error: null }) + ); + (builder as any).then = (onF: any, onR: any) => + Promise.resolve(result.awaited ?? { data: [], error: null }).then(onF, onR); + const db = { from: vi.fn(() => builder) }; + const ctx = { db, user: { id: "u1" } } as any; + return { caller: mcpTokenRouter.createCaller(ctx), builder, db }; +} + +beforeEach(() => vi.clearAllMocks()); + +describe("mcpToken.create", () => { + it("returns the plaintext token once and stores only its hash", async () => { + const { caller, builder } = makeCaller({ + single: { + data: { + id: "tok-1", + name: "laptop", + token_prefix: "gp_mcp_ab12", + expires_at: null, + created_at: "2026-07-20T00:00:00Z", + }, + error: null, + }, + }); + + const res = await caller.create({ name: "laptop" }); + + expect(res.token).toMatch(/^gp_mcp_/); + expect(res.id).toBe("tok-1"); + + const insertArg = builder.insert.mock.calls[0][0] as Record; + expect(insertArg).toMatchObject({ user_id: "u1", name: "laptop", scopes: ["*"] }); + // Stored value is the hash, never the plaintext. + expect(insertArg.token_hash).toMatch(/^[0-9a-f]{64}$/); + expect(insertArg.token_hash).not.toBe(res.token); + expect(insertArg).not.toHaveProperty("token"); + }); + + it("sets expires_at when expiresInDays is provided", async () => { + const { caller, builder } = makeCaller({ + single: { + data: { + id: "tok-2", + name: "ci", + token_prefix: "gp_mcp_cd34", + expires_at: "2026-10-18T00:00:00Z", + created_at: "2026-07-20T00:00:00Z", + }, + error: null, + }, + }); + + await caller.create({ name: "ci", expiresInDays: 90 }); + const insertArg = builder.insert.mock.calls[0][0] as Record; + expect(insertArg.expires_at).toEqual(expect.any(String)); + }); +}); + +describe("mcpToken.revoke", () => { + it("stamps revoked_at scoped to id + user", async () => { + const { caller, builder } = makeCaller({}); + const res = await caller.revoke({ id: "tok-1" }); + + expect(builder.update).toHaveBeenCalledWith( + expect.objectContaining({ revoked_at: expect.any(String) }) + ); + expect(builder.eq).toHaveBeenCalledWith("id", "tok-1"); + expect(builder.eq).toHaveBeenCalledWith("user_id", "u1"); + expect(res).toEqual({ success: true, id: "tok-1" }); + }); +}); + +describe("mcpToken.list", () => { + it("returns non-revoked tokens mapped for display", async () => { + const { caller, builder } = makeCaller({ + awaited: { + data: [ + { + id: "tok-1", + name: "laptop", + token_prefix: "gp_mcp_ab12", + scopes: ["*"], + last_used_at: null, + expires_at: null, + created_at: "2026-07-20T00:00:00Z", + }, + ], + error: null, + }, + }); + + const res = await caller.list(); + expect(res).toHaveLength(1); + expect(res[0]).toMatchObject({ id: "tok-1", name: "laptop", prefix: "gp_mcp_ab12" }); + // Filtered to active tokens only. + expect(builder.is).toHaveBeenCalledWith("revoked_at", null); + }); +}); diff --git a/src/__tests__/lib/mcp-token.test.ts b/src/__tests__/lib/mcp-token.test.ts new file mode 100644 index 0000000..7e143e5 --- /dev/null +++ b/src/__tests__/lib/mcp-token.test.ts @@ -0,0 +1,50 @@ +/** + * Tests for src/lib/mcp/token.ts + * + * @vitest-environment node + */ +import { createHash } from "crypto"; +import { + MCP_TOKEN_PREFIX, + generateMcpToken, + hashToken, + looksLikeMcpToken, +} from "@/lib/mcp/token"; + +describe("generateMcpToken", () => { + it("produces a prefixed token whose hash and prefix match", () => { + const { token, hash, prefix } = generateMcpToken(); + + expect(token.startsWith(MCP_TOKEN_PREFIX)).toBe(true); + expect(hash).toBe(createHash("sha256").update(token).digest("hex")); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + expect(token.startsWith(prefix)).toBe(true); + expect(prefix.length).toBe(12); + }); + + it("generates unique tokens across calls", () => { + const seen = new Set( + Array.from({ length: 200 }, () => generateMcpToken().token) + ); + expect(seen.size).toBe(200); + }); +}); + +describe("hashToken", () => { + it("is deterministic", () => { + expect(hashToken("gp_mcp_abc")).toBe(hashToken("gp_mcp_abc")); + expect(hashToken("gp_mcp_abc")).not.toBe(hashToken("gp_mcp_abd")); + }); +}); + +describe("looksLikeMcpToken", () => { + it("accepts real generated tokens", () => { + expect(looksLikeMcpToken(generateMcpToken().token)).toBe(true); + }); + + it("rejects wrong prefix or too-short strings", () => { + expect(looksLikeMcpToken("sk_live_1234567890abcdef")).toBe(false); + expect(looksLikeMcpToken("gp_mcp_short")).toBe(false); + expect(looksLikeMcpToken("")).toBe(false); + }); +}); diff --git a/src/__tests__/lib/tool-specs.test.ts b/src/__tests__/lib/tool-specs.test.ts new file mode 100644 index 0000000..69bd3ca --- /dev/null +++ b/src/__tests__/lib/tool-specs.test.ts @@ -0,0 +1,54 @@ +/** + * Tests for src/lib/tools/specs.ts — the shared tool registry. + * + * @vitest-environment node + */ +import { describe, expect, it } from "vitest"; +import { + coreToolDefs, + goalWriteToolDefs, + checkInToolDefs, + allToolDefs, +} from "@/lib/tools/specs"; + +describe("tool registry", () => { + it("composes the full MCP surface from core + goal-write + check-in", () => { + expect(coreToolDefs()).toHaveLength(10); + expect(goalWriteToolDefs()).toHaveLength(3); + expect(checkInToolDefs()).toHaveLength(3); + expect(allToolDefs()).toHaveLength(16); + }); + + it("gives every def a unique name and an object input schema", () => { + const defs = allToolDefs(); + const names = defs.map((d) => d.name); + expect(new Set(names).size).toBe(names.length); + for (const d of defs) { + // A ZodObject exposes `.shape` — required by the MCP adapter. + expect(d.inputSchema.shape).toBeDefined(); + expect(typeof d.description).toBe("string"); + } + }); + + it("marks read-only tools with readOnlyHint", () => { + const byName = Object.fromEntries(allToolDefs().map((d) => [d.name, d])); + for (const name of [ + "list_tasks", + "list_goals", + "get_accountability_score", + "get_reflection_history", + "get_task_history", + ]) { + expect(byName[name].annotations?.readOnlyHint).toBe(true); + } + }); + + it("marks destructive tools with destructiveHint", () => { + const byName = Object.fromEntries(allToolDefs().map((d) => [d.name, d])); + for (const name of ["delete_task", "delete_goal", "record_evening_checkin"]) { + expect(byName[name].annotations?.destructiveHint).toBe(true); + } + // Read-only tools must not be flagged destructive. + expect(byName["list_tasks"].annotations?.destructiveHint).toBeUndefined(); + }); +}); diff --git a/src/app/api/mcp/[transport]/route.ts b/src/app/api/mcp/[transport]/route.ts new file mode 100644 index 0000000..af48d24 --- /dev/null +++ b/src/app/api/mcp/[transport]/route.ts @@ -0,0 +1,98 @@ +import { createMcpHandler } from "mcp-handler"; +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; +import { resolveCredential } from "@/lib/mcp/auth"; +import { createUserScopedClient } from "@/lib/supabase/scoped"; +import { supabaseAdmin } from "@/lib/supabase/server"; +import { registerMcpTools } from "@/lib/tools/to-mcp"; +import { allToolDefs, type ToolContext } from "@/lib/tools/specs"; + +/** + * Remote MCP server. Users connect their own AI agent (Claude Code, Cursor, + * custom agents) by pasting a personal access token; the agent then drives + * their GrindProof data as them. + * + * The dynamic [transport] segment is required because `/api/[trpc]` already + * occupies a sibling dynamic segment directly under /api. With basePath + * "/api/mcp", the Streamable-HTTP endpoint users paste is `/api/mcp/mcp`. + * + * Auth + rate limiting run in a thin wrapper (not withMcpAuth) so we can return + * proper 401 vs 429 status codes. On success we set `req.auth` — the shape the + * MCP SDK reads and exposes to each tool handler as `extra.authInfo`. + */ + +export const runtime = "nodejs"; +export const maxDuration = 60; + +// Fixed-window per-token limit. Generous for interactive agent use; the real +// backstop is that only an authenticated token holder can reach this at all. +const RATE_LIMIT_WINDOW_SECONDS = 60; +const RATE_LIMIT_MAX = 120; + +/** + * Per-call context resolution: read the userId the wrapper stamped into + * req.auth, and build a fresh RLS-scoped client for exactly this call. This is + * the only identity→context step; OAuth later would change only how req.auth is + * populated, not this. + */ +async function resolveToolContext(extra: unknown): Promise { + const authInfo = (extra as { authInfo?: AuthInfo } | undefined)?.authInfo; + const userId = (authInfo?.extra as { userId?: string } | undefined)?.userId; + if (!userId) throw new Error("Unauthenticated MCP tool call"); + const supabase = await createUserScopedClient(userId); + return { userId, supabase }; +} + +const baseHandler = createMcpHandler( + (server) => { + registerMcpTools(server, allToolDefs(), resolveToolContext); + }, + { serverInfo: { name: "grindproof", version: "1.0.0" } }, + { basePath: "/api/mcp", maxDuration: 60, verboseLogs: false } +); + +function bearerFrom(req: Request): string | undefined { + const header = req.headers.get("authorization"); + if (!header) return undefined; + const [scheme, ...rest] = header.split(" "); + if (scheme.toLowerCase() !== "bearer") return undefined; + return rest.join(" ").trim() || undefined; +} + +function jsonError(status: number, message: string): Response { + return new Response(JSON.stringify({ error: message }), { + status, + headers: { "content-type": "application/json" }, + }); +} + +async function handler(req: Request): Promise { + const bearer = bearerFrom(req); + const identity = await resolveCredential(bearer); + if (!identity) { + return jsonError(401, "Invalid or missing GrindProof access token"); + } + + const { data: allowed, error } = await supabaseAdmin.rpc("mcp_touch_rate_limit", { + p_token_id: identity.tokenId, + p_window_seconds: RATE_LIMIT_WINDOW_SECONDS, + p_max: RATE_LIMIT_MAX, + }); + // Fail open on limiter errors (availability over strictness for v1); block + // only on an explicit "not allowed". + if (!error && allowed === false) { + return jsonError(429, "Rate limit exceeded. Try again shortly."); + } + + // The MCP SDK reads req.auth and surfaces it to tool handlers as + // extra.authInfo. We stash userId/tokenId in `extra` for resolveToolContext. + (req as Request & { auth?: AuthInfo }).auth = { + token: bearer as string, + clientId: identity.userId, + scopes: identity.scopes, + extra: { userId: identity.userId, tokenId: identity.tokenId }, + }; + + return baseHandler(req); +} + +export { handler as GET, handler as POST }; diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index 056ef65..b1d1943 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -372,6 +372,243 @@ function AccountSection() { ); } +function ConnectAgentSection() { + const utils = trpc.useUtils(); + const { data: tokens, isLoading } = trpc.mcpToken.list.useQuery(); + const [name, setName] = useState(""); + const [expiry, setExpiry] = useState<"never" | "90" | "365">("never"); + const [dialogOpen, setDialogOpen] = useState(false); + const [revealed, setRevealed] = useState<{ token: string; name: string } | null>(null); + const [copied, setCopied] = useState<"token" | "config" | null>(null); + const [revokeTarget, setRevokeTarget] = useState<{ id: string; name: string } | null>(null); + + const createToken = trpc.mcpToken.create.useMutation({ + onSuccess: (data) => { + setRevealed({ token: data.token, name: data.name }); + setName(""); + setExpiry("never"); + setDialogOpen(false); + utils.mcpToken.list.invalidate(); + }, + }); + + const revokeToken = trpc.mcpToken.revoke.useMutation({ + onSuccess: () => { + setRevokeTarget(null); + utils.mcpToken.list.invalidate(); + }, + }); + + const endpoint = + (typeof window !== "undefined" ? window.location.origin : "https://grindproof.co") + + "/api/mcp/mcp"; + + const configSnippet = revealed + ? `{ + "mcpServers": { + "grindproof": { + "url": "${endpoint}", + "headers": { "Authorization": "Bearer ${revealed.token}" } + } + } +}` + : ""; + + async function copy(text: string, which: "token" | "config") { + try { + await navigator.clipboard.writeText(text); + setCopied(which); + setTimeout(() => setCopied(null), 1500); + } catch { + // Clipboard may be unavailable (e.g. insecure context); ignore. + } + } + + return ( +
+
+

+ Connect an agent +

+

+ Generate a token to connect your own AI agent (Claude Code, Cursor, custom + agents) to your GrindProof account over MCP. +

+
+ + {/* One-time reveal of a freshly created token. */} + {revealed && ( +
+

+ Token created — copy it now. You won't be able to see it again. +

+
+ + {revealed.token} + + +
+
+
+ MCP client config + +
+
+              {configSnippet}
+            
+
+ +
+ )} + + {/* Existing tokens */} + {isLoading ? ( + + ) : tokens && tokens.length > 0 ? ( +
    + {tokens.map((t) => ( +
  • +
    +

    {t.name}

    +

    + {t.prefix}… ·{" "} + {t.lastUsedAt + ? `last used ${new Date(t.lastUsedAt).toLocaleDateString()}` + : "never used"} + {t.expiresAt + ? ` · expires ${new Date(t.expiresAt).toLocaleDateString()}` + : ""} +

    +
    + +
  • + ))} +
+ ) : ( +

No connected agents yet.

+ )} + + {/* Generate token dialog */} + + + + + + + Generate an access token + + Give it a name so you can recognize it later, then paste it into your MCP + client. + + +
+
+ + setName(e.target.value)} + placeholder="Claude Code laptop" + maxLength={100} + /> +
+
+ + +
+ {createToken.isError && ( +

Failed to create token. Please try again.

+ )} +
+ + + + + + +
+
+ + {/* Revoke confirmation */} + !open && setRevokeTarget(null)}> + + + Revoke token + + Any agent using “{revokeTarget?.name}” will immediately lose access. This + cannot be undone. + + + {revokeToken.isError && ( +

Failed to revoke. Please try again.

+ )} + + + + + + +
+
+
+ ); +} + function ToggleSwitch({ checked, onChange, @@ -448,6 +685,7 @@ export default function SettingsPage() { + diff --git a/src/lib/actions/daily-checks.ts b/src/lib/actions/daily-checks.ts new file mode 100644 index 0000000..2476f60 --- /dev/null +++ b/src/lib/actions/daily-checks.ts @@ -0,0 +1,206 @@ +import { z } from "zod"; +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { Database } from "@/lib/supabase/types"; +import { computeUserPatterns } from "@/lib/ai/patterns"; +import { fireAndForgetScoreChange } from "@/lib/accountability/hooks"; + +/** + * Check-in / reflection write actions, shared by the tRPC dailyCheck router and + * the MCP server. These carry the accountability side effects — the carry-over + * RPC, the daily_checks marker, score recompute, and the pattern engine — so + * that an MCP-driven check-in keeps streaks and scores in sync exactly like the + * in-app flow. (Analytics/funnel events stay in the router, where the full user + * session is available.) + */ + +export const eveningReflectionsSchema = z.object({ + reflections: z.array( + z.object({ + taskId: z.string(), + status: z.enum(["completed", "skipped"]), + reflection: z.string().max(2000).optional(), + }) + ), +}); +export type EveningReflectionsInput = z.infer; + +export const morningCheckInSchema = z.object({ + taskIds: z.array(z.string()).max(100).default([]), +}); +export type MorningCheckInInput = z.infer; + +export const taskReflectionSchema = z.object({ + taskId: z.string(), + reflection: z.string().max(2000).optional(), + status: z.enum(["completed", "skipped", "pending"]).optional(), +}); +export type TaskReflectionInput = z.infer; + +/** Swallow a unique-violation (idempotent daily_checks marker); rethrow else. */ +function isUniqueViolation(err: { code?: string } | null): boolean { + return err?.code === "23505"; +} + +/** + * Morning check-in: carry yesterday's incomplete tasks forward (atomic RPC), + * record the morning marker (idempotent), and nudge the score. Mirrors + * dailyCheckRouter.carryOverTasks. + */ +export async function recordMorningCheckIn( + db: SupabaseClient, + userId: string, + input: MorningCheckInInput +): Promise<{ success: true; count: number }> { + const today = new Date(); + today.setHours(12, 0, 0, 0); + + // Atomic single-statement carry-over via Postgres RPC (RLS scopes user_id). + // Called unconditionally — an empty task list is a no-op that returns 0. + const { data: count, error } = await db.rpc("carry_over_tasks", { + p_task_ids: input.taskIds, + p_new_due: today.toISOString(), + }); + if (error) throw new Error(`Failed to carry over tasks: ${error.message}`); + const carriedOver = count ?? 0; + + const { error: morningErr } = await db + .from("daily_checks") + .insert({ user_id: userId, type: "morning" }); + if (morningErr && !isUniqueViolation(morningErr)) { + throw new Error(`Failed to record check-in: ${morningErr.message}`); + } + + fireAndForgetScoreChange(db, userId, "task_carried_over"); + + return { success: true, count: carriedOver }; +} + +/** + * Evening reality-check: apply per-task completed/skipped status + reflections, + * carry skipped tasks forward, record the evening marker, recompute score, and + * run the pattern engine. Mirrors dailyCheckRouter.submitEveningReflections + * (minus the funnel analytics, which stay in the router). + */ +export async function recordEveningReflections( + db: SupabaseClient, + userId: string, + input: EveningReflectionsInput +): Promise<{ success: true; completedCount: number; skippedCount: number }> { + const failures: string[] = []; + + const tomorrow = new Date(); + tomorrow.setHours(0, 0, 0, 0); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(12, 0, 0, 0); + + const skipIds: string[] = []; + const skipReflections: Record = {}; + const completedIds: string[] = []; + const completedReflections: Record = {}; + + for (const item of input.reflections) { + if (item.status === "skipped") { + skipIds.push(item.taskId); + if (item.reflection) skipReflections[item.taskId] = item.reflection; + } else { + completedIds.push(item.taskId); + if (item.reflection) completedReflections[item.taskId] = item.reflection; + } + } + + if (skipIds.length > 0) { + const { error: rpcErr } = await db.rpc("carry_over_tasks", { + p_task_ids: skipIds, + p_new_due: tomorrow.toISOString(), + }); + if (rpcErr) throw new Error(`Failed to carry over tasks: ${rpcErr.message}`); + + for (const id of skipIds) { + if (skipReflections[id]) { + const { error } = await db + .from("tasks") + .update({ reflection: skipReflections[id] }) + .eq("id", id) + .eq("user_id", userId); + if (error) failures.push(id); + } + } + } + + const nowIso = new Date().toISOString(); + for (const id of completedIds) { + const update: Record = { + status: "completed", + completed_at: nowIso, + }; + if (completedReflections[id]) update.reflection = completedReflections[id]; + const { error } = await db + .from("tasks") + .update(update) + .eq("id", id) + .eq("user_id", userId); + if (error) failures.push(id); + } + + if (failures.length > 0) { + throw new Error(`Failed to update tasks: ${failures.join(", ")}`); + } + + const { error: eveningErr } = await db + .from("daily_checks") + .insert({ user_id: userId, type: "evening" }); + if (eveningErr && !isUniqueViolation(eveningErr)) { + throw new Error(`Failed to record check-in: ${eveningErr.message}`); + } + + fireAndForgetScoreChange(db, userId, "evening_reflection"); + + computeUserPatterns(db, userId).catch((err) => + console.error("Pattern engine error:", err) + ); + + return { + success: true, + completedCount: completedIds.length, + skippedCount: skipIds.length, + }; +} + +/** + * Record a reflection (and optionally a new status) on a single task. A lighter + * tool than the full evening flow — used when an agent logs one task at a time. + * If a terminal status is set, the score is nudged to stay consistent. + */ +export async function recordTaskReflection( + db: SupabaseClient, + userId: string, + input: TaskReflectionInput +): Promise<{ success: true; taskId: string }> { + const update: Record = {}; + if (input.reflection !== undefined) update.reflection = input.reflection; + if (input.status !== undefined) { + update.status = input.status; + if (input.status === "completed") update.completed_at = new Date().toISOString(); + } + + if (Object.keys(update).length === 0) { + return { success: true, taskId: input.taskId }; + } + + const { data, error } = await db + .from("tasks") + .update(update) + .eq("id", input.taskId) + .eq("user_id", userId) + .select("id") + .maybeSingle(); + + if (error) throw new Error(`Failed to update task: ${error.message}`); + if (!data) throw new Error("Task not found or access denied"); + + if (input.status && input.status !== "pending") { + fireAndForgetScoreChange(db, userId, "evening_reflection"); + } + + return { success: true, taskId: input.taskId }; +} diff --git a/src/lib/actions/goals.ts b/src/lib/actions/goals.ts new file mode 100644 index 0000000..30c8597 --- /dev/null +++ b/src/lib/actions/goals.ts @@ -0,0 +1,106 @@ +import { z } from "zod"; +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { Database } from "@/lib/supabase/types"; + +/** + * Goal write actions, shared by the tRPC goal router and the MCP server so both + * surfaces stay in lockstep. Each takes an explicit (db, userId) — the caller + * supplies a client already scoped to the user (cookie-based for tRPC, minted + * JWT for MCP), and every query additionally filters by user_id as belt-and- + * suspenders alongside RLS. + */ + +export const createGoalSchema = z.object({ + title: z.string().min(1, "Title is required").max(200), + description: z.string().max(1000).optional(), + status: z.enum(["active", "completed"]).default("active"), + priority: z.enum(["high", "medium", "low"]).default("medium"), +}); + +export const updateGoalSchema = z.object({ + id: z.string(), + title: z.string().min(1).max(200).optional(), + description: z.string().max(1000).optional().nullable(), + status: z.enum(["active", "completed"]).optional(), + priority: z.enum(["high", "medium", "low"]).optional(), +}); + +export type CreateGoalInput = z.infer; +export type UpdateGoalInput = z.infer; + +type GoalRow = Database["public"]["Tables"]["goals"]["Row"]; + +export function mapGoalFromDb(goal: GoalRow) { + return { + id: goal.id, + userId: goal.user_id, + title: goal.title, + description: goal.description || null, + status: goal.status as "active" | "completed", + priority: goal.priority as "high" | "medium" | "low", + createdAt: new Date(goal.created_at), + updatedAt: new Date(goal.updated_at), + }; +} + +export async function createGoal( + db: SupabaseClient, + userId: string, + input: CreateGoalInput +) { + const { data, error } = await db + .from("goals") + .insert({ + user_id: userId, + title: input.title, + description: input.description || null, + status: input.status || "active", + priority: input.priority || "medium", + }) + .select() + .maybeSingle(); + + if (error) throw new Error(`Failed to create goal: ${error.message}`); + if (!data) throw new Error("Failed to create goal: No data returned"); + return mapGoalFromDb(data); +} + +export async function updateGoal( + db: SupabaseClient, + userId: string, + input: UpdateGoalInput +) { + const updateData: Record = {}; + if (input.title !== undefined) updateData.title = input.title; + if (input.description !== undefined) + updateData.description = input.description || null; + if (input.status !== undefined) updateData.status = input.status; + if (input.priority !== undefined) updateData.priority = input.priority; + + const { data, error } = await db + .from("goals") + .update(updateData) + .eq("id", input.id) + .eq("user_id", userId) + .select() + .maybeSingle(); + + if (error) throw new Error(`Failed to update goal: ${error.message}`); + if (!data) throw new Error("Goal not found or access denied"); + return mapGoalFromDb(data); +} + +export async function deleteGoal( + db: SupabaseClient, + userId: string, + id: string +) { + const { error } = await db + .from("goals") + .delete() + .eq("id", id) + .eq("user_id", userId); + + if (error) throw new Error(`Failed to delete goal: ${error.message}`); + return { success: true as const, id }; +} diff --git a/src/lib/ai/tools.ts b/src/lib/ai/tools.ts index 205ca9b..61febb3 100644 --- a/src/lib/ai/tools.ts +++ b/src/lib/ai/tools.ts @@ -1,531 +1,21 @@ -import { tool } from "ai"; -import { z } from "zod"; import type { SupabaseClient } from "@supabase/supabase-js"; import type { Database } from "@/lib/supabase/types"; -import { computeUserAccountability } from "@/lib/accountability/compute"; -import { sanitizeForPrompt } from "@/lib/prompts/sanitize"; - -/** Escape Postgres LIKE/ILIKE wildcards so user input is treated literally. */ -function escapeLike(str: string): string { - return str.replace(/%/g, "\\%").replace(/_/g, "\\_"); -} - +import { coreToolDefs } from "@/lib/tools/specs"; +import { toAiSdkTools } from "@/lib/tools/to-ai-sdk"; + +/** + * The in-app coach's tool set. + * + * Thin wrapper over the shared, transport-neutral registry in + * src/lib/tools/specs.ts — the single source of truth for agent tools. The + * coach keeps its original surface (task CRUD, goal read, accountability, coach + * memory, history); the fuller goal-write and check-in tools are exposed only + * over MCP (see src/app/api/mcp). Output sanitization lives in the shared defs, + * so it applies to both surfaces. + */ export function createGrindproofTools( userId: string, supabase: SupabaseClient ) { - return { - create_task: tool({ - description: - "Create a new task for the user. Use when they mention wanting to do something, add a task, or plan an activity.", - inputSchema: z.object({ - title: z.string().max(200).describe("Task title"), - description: z.string().max(1000).optional().describe("Task description"), - dueDate: z - .string() - .optional() - .describe("Due date in YYYY-MM-DD format"), - priority: z - .enum(["high", "medium", "low"]) - .default("medium") - .describe("Task priority"), - tags: z - .array(z.string()) - .optional() - .describe("Tags for categorization"), - goalId: z - .string() - .optional() - .describe("Optional goal ID to associate this task with"), - }), - execute: async ({ title, description, dueDate, priority, tags, goalId }) => { - const { data, error } = await supabase - .from("tasks") - .insert({ - user_id: userId, - title, - description: description ?? null, - due_date: dueDate - ? new Date(dueDate).toISOString() - : new Date().toISOString(), - priority, - tags: tags ?? null, - status: "pending", - goal_id: goalId ?? null, - }) - .select() - .single(); - - if (error) return { success: false as const, error: error.message }; - return { success: true as const, task: { id: data.id, title: data.title } }; - }, - }), - - update_task: tool({ - description: - "Update an existing task. Search by keywords in the title to find the task, then apply updates.", - inputSchema: z.object({ - searchQuery: z - .string() - .describe("Keywords to find the task by title"), - updates: z.object({ - title: z.string().max(200).optional(), - priority: z.enum(["high", "medium", "low"]).optional(), - status: z.enum(["pending", "completed", "skipped"]).optional(), - dueDate: z.string().optional().describe("YYYY-MM-DD format"), - }), - }), - execute: async ({ searchQuery, updates }) => { - const { data: tasks } = await supabase - .from("tasks") - .select("*") - .eq("user_id", userId) - .ilike("title", `%${escapeLike(searchQuery)}%`) - .limit(1); - - if (!tasks || tasks.length === 0) { - return { - success: false as const, - error: `No task found matching "${searchQuery}"`, - }; - } - - const task = tasks[0]; - const updateData: Record = {}; - if (updates.title !== undefined) updateData.title = updates.title; - if (updates.priority !== undefined) updateData.priority = updates.priority; - if (updates.status !== undefined) updateData.status = updates.status; - if (updates.dueDate) - updateData.due_date = new Date(updates.dueDate).toISOString(); - - const { error } = await supabase - .from("tasks") - .update(updateData) - .eq("id", task.id) - .eq("user_id", userId); - - if (error) return { success: false as const, error: error.message }; - return { - success: true as const, - task: { id: task.id, title: task.title, ...updates }, - }; - }, - }), - - delete_task: tool({ - description: "Delete a task by searching for it by title keywords.", - inputSchema: z.object({ - searchQuery: z - .string() - .describe("Keywords to find the task to delete"), - }), - execute: async ({ searchQuery }) => { - const { data: tasks } = await supabase - .from("tasks") - .select("*") - .eq("user_id", userId) - .ilike("title", `%${escapeLike(searchQuery)}%`) - .limit(1); - - if (!tasks || tasks.length === 0) { - return { - success: false as const, - error: `No task found matching "${searchQuery}"`, - }; - } - - const task = tasks[0]; - const { error } = await supabase - .from("tasks") - .delete() - .eq("id", task.id) - .eq("user_id", userId); - - if (error) return { success: false as const, error: error.message }; - return { - success: true as const, - deleted: { id: task.id, title: task.title }, - }; - }, - }), - - list_tasks: tool({ - description: - "List the user's tasks, optionally filtered by status or date.", - inputSchema: z.object({ - status: z - .enum(["pending", "completed", "skipped", "all"]) - .default("all") - .describe("Filter by status"), - dateFilter: z - .enum(["today", "tomorrow", "this_week", "overdue", "all"]) - .default("all") - .describe("Filter by date range"), - }), - execute: async ({ status, dateFilter }) => { - let query = supabase - .from("tasks") - .select("id, title, status, priority, due_date, tags, reflection, goal_id, created_at, carry_over_count") - .eq("user_id", userId) - .order("due_date", { ascending: true }); - - if (status !== "all") query = query.eq("status", status); - - const now = new Date(); - if (dateFilter === "today") { - const start = new Date(now); - start.setHours(0, 0, 0, 0); - const end = new Date(now); - end.setHours(23, 59, 59, 999); - query = query - .gte("due_date", start.toISOString()) - .lte("due_date", end.toISOString()); - } else if (dateFilter === "overdue") { - query = query - .lt("due_date", now.toISOString()) - .eq("status", "pending"); - } - - const { data, error } = await query.limit(50); - if (error) return { success: false as const, error: error.message }; - - const tasks = data ?? []; - - // Enrich tasks with goal titles - const uniqueGoalIds = [...new Set(tasks.map((t) => t.goal_id).filter(Boolean))] as string[]; - const goalTitleMap = new Map(); - - if (uniqueGoalIds.length > 0) { - const { data: goals } = await supabase - .from("goals") - .select("id, title") - .in("id", uniqueGoalIds); - - for (const g of goals ?? []) { - goalTitleMap.set(g.id, sanitizeForPrompt(g.title, 200)); - } - } - - // Tool results feed back into the model across multiple agentic steps. - // Sanitize user-authored strings so stored task/goal text can't act as - // indirect prompt injection when the coach reads it back. - const enrichedTasks = tasks.map((t) => ({ - ...t, - title: sanitizeForPrompt(t.title, 200), - reflection: t.reflection ? sanitizeForPrompt(t.reflection, 1000) : t.reflection, - tags: Array.isArray(t.tags) - ? t.tags.map((tag) => sanitizeForPrompt(tag, 100)) - : t.tags, - goalTitle: t.goal_id ? (goalTitleMap.get(t.goal_id) ?? null) : null, - })); - - return { - success: true as const, - tasks: enrichedTasks, - count: enrichedTasks.length, - }; - }, - }), - - list_goals: tool({ - description: "List the user's goals with task progress counts.", - inputSchema: z.object({ - status: z - .enum(["active", "completed", "all"]) - .default("active") - .describe("Filter goals by status"), - }), - execute: async ({ status }) => { - let query = supabase - .from("goals") - .select("id, title, description, status, priority, created_at") - .eq("user_id", userId); - - if (status !== "all") query = query.eq("status", status); - - const { data: goals } = await query; - - if (!goals) return { success: true as const, goals: [] }; - - const goalsWithProgress = await Promise.all( - goals.map(async (goal) => { - const { count: total } = await supabase - .from("tasks") - .select("*", { count: "exact", head: true }) - .eq("goal_id", goal.id); - const { count: completed } = await supabase - .from("tasks") - .select("*", { count: "exact", head: true }) - .eq("goal_id", goal.id) - .eq("status", "completed"); - - // Get most recent completed task for staleness - const { data: recentCompleted } = await supabase - .from("tasks") - .select("due_date") - .eq("goal_id", goal.id) - .eq("status", "completed") - .order("due_date", { ascending: false }) - .limit(1); - - let daysSinceLastCompletion: number | null = null; - if (recentCompleted && recentCompleted.length > 0 && recentCompleted[0].due_date) { - const lastDate = new Date(recentCompleted[0].due_date); - const now = new Date(); - daysSinceLastCompletion = Math.floor( - (now.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24) - ); - } - - return { - ...goal, - title: sanitizeForPrompt(goal.title, 200), - description: goal.description - ? sanitizeForPrompt(goal.description, 1000) - : goal.description, - totalTasks: total ?? 0, - completedTasks: completed ?? 0, - daysSinceLastCompletion, - }; - }) - ); - - return { success: true as const, goals: goalsWithProgress }; - }, - }), - - get_accountability_score: tool({ - description: - "Get the user's current accountability score, tier, streak, and performance metrics. Use when the user asks about their progress, performance, or score.", - inputSchema: z.object({}), - execute: async () => { - const snap = await computeUserAccountability(supabase, userId); - return { - success: true as const, - score: snap.score, - tier: `${snap.tier.name} (${snap.tier.color})`, - currentStreak: snap.streak, - weightedCompletion: snap.weightedCompletion, - consistencyRate: Math.round(snap.consistencyRate), - disciplineScore: snap.disciplineScore, - delta: snap.delta, - activeDays: Math.round(snap.consistencyRate * 14 / 100), - windowDays: 14, - todayProgress: { - completed: snap.today.completed, - total: snap.today.total, - }, - drivers: snap.drivers, - }; - }, - }), - - save_coach_note: tool({ - description: - "Save a coaching note to memory. Use to record commitments, recommendations, observed patterns, or excuses for future reference.", - inputSchema: z.object({ - category: z - .enum(["commitment", "recommendation", "pattern_flagged", "observation", "excuse_called"]) - .describe("The type of coaching note"), - content: z.string().max(500).describe("The content of the coaching note"), - relatedTo: z - .object({ - taskIds: z.array(z.string()).optional(), - goalIds: z.array(z.string()).optional(), - score: z.number().optional(), - }) - .optional() - .describe("Optional context linking this note to tasks, goals, or a score"), - expiresInDays: z - .number() - .default(30) - .describe("How many days until this note expires"), - }), - execute: async ({ category, content, relatedTo, expiresInDays }) => { - // Map input category to DB category - const dbCategory = - category === "pattern_flagged" - ? "pattern" - : category === "excuse_called" - ? "excuse_flagged" - : category; - - const expiresAt = new Date(); - expiresAt.setDate(expiresAt.getDate() + expiresInDays); - - const { data, error } = await supabase - .from("coach_memory") - .insert({ - user_id: userId, - category: dbCategory as "commitment" | "recommendation" | "pattern" | "observation" | "excuse_flagged", - content, - source: "coach_inline", - related_to: relatedTo ?? null, - expires_at: expiresAt.toISOString(), - }) - .select("id") - .single(); - - if (error) return { success: false as const, error: error.message }; - return { success: true as const, noteId: data.id }; - }, - }), - - update_coach_note: tool({ - description: - "Update the status of an existing coaching note. Use to mark commitments as fulfilled or broken.", - inputSchema: z.object({ - noteId: z.string().describe("The ID of the coaching note to update"), - status: z - .enum(["fulfilled", "broken", "expired"]) - .describe("The new status for the note"), - }), - execute: async ({ noteId, status }) => { - const { error } = await supabase - .from("coach_memory") - .update({ - status, - updated_at: new Date().toISOString(), - }) - .eq("id", noteId) - .eq("user_id", userId); - - if (error) return { success: false as const, error: error.message }; - return { success: true as const }; - }, - }), - - get_reflection_history: tool({ - description: - "Get the user's past task reflections. Use to understand how the user has been feeling about their work.", - inputSchema: z.object({ - days: z - .number() - .default(30) - .describe("How many days back to look for reflections"), - limit: z - .number() - .default(20) - .describe("Maximum number of reflections to return"), - }), - execute: async ({ days, limit }) => { - const since = new Date(); - since.setDate(since.getDate() - days); - - const { data, error } = await supabase - .from("tasks") - .select("title, reflection, due_date, status") - .eq("user_id", userId) - .not("reflection", "is", null) - .gte("due_date", since.toISOString()) - .order("due_date", { ascending: false }) - .limit(limit); - - if (error) return { success: false as const, error: error.message }; - - const reflections = (data ?? []).map((t) => ({ - taskTitle: sanitizeForPrompt(t.title, 200), - reflection: t.reflection ? sanitizeForPrompt(t.reflection, 1000) : t.reflection, - dueDate: t.due_date, - status: t.status, - })); - - return { success: true as const, reflections }; - }, - }), - - get_task_history: tool({ - description: - "Get historical task statistics grouped by status, goal, or day. Use for trend analysis and performance reviews.", - inputSchema: z.object({ - days: z - .number() - .default(30) - .describe("How many days back to include"), - groupBy: z - .enum(["status", "goal", "day"]) - .default("status") - .describe("How to group the results"), - }), - execute: async ({ days, groupBy }) => { - const since = new Date(); - since.setDate(since.getDate() - days); - - const { data, error } = await supabase - .from("tasks") - .select("id, status, due_date, goal_id") - .eq("user_id", userId) - .gte("due_date", since.toISOString()) - .order("due_date", { ascending: true }); - - if (error) return { success: false as const, error: error.message }; - - const allTasks = data ?? []; - - if (groupBy === "status") { - const completed = allTasks.filter((t) => t.status === "completed").length; - const skipped = allTasks.filter((t) => t.status === "skipped").length; - const pending = allTasks.filter((t) => t.status === "pending").length; - return { - success: true as const, - groupBy: "status" as const, - stats: { completed, skipped, pending, total: allTasks.length }, - }; - } - - if (groupBy === "goal") { - const uniqueGoalIds = [...new Set(allTasks.map((t) => t.goal_id).filter(Boolean))] as string[]; - const goalTitleMap = new Map(); - - if (uniqueGoalIds.length > 0) { - const { data: goals } = await supabase - .from("goals") - .select("id, title") - .in("id", uniqueGoalIds); - for (const g of goals ?? []) { - goalTitleMap.set(g.id, sanitizeForPrompt(g.title, 200)); - } - } - - const grouped: Record = {}; - for (const t of allTasks) { - const key = t.goal_id ?? "no_goal"; - const goalTitle = t.goal_id ? (goalTitleMap.get(t.goal_id) ?? "Unknown Goal") : "No Goal"; - if (!grouped[key]) { - grouped[key] = { goalTitle, completed: 0, skipped: 0, pending: 0, total: 0 }; - } - grouped[key].total++; - if (t.status === "completed") grouped[key].completed++; - else if (t.status === "skipped") grouped[key].skipped++; - else grouped[key].pending++; - } - - return { - success: true as const, - groupBy: "goal" as const, - stats: Object.values(grouped), - }; - } - - // groupBy === "day" - const byDay: Record = {}; - for (const t of allTasks) { - const dateStr = t.due_date ? new Date(t.due_date).toISOString().split("T")[0] : "unknown"; - if (!byDay[dateStr]) { - byDay[dateStr] = { date: dateStr, completed: 0, skipped: 0, pending: 0, total: 0 }; - } - byDay[dateStr].total++; - if (t.status === "completed") byDay[dateStr].completed++; - else if (t.status === "skipped") byDay[dateStr].skipped++; - else byDay[dateStr].pending++; - } - - return { - success: true as const, - groupBy: "day" as const, - stats: Object.values(byDay).sort((a, b) => a.date.localeCompare(b.date)), - }; - }, - }), - }; + return toAiSdkTools(coreToolDefs(), { userId, supabase }); } diff --git a/src/lib/env.ts b/src/lib/env.ts index 864ce26..ff91ceb 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -10,6 +10,9 @@ const clientEnvSchema = z.object({ const serverEnvSchema = clientEnvSchema.extend({ SUPABASE_SERVICE_ROLE_KEY: z.string().min(1), + // Legacy Supabase JWT secret (HS256) — used to mint short-lived user-scoped + // access tokens for the MCP server so RLS still applies. See scoped.ts. + SUPABASE_JWT_SECRET: z.string().min(1), NEXT_GOOGLE_GEMINI_API_KEY: z.string().min(1), CRON_SECRET: z.string().min(1), QSTASH_CURRENT_SIGNING_KEY: z.string().min(1).optional(), @@ -26,6 +29,7 @@ const testDefaults: z.infer = { NEXT_PUBLIC_SUPABASE_URL: "https://test.supabase.co", NEXT_PUBLIC_SUPABASE_ANON_KEY: "test-key", SUPABASE_SERVICE_ROLE_KEY: "test-service-key", + SUPABASE_JWT_SECRET: "test-jwt-secret-at-least-32-chars-long!!", NEXT_GOOGLE_GEMINI_API_KEY: "test-gemini-key", CRON_SECRET: "test-cron-secret", QSTASH_CURRENT_SIGNING_KEY: undefined, @@ -52,6 +56,7 @@ const rawEnv = { : undefined), NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY, SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY, + SUPABASE_JWT_SECRET: process.env.SUPABASE_JWT_SECRET, NEXT_GOOGLE_GEMINI_API_KEY: process.env.NEXT_GOOGLE_GEMINI_API_KEY, CRON_SECRET: process.env.CRON_SECRET, QSTASH_CURRENT_SIGNING_KEY: process.env.QSTASH_CURRENT_SIGNING_KEY, diff --git a/src/lib/mcp/auth.ts b/src/lib/mcp/auth.ts new file mode 100644 index 0000000..fe0bef2 --- /dev/null +++ b/src/lib/mcp/auth.ts @@ -0,0 +1,57 @@ +import { supabaseAdmin } from "@/lib/supabase/server"; +import { hashToken, looksLikeMcpToken } from "./token"; + +/** + * Resolved MCP caller identity. Everything downstream (RLS-scoped client, tool + * dispatch, rate limiting) consumes this — it is the single credential→identity + * boundary. Layering OAuth on later means changing only this file: the rest of + * the transport and tools stay identical. + */ +export interface McpIdentity { + userId: string; + tokenId: string; + scopes: string[]; +} + +/** + * Resolve a bearer token to an identity, or null if it is missing, malformed, + * unknown, revoked, or expired. + * + * This is the one place that runs BEFORE a user context exists, so it uses the + * service-role client (which bypasses RLS). It only ever reads a token row by + * its hash and stamps last_used_at — it never touches user data. Everything + * after resolution runs through the RLS-scoped client (src/lib/supabase/scoped). + */ +export async function resolveCredential( + bearerToken: string | undefined +): Promise { + if (!bearerToken || !looksLikeMcpToken(bearerToken)) return null; + + const { data, error } = await supabaseAdmin + .from("mcp_tokens") + .select("id, user_id, scopes, expires_at, revoked_at") + .eq("token_hash", hashToken(bearerToken)) + .maybeSingle(); + + if (error || !data) return null; + if (data.revoked_at) return null; + if (data.expires_at && new Date(data.expires_at).getTime() <= Date.now()) { + return null; + } + + // Fire-and-forget: recording usage must never block or fail the request. + void supabaseAdmin + .from("mcp_tokens") + .update({ last_used_at: new Date().toISOString() }) + .eq("id", data.id) + .then( + () => {}, + () => {} + ); + + return { + userId: data.user_id, + tokenId: data.id, + scopes: data.scopes ?? [], + }; +} diff --git a/src/lib/mcp/token.ts b/src/lib/mcp/token.ts new file mode 100644 index 0000000..931b656 --- /dev/null +++ b/src/lib/mcp/token.ts @@ -0,0 +1,48 @@ +import { createHash, randomBytes } from "crypto"; + +/** + * Personal access tokens for the MCP server. + * + * A token is a high-entropy random secret with a recognizable prefix. Only its + * SHA-256 hash is persisted (see the mcp_tokens migration); the plaintext is + * shown to the user exactly once. Because the token carries full entropy, a + * fast exact-match hash (SHA-256, GitHub-PAT style) is the correct choice — the + * indexed lookup is O(1) and there is nothing low-entropy to brute-force, so + * bcrypt's work factor would only slow the auth hot path. + */ + +/** Human-recognizable prefix so a leaked token is obviously a GrindProof MCP key. */ +export const MCP_TOKEN_PREFIX = "gp_mcp_"; + +/** Chars stored/displayed for identifying a token without revealing it. */ +const DISPLAY_PREFIX_LENGTH = 12; + +export interface GeneratedToken { + /** Full plaintext token — returned to the user once, never stored. */ + token: string; + /** SHA-256 hex hash, stored in mcp_tokens.token_hash. */ + hash: string; + /** Leading chars stored in mcp_tokens.token_prefix for display. */ + prefix: string; +} + +/** SHA-256 hex of a token. Used at generation and on every auth lookup. */ +export function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +/** Generate a new MCP token plus its stored hash and display prefix. */ +export function generateMcpToken(): GeneratedToken { + const secret = randomBytes(32).toString("base64url"); + const token = `${MCP_TOKEN_PREFIX}${secret}`; + return { + token, + hash: hashToken(token), + prefix: token.slice(0, DISPLAY_PREFIX_LENGTH), + }; +} + +/** Cheap shape check to reject obviously-invalid bearer strings before a DB hit. */ +export function looksLikeMcpToken(value: string): boolean { + return value.startsWith(MCP_TOKEN_PREFIX) && value.length > MCP_TOKEN_PREFIX.length + 20; +} diff --git a/src/lib/supabase/scoped.ts b/src/lib/supabase/scoped.ts new file mode 100644 index 0000000..921865e --- /dev/null +++ b/src/lib/supabase/scoped.ts @@ -0,0 +1,62 @@ +import { SignJWT } from "jose"; +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import { env } from "@/lib/env"; +import type { Database } from "./types"; + +/** + * User-scoped Supabase client for non-cookie contexts (the MCP server). + * + * The app normally obtains a user-scoped client from GoTrue cookies, which + * carries a JWT whose `sub` claim drives `auth.uid()` in RLS policies. MCP + * requests arrive with a personal access token, not cookies — so we synthesize + * an equivalent JWT: a short-lived HS256 token signed with the project's legacy + * `SUPABASE_JWT_SECRET`, with `sub = userId`. PostgREST validates it exactly + * like a real session token, so `auth.uid() = userId` and every existing RLS + * policy applies unchanged. This is defense-in-depth: even if a tool forgets a + * `.eq("user_id", …)` filter, RLS still scopes the query to the token's owner. + * + * Never use `supabaseAdmin` (service role) to execute MCP tools — it bypasses + * RLS. Use it only for the pre-auth token lookup (see src/lib/mcp/auth.ts). + */ + +const jwtSecret = new TextEncoder().encode(env.SUPABASE_JWT_SECRET); + +/** Access-token lifetime. Long enough to cover a single MCP request/tool run. */ +const TOKEN_TTL_SECONDS = 300; + +/** + * Mint a short-lived Supabase-compatible access token for `userId`. + * Exported for testing (claim assertions); the route uses the client below. + */ +export async function mintUserAccessToken( + userId: string, + nowSeconds: number = Math.floor(Date.now() / 1000) +): Promise { + return new SignJWT({ role: "authenticated" }) + .setProtectedHeader({ alg: "HS256", typ: "JWT" }) + .setSubject(userId) + .setAudience("authenticated") + .setIssuedAt(nowSeconds) + .setExpirationTime(nowSeconds + TOKEN_TTL_SECONDS) + .sign(jwtSecret); +} + +/** + * Create a Supabase client that acts as `userId` under RLS. The minted token is + * attached as the `Authorization` header so it reaches PostgREST/RPC on every + * query. The client holds no session and never refreshes. + */ +export async function createUserScopedClient( + userId: string +): Promise> { + const accessToken = await mintUserAccessToken(userId); + + return createClient( + env.NEXT_PUBLIC_SUPABASE_URL, + env.NEXT_PUBLIC_SUPABASE_ANON_KEY, + { + auth: { persistSession: false, autoRefreshToken: false }, + global: { headers: { Authorization: `Bearer ${accessToken}` } }, + } + ); +} diff --git a/src/lib/supabase/types.ts b/src/lib/supabase/types.ts index b65bef5..a020c02 100644 --- a/src/lib/supabase/types.ts +++ b/src/lib/supabase/types.ts @@ -267,6 +267,53 @@ export type Database = { } ]; }; + mcp_tokens: { + Row: { + id: string; + user_id: string; + name: string; + token_hash: string; + token_prefix: string; + scopes: string[]; + last_used_at: string | null; + expires_at: string | null; + revoked_at: string | null; + created_at: string; + }; + Insert: { + id?: string; + user_id: string; + name: string; + token_hash: string; + token_prefix: string; + scopes?: string[]; + last_used_at?: string | null; + expires_at?: string | null; + revoked_at?: string | null; + created_at?: string; + }; + Update: { + id?: string; + user_id?: string; + name?: string; + token_hash?: string; + token_prefix?: string; + scopes?: string[]; + last_used_at?: string | null; + expires_at?: string | null; + revoked_at?: string | null; + created_at?: string; + }; + Relationships: [ + { + foreignKeyName: "mcp_tokens_user_id_fkey"; + columns: ["user_id"]; + isOneToOne: false; + referencedRelation: "users"; + referencedColumns: ["id"]; + } + ]; + }; push_subscriptions: { Row: { id: string; @@ -585,6 +632,14 @@ export type Database = { }; Returns: number; }; + mcp_touch_rate_limit: { + Args: { + p_token_id: string; + p_window_seconds: number; + p_max: number; + }; + Returns: boolean; + }; }; Enums: { [_ in never]: never; diff --git a/src/lib/tools/specs.ts b/src/lib/tools/specs.ts new file mode 100644 index 0000000..ecbb434 --- /dev/null +++ b/src/lib/tools/specs.ts @@ -0,0 +1,585 @@ +import { z } from "zod"; +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { Database } from "@/lib/supabase/types"; +import { computeUserAccountability } from "@/lib/accountability/compute"; +import { sanitizeForPrompt } from "@/lib/prompts/sanitize"; +import { + createGoalSchema, + updateGoalSchema, + createGoal, + updateGoal, + deleteGoal, +} from "@/lib/actions/goals"; +import { + morningCheckInSchema, + eveningReflectionsSchema, + taskReflectionSchema, + recordMorningCheckIn, + recordEveningReflections, + recordTaskReflection, +} from "@/lib/actions/daily-checks"; + +/** + * Transport-neutral tool registry — the single source of truth for GrindProof's + * agent tools. The AI-SDK coach route and the MCP server both build their tool + * surfaces from these defs (see to-ai-sdk.ts / to-mcp.ts), so a tool is defined + * once and stays in lockstep across both. + * + * Each def's `execute` takes an explicit `ToolContext` (the user id + a client + * already scoped to that user), so the same logic runs unchanged whether the + * caller authenticated via cookies (coach) or a personal access token (MCP). + */ + +export interface ToolContext { + userId: string; + supabase: SupabaseClient; +} + +/** Subset of MCP tool annotations we set. */ +export interface ToolAnnotations { + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; +} + +export interface ToolDef = z.ZodObject> { + name: string; + description: string; + inputSchema: S; + annotations?: ToolAnnotations; + execute: (ctx: ToolContext, input: z.infer) => Promise; +} + +/** Helper to define a def with input-type inference preserved. */ +function def>(d: ToolDef): ToolDef { + return d as unknown as ToolDef; +} + +/** Escape Postgres LIKE/ILIKE wildcards so user input is treated literally. */ +function escapeLike(str: string): string { + return str.replace(/%/g, "\\%").replace(/_/g, "\\_"); +} + +/** + * The original coach tool set: task CRUD, goal read, accountability, coach + * memory, and history. Executors preserved verbatim from the former + * createGrindproofTools, including the sanitizeForPrompt output hardening — + * tool results feed back into a model across agentic steps, so stored + * user-authored text is sanitized to defuse indirect prompt injection. + */ +export function coreToolDefs(): ToolDef[] { + return [ + def({ + name: "create_task", + description: + "Create a new task for the user. Use when they mention wanting to do something, add a task, or plan an activity.", + inputSchema: z.object({ + title: z.string().max(200).describe("Task title"), + description: z.string().max(1000).optional().describe("Task description"), + dueDate: z.string().optional().describe("Due date in YYYY-MM-DD format"), + priority: z + .enum(["high", "medium", "low"]) + .default("medium") + .describe("Task priority"), + tags: z.array(z.string()).optional().describe("Tags for categorization"), + goalId: z + .string() + .optional() + .describe("Optional goal ID to associate this task with"), + }), + execute: async ({ userId, supabase }, { title, description, dueDate, priority, tags, goalId }) => { + const { data, error } = await supabase + .from("tasks") + .insert({ + user_id: userId, + title, + description: description ?? null, + due_date: dueDate ? new Date(dueDate).toISOString() : new Date().toISOString(), + priority, + tags: tags ?? null, + status: "pending", + goal_id: goalId ?? null, + }) + .select() + .single(); + + if (error) return { success: false as const, error: error.message }; + return { success: true as const, task: { id: data.id, title: data.title } }; + }, + }), + + def({ + name: "update_task", + description: + "Update an existing task. Search by keywords in the title to find the task, then apply updates.", + inputSchema: z.object({ + searchQuery: z.string().describe("Keywords to find the task by title"), + updates: z.object({ + title: z.string().max(200).optional(), + priority: z.enum(["high", "medium", "low"]).optional(), + status: z.enum(["pending", "completed", "skipped"]).optional(), + dueDate: z.string().optional().describe("YYYY-MM-DD format"), + }), + }), + execute: async ({ userId, supabase }, { searchQuery, updates }) => { + const { data: tasks } = await supabase + .from("tasks") + .select("*") + .eq("user_id", userId) + .ilike("title", `%${escapeLike(searchQuery)}%`) + .limit(1); + + if (!tasks || tasks.length === 0) { + return { success: false as const, error: `No task found matching "${searchQuery}"` }; + } + + const task = tasks[0]; + const updateData: Record = {}; + if (updates.title !== undefined) updateData.title = updates.title; + if (updates.priority !== undefined) updateData.priority = updates.priority; + if (updates.status !== undefined) updateData.status = updates.status; + if (updates.dueDate) updateData.due_date = new Date(updates.dueDate).toISOString(); + + const { error } = await supabase + .from("tasks") + .update(updateData) + .eq("id", task.id) + .eq("user_id", userId); + + if (error) return { success: false as const, error: error.message }; + return { success: true as const, task: { id: task.id, title: task.title, ...updates } }; + }, + }), + + def({ + name: "delete_task", + description: "Delete a task by searching for it by title keywords.", + annotations: { destructiveHint: true }, + inputSchema: z.object({ + searchQuery: z.string().describe("Keywords to find the task to delete"), + }), + execute: async ({ userId, supabase }, { searchQuery }) => { + const { data: tasks } = await supabase + .from("tasks") + .select("*") + .eq("user_id", userId) + .ilike("title", `%${escapeLike(searchQuery)}%`) + .limit(1); + + if (!tasks || tasks.length === 0) { + return { success: false as const, error: `No task found matching "${searchQuery}"` }; + } + + const task = tasks[0]; + const { error } = await supabase + .from("tasks") + .delete() + .eq("id", task.id) + .eq("user_id", userId); + + if (error) return { success: false as const, error: error.message }; + return { success: true as const, deleted: { id: task.id, title: task.title } }; + }, + }), + + def({ + name: "list_tasks", + description: "List the user's tasks, optionally filtered by status or date.", + annotations: { readOnlyHint: true }, + inputSchema: z.object({ + status: z + .enum(["pending", "completed", "skipped", "all"]) + .default("all") + .describe("Filter by status"), + dateFilter: z + .enum(["today", "tomorrow", "this_week", "overdue", "all"]) + .default("all") + .describe("Filter by date range"), + }), + execute: async ({ userId, supabase }, { status, dateFilter }) => { + let query = supabase + .from("tasks") + .select("id, title, status, priority, due_date, tags, reflection, goal_id, created_at, carry_over_count") + .eq("user_id", userId) + .order("due_date", { ascending: true }); + + if (status !== "all") query = query.eq("status", status); + + const now = new Date(); + if (dateFilter === "today") { + const start = new Date(now); + start.setHours(0, 0, 0, 0); + const end = new Date(now); + end.setHours(23, 59, 59, 999); + query = query.gte("due_date", start.toISOString()).lte("due_date", end.toISOString()); + } else if (dateFilter === "overdue") { + query = query.lt("due_date", now.toISOString()).eq("status", "pending"); + } + + const { data, error } = await query.limit(50); + if (error) return { success: false as const, error: error.message }; + + const tasks = data ?? []; + + const uniqueGoalIds = [...new Set(tasks.map((t) => t.goal_id).filter(Boolean))] as string[]; + const goalTitleMap = new Map(); + if (uniqueGoalIds.length > 0) { + const { data: goals } = await supabase + .from("goals") + .select("id, title") + .in("id", uniqueGoalIds); + for (const g of goals ?? []) { + goalTitleMap.set(g.id, sanitizeForPrompt(g.title, 200)); + } + } + + // Tool results feed back into the model across multiple agentic steps. + // Sanitize user-authored strings so stored task/goal text can't act as + // indirect prompt injection when it is read back. + const enrichedTasks = tasks.map((t) => ({ + ...t, + title: sanitizeForPrompt(t.title, 200), + reflection: t.reflection ? sanitizeForPrompt(t.reflection, 1000) : t.reflection, + tags: Array.isArray(t.tags) ? t.tags.map((tag) => sanitizeForPrompt(tag, 100)) : t.tags, + goalTitle: t.goal_id ? (goalTitleMap.get(t.goal_id) ?? null) : null, + })); + + return { success: true as const, tasks: enrichedTasks, count: enrichedTasks.length }; + }, + }), + + def({ + name: "list_goals", + description: "List the user's goals with task progress counts.", + annotations: { readOnlyHint: true }, + inputSchema: z.object({ + status: z + .enum(["active", "completed", "all"]) + .default("active") + .describe("Filter goals by status"), + }), + execute: async ({ userId, supabase }, { status }) => { + let query = supabase + .from("goals") + .select("id, title, description, status, priority, created_at") + .eq("user_id", userId); + + if (status !== "all") query = query.eq("status", status); + + const { data: goals } = await query; + if (!goals) return { success: true as const, goals: [] }; + + const goalsWithProgress = await Promise.all( + goals.map(async (goal) => { + const { count: total } = await supabase + .from("tasks") + .select("*", { count: "exact", head: true }) + .eq("goal_id", goal.id); + const { count: completed } = await supabase + .from("tasks") + .select("*", { count: "exact", head: true }) + .eq("goal_id", goal.id) + .eq("status", "completed"); + + const { data: recentCompleted } = await supabase + .from("tasks") + .select("due_date") + .eq("goal_id", goal.id) + .eq("status", "completed") + .order("due_date", { ascending: false }) + .limit(1); + + let daysSinceLastCompletion: number | null = null; + if (recentCompleted && recentCompleted.length > 0 && recentCompleted[0].due_date) { + const lastDate = new Date(recentCompleted[0].due_date); + const now = new Date(); + daysSinceLastCompletion = Math.floor( + (now.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24) + ); + } + + return { + ...goal, + title: sanitizeForPrompt(goal.title, 200), + description: goal.description ? sanitizeForPrompt(goal.description, 1000) : goal.description, + totalTasks: total ?? 0, + completedTasks: completed ?? 0, + daysSinceLastCompletion, + }; + }) + ); + + return { success: true as const, goals: goalsWithProgress }; + }, + }), + + def({ + name: "get_accountability_score", + description: + "Get the user's current accountability score, tier, streak, and performance metrics. Use when the user asks about their progress, performance, or score.", + annotations: { readOnlyHint: true }, + inputSchema: z.object({}), + execute: async ({ userId, supabase }) => { + const snap = await computeUserAccountability(supabase, userId); + return { + success: true as const, + score: snap.score, + tier: `${snap.tier.name} (${snap.tier.color})`, + currentStreak: snap.streak, + weightedCompletion: snap.weightedCompletion, + consistencyRate: Math.round(snap.consistencyRate), + disciplineScore: snap.disciplineScore, + delta: snap.delta, + activeDays: Math.round((snap.consistencyRate * 14) / 100), + windowDays: 14, + todayProgress: { completed: snap.today.completed, total: snap.today.total }, + drivers: snap.drivers, + }; + }, + }), + + def({ + name: "save_coach_note", + description: + "Save a coaching note to memory. Use to record commitments, recommendations, observed patterns, or excuses for future reference.", + inputSchema: z.object({ + category: z + .enum(["commitment", "recommendation", "pattern_flagged", "observation", "excuse_called"]) + .describe("The type of coaching note"), + content: z.string().max(500).describe("The content of the coaching note"), + relatedTo: z + .object({ + taskIds: z.array(z.string()).optional(), + goalIds: z.array(z.string()).optional(), + score: z.number().optional(), + }) + .optional() + .describe("Optional context linking this note to tasks, goals, or a score"), + expiresInDays: z.number().default(30).describe("How many days until this note expires"), + }), + execute: async ({ userId, supabase }, { category, content, relatedTo, expiresInDays }) => { + const dbCategory = + category === "pattern_flagged" + ? "pattern" + : category === "excuse_called" + ? "excuse_flagged" + : category; + + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + expiresInDays); + + const { data, error } = await supabase + .from("coach_memory") + .insert({ + user_id: userId, + category: dbCategory as "commitment" | "recommendation" | "pattern" | "observation" | "excuse_flagged", + content, + source: "coach_inline", + related_to: relatedTo ?? null, + expires_at: expiresAt.toISOString(), + }) + .select("id") + .single(); + + if (error) return { success: false as const, error: error.message }; + return { success: true as const, noteId: data.id }; + }, + }), + + def({ + name: "update_coach_note", + description: + "Update the status of an existing coaching note. Use to mark commitments as fulfilled or broken.", + inputSchema: z.object({ + noteId: z.string().describe("The ID of the coaching note to update"), + status: z.enum(["fulfilled", "broken", "expired"]).describe("The new status for the note"), + }), + execute: async ({ userId, supabase }, { noteId, status }) => { + const { error } = await supabase + .from("coach_memory") + .update({ status, updated_at: new Date().toISOString() }) + .eq("id", noteId) + .eq("user_id", userId); + + if (error) return { success: false as const, error: error.message }; + return { success: true as const }; + }, + }), + + def({ + name: "get_reflection_history", + description: + "Get the user's past task reflections. Use to understand how the user has been feeling about their work.", + annotations: { readOnlyHint: true }, + inputSchema: z.object({ + days: z.number().default(30).describe("How many days back to look for reflections"), + limit: z.number().default(20).describe("Maximum number of reflections to return"), + }), + execute: async ({ userId, supabase }, { days, limit }) => { + const since = new Date(); + since.setDate(since.getDate() - days); + + const { data, error } = await supabase + .from("tasks") + .select("title, reflection, due_date, status") + .eq("user_id", userId) + .not("reflection", "is", null) + .gte("due_date", since.toISOString()) + .order("due_date", { ascending: false }) + .limit(limit); + + if (error) return { success: false as const, error: error.message }; + + const reflections = (data ?? []).map((t) => ({ + taskTitle: sanitizeForPrompt(t.title, 200), + reflection: t.reflection ? sanitizeForPrompt(t.reflection, 1000) : t.reflection, + dueDate: t.due_date, + status: t.status, + })); + + return { success: true as const, reflections }; + }, + }), + + def({ + name: "get_task_history", + description: + "Get historical task statistics grouped by status, goal, or day. Use for trend analysis and performance reviews.", + annotations: { readOnlyHint: true }, + inputSchema: z.object({ + days: z.number().default(30).describe("How many days back to include"), + groupBy: z + .enum(["status", "goal", "day"]) + .default("status") + .describe("How to group the results"), + }), + execute: async ({ userId, supabase }, { days, groupBy }) => { + const since = new Date(); + since.setDate(since.getDate() - days); + + const { data, error } = await supabase + .from("tasks") + .select("id, status, due_date, goal_id") + .eq("user_id", userId) + .gte("due_date", since.toISOString()) + .order("due_date", { ascending: true }); + + if (error) return { success: false as const, error: error.message }; + + const allTasks = data ?? []; + + if (groupBy === "status") { + const completed = allTasks.filter((t) => t.status === "completed").length; + const skipped = allTasks.filter((t) => t.status === "skipped").length; + const pending = allTasks.filter((t) => t.status === "pending").length; + return { + success: true as const, + groupBy: "status" as const, + stats: { completed, skipped, pending, total: allTasks.length }, + }; + } + + if (groupBy === "goal") { + const uniqueGoalIds = [...new Set(allTasks.map((t) => t.goal_id).filter(Boolean))] as string[]; + const goalTitleMap = new Map(); + if (uniqueGoalIds.length > 0) { + const { data: goals } = await supabase + .from("goals") + .select("id, title") + .in("id", uniqueGoalIds); + for (const g of goals ?? []) { + goalTitleMap.set(g.id, sanitizeForPrompt(g.title, 200)); + } + } + + const grouped: Record = {}; + for (const t of allTasks) { + const key = t.goal_id ?? "no_goal"; + const goalTitle = t.goal_id ? (goalTitleMap.get(t.goal_id) ?? "Unknown Goal") : "No Goal"; + if (!grouped[key]) grouped[key] = { goalTitle, completed: 0, skipped: 0, pending: 0, total: 0 }; + grouped[key].total++; + if (t.status === "completed") grouped[key].completed++; + else if (t.status === "skipped") grouped[key].skipped++; + else grouped[key].pending++; + } + + return { success: true as const, groupBy: "goal" as const, stats: Object.values(grouped) }; + } + + const byDay: Record = {}; + for (const t of allTasks) { + const dateStr = t.due_date ? new Date(t.due_date).toISOString().split("T")[0] : "unknown"; + if (!byDay[dateStr]) byDay[dateStr] = { date: dateStr, completed: 0, skipped: 0, pending: 0, total: 0 }; + byDay[dateStr].total++; + if (t.status === "completed") byDay[dateStr].completed++; + else if (t.status === "skipped") byDay[dateStr].skipped++; + else byDay[dateStr].pending++; + } + + return { + success: true as const, + groupBy: "day" as const, + stats: Object.values(byDay).sort((a, b) => a.date.localeCompare(b.date)), + }; + }, + }), + ]; +} + +/** Goal write tools — new surface for agents (goals were read-only before). */ +export function goalWriteToolDefs(): ToolDef[] { + return [ + def({ + name: "create_goal", + description: "Create a new goal for the user.", + inputSchema: createGoalSchema, + execute: ({ userId, supabase }, input) => createGoal(supabase, userId, input), + }), + def({ + name: "update_goal", + description: "Update an existing goal by id. Only provided fields change.", + inputSchema: updateGoalSchema, + execute: ({ userId, supabase }, input) => updateGoal(supabase, userId, input), + }), + def({ + name: "delete_goal", + description: "Delete a goal by id.", + annotations: { destructiveHint: true }, + inputSchema: z.object({ id: z.string().describe("The goal id to delete") }), + execute: ({ userId, supabase }, { id }) => deleteGoal(supabase, userId, id), + }), + ]; +} + +/** Check-in / reflection logging tools — the core GrindProof rituals. */ +export function checkInToolDefs(): ToolDef[] { + return [ + def({ + name: "record_morning_checkin", + description: + "Record the morning check-in: optionally carry the given still-pending task ids forward to today, and mark the morning ritual complete.", + inputSchema: morningCheckInSchema, + execute: ({ userId, supabase }, input) => recordMorningCheckIn(supabase, userId, input), + }), + def({ + name: "record_evening_checkin", + description: + "Record the evening reality-check: set each task completed or skipped with an optional reflection, carry skipped tasks forward, and mark the evening ritual complete.", + annotations: { destructiveHint: true }, + inputSchema: eveningReflectionsSchema, + execute: ({ userId, supabase }, input) => recordEveningReflections(supabase, userId, input), + }), + def({ + name: "record_task_reflection", + description: + "Record a reflection (and optionally a new status) on a single task by id.", + inputSchema: taskReflectionSchema, + execute: ({ userId, supabase }, input) => recordTaskReflection(supabase, userId, input), + }), + ]; +} + +/** Full agent surface exposed over MCP: core + goal write + check-in logging. */ +export function allToolDefs(): ToolDef[] { + return [...coreToolDefs(), ...goalWriteToolDefs(), ...checkInToolDefs()]; +} diff --git a/src/lib/tools/to-ai-sdk.ts b/src/lib/tools/to-ai-sdk.ts new file mode 100644 index 0000000..2fddbe6 --- /dev/null +++ b/src/lib/tools/to-ai-sdk.ts @@ -0,0 +1,22 @@ +import { tool, type Tool } from "ai"; +import type { ToolContext, ToolDef } from "./specs"; + +/** + * Adapt transport-neutral tool defs into the AI SDK `tool()` shape used by the + * coach chat route. The context (user id + scoped client) is bound up front + * because the coach authenticates once per request via cookies. + */ +export function toAiSdkTools( + defs: ToolDef[], + ctx: ToolContext +): Record { + const tools: Record = {}; + for (const d of defs) { + tools[d.name] = tool({ + description: d.description, + inputSchema: d.inputSchema, + execute: (input: unknown) => d.execute(ctx, input as never), + }); + } + return tools; +} diff --git a/src/lib/tools/to-mcp.ts b/src/lib/tools/to-mcp.ts new file mode 100644 index 0000000..8a0eac2 --- /dev/null +++ b/src/lib/tools/to-mcp.ts @@ -0,0 +1,44 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ToolContext, ToolDef } from "./specs"; + +/** + * Register transport-neutral tool defs on an MCP server. Unlike the coach + * adapter, the identity is not known at registration time — tools are declared + * once (name/schema/annotations) and the per-call `resolveContext` derives the + * user id from the request's auth info and builds a scoped client for that one + * call. See src/app/api/mcp/[transport]/route.ts. + */ +export function registerMcpTools( + server: McpServer, + defs: ToolDef[], + resolveContext: (extra: unknown) => Promise +): void { + for (const d of defs) { + server.registerTool( + d.name, + { + description: d.description, + // MCP expects a raw Zod shape, not a wrapped z.object(). + inputSchema: d.inputSchema.shape, + annotations: d.annotations, + }, + async (args, extra) => { + try { + const ctx = await resolveContext(extra); + const result = await d.execute(ctx, args as never); + return { + content: [{ type: "text" as const, text: JSON.stringify(result) }], + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + content: [ + { type: "text" as const, text: JSON.stringify({ success: false, error: message }) }, + ], + isError: true, + }; + } + } + ); + } +} diff --git a/src/server/trpc/routers/_app.ts b/src/server/trpc/routers/_app.ts index 44ec352..8aafcdb 100644 --- a/src/server/trpc/routers/_app.ts +++ b/src/server/trpc/routers/_app.ts @@ -8,6 +8,7 @@ import { dailyCheckRouter } from "./dailyCheck"; import { weeklyRoastRouter } from "./weeklyRoast"; import { accountabilityScoreRouter } from "./accountabilityScore"; import { retentionRouter } from "./retention"; +import { mcpTokenRouter } from "./mcpToken"; export const appRouter = router({ goal: goalRouter, @@ -19,6 +20,7 @@ export const appRouter = router({ weeklyRoast: weeklyRoastRouter, accountabilityScore: accountabilityScoreRouter, retention: retentionRouter, + mcpToken: mcpTokenRouter, }); export type AppRouter = typeof appRouter; diff --git a/src/server/trpc/routers/dailyCheck.ts b/src/server/trpc/routers/dailyCheck.ts index 58f130e..7c65301 100644 --- a/src/server/trpc/routers/dailyCheck.ts +++ b/src/server/trpc/routers/dailyCheck.ts @@ -1,8 +1,10 @@ import { z } from "zod"; import { router, protectedProcedure } from "../context"; -import { computeUserPatterns } from "@/lib/ai/patterns"; -import { fireAndForgetScoreChange } from "@/lib/accountability/hooks"; import { captureServerEvent } from "@/lib/posthog/server"; +import { + recordMorningCheckIn, + recordEveningReflections, +} from "@/lib/actions/daily-checks"; export const dailyCheckRouter = router({ getMorningSchedule: protectedProcedure.query(async ({ ctx }) => { @@ -92,34 +94,9 @@ export const dailyCheckRouter = router({ taskIds: z.array(z.string()).max(100), }) ) - .mutation(async ({ ctx, input }) => { - const today = new Date(); - today.setHours(12, 0, 0, 0); - - // Atomic single-statement carry-over via Postgres RPC. Replaces the - // read-then-write loop that raced on concurrent submits and double- - // incremented carry_over_count. RLS enforces user_id scoping. - const { data: count, error } = await ctx.db.rpc("carry_over_tasks", { - p_task_ids: input.taskIds, - p_new_due: today.toISOString(), - }); - - if (error) - throw new Error(`Failed to carry over tasks: ${error.message}`); - - // Idempotent: unique index on (user_id, type, day) — swallow 23505 - // (unique_violation) so retries don't surface as errors to the client. - const { error: morningErr } = await ctx.db - .from("daily_checks") - .insert({ user_id: ctx.user.id, type: "morning" }); - if (morningErr && morningErr.code !== "23505") { - throw new Error(`Failed to record check-in: ${morningErr.message}`); - } - - fireAndForgetScoreChange(ctx.db, ctx.user.id, "task_carried_over"); - - return { success: true, count: count ?? 0 }; - }), + .mutation(({ ctx, input }) => + recordMorningCheckIn(ctx.db, ctx.user.id, { taskIds: input.taskIds }) + ), submitEveningReflections: protectedProcedure .input( @@ -134,86 +111,20 @@ export const dailyCheckRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - const failures: string[] = []; - - const tomorrow = new Date(); - tomorrow.setHours(0, 0, 0, 0); - tomorrow.setDate(tomorrow.getDate() + 1); - tomorrow.setHours(12, 0, 0, 0); - - // Bucket reflections so carry-overs can hit the atomic RPC in one shot. - const skipIds: string[] = []; - const skipReflections: Record = {}; - const completedIds: string[] = []; - const completedReflections: Record = {}; - - for (const item of input.reflections) { - if (item.status === "skipped") { - skipIds.push(item.taskId); - if (item.reflection) skipReflections[item.taskId] = item.reflection; - } else { - completedIds.push(item.taskId); - if (item.reflection) - completedReflections[item.taskId] = item.reflection; - } - } - - // Carry-over: atomic increment + reschedule via RPC. Reflections are - // applied separately because the RPC's signature only handles the - // shared rollover fields. - if (skipIds.length > 0) { - const { error: rpcErr } = await ctx.db.rpc("carry_over_tasks", { - p_task_ids: skipIds, - p_new_due: tomorrow.toISOString(), - }); - if (rpcErr) { - throw new Error(`Failed to carry over tasks: ${rpcErr.message}`); - } - for (const id of skipIds) { - if (skipReflections[id]) { - const { error } = await ctx.db - .from("tasks") - .update({ reflection: skipReflections[id] }) - .eq("id", id) - .eq("user_id", ctx.user.id); - if (error) failures.push(id); - } - } - } - - // Completed: set status + completed_at + optional reflection. - const nowIso = new Date().toISOString(); - for (const id of completedIds) { - const update: Record = { - status: "completed", - completed_at: nowIso, - }; - if (completedReflections[id]) update.reflection = completedReflections[id]; - const { error } = await ctx.db - .from("tasks") - .update(update) - .eq("id", id) - .eq("user_id", ctx.user.id); - if (error) failures.push(id); - } - - if (failures.length > 0) { - throw new Error(`Failed to update tasks: ${failures.join(", ")}`); - } - - // Idempotent: unique index on (user_id, type, day) — swallow 23505 - // (unique_violation) so retries don't surface as errors to the client. - const { error: eveningErr } = await ctx.db - .from("daily_checks") - .insert({ user_id: ctx.user.id, type: "evening" }); - if (eveningErr && eveningErr.code !== "23505") { - throw new Error(`Failed to record check-in: ${eveningErr.message}`); - } + // Core writes + accountability side effects (carry-over, markers, score + // recompute, pattern engine) live in the shared action so MCP-driven + // check-ins behave identically. + const { completedCount, skippedCount } = await recordEveningReflections( + ctx.db, + ctx.user.id, + input + ); // Server-side funnel event — GRI-6 requires this fires from the server, // not the client, so it can't be blocked by ad-blockers. Per the spec // this event is the *first* check-in only — gate the capture, don't just - // tag a property. + // tag a property. Kept in the router because it needs the full user + // session (created_at) and is analytics, not accountability state. const { count: previousEveningCheckins } = await ctx.db .from("daily_checks") .select("*", { count: "exact", head: true }) @@ -231,18 +142,11 @@ export const dailyCheckRouter = router({ captureServerEvent(ctx.user.id, "first_checkin_completed", { user_id: ctx.user.id, time_to_first_checkin_seconds: timeToFirstCheckin, - tasks_completed: completedIds.length, - tasks_skipped: skipIds.length, + tasks_completed: completedCount, + tasks_skipped: skippedCount, }).catch(() => {}); // fire-and-forget } - fireAndForgetScoreChange(ctx.db, ctx.user.id, "evening_reflection"); - - // Fire pattern engine (non-blocking) - computeUserPatterns(ctx.db, ctx.user.id).catch((err) => - console.error("Pattern engine error:", err) - ); - return { success: true, count: input.reflections.length }; }), }); diff --git a/src/server/trpc/routers/goal.ts b/src/server/trpc/routers/goal.ts index e6ec57a..abe30f9 100644 --- a/src/server/trpc/routers/goal.ts +++ b/src/server/trpc/routers/goal.ts @@ -1,33 +1,16 @@ import { z } from "zod"; import { router, protectedProcedure } from "../context"; - -export const createGoalSchema = z.object({ - title: z.string().min(1, "Title is required").max(200), - description: z.string().max(1000).optional(), - status: z.enum(["active", "completed"]).default("active"), - priority: z.enum(["high", "medium", "low"]).default("medium"), -}); - -export const updateGoalSchema = z.object({ - id: z.string(), - title: z.string().min(1).max(200).optional(), - description: z.string().max(1000).optional().nullable(), - status: z.enum(["active", "completed"]).optional(), - priority: z.enum(["high", "medium", "low"]).optional(), -}); - -function mapGoalFromDb(goal: any) { - return { - id: goal.id, - userId: goal.user_id, - title: goal.title, - description: goal.description || null, - status: goal.status as "active" | "completed", - priority: goal.priority as "high" | "medium" | "low", - createdAt: new Date(goal.created_at), - updatedAt: new Date(goal.updated_at), - }; -} +import { + createGoalSchema, + updateGoalSchema, + mapGoalFromDb, + createGoal, + updateGoal, + deleteGoal, +} from "@/lib/actions/goals"; + +// Re-exported for consumers that import the schemas from the router (e.g. tests). +export { createGoalSchema, updateGoalSchema }; export const goalRouter = router({ getAll: protectedProcedure.query(async ({ ctx }) => { @@ -58,57 +41,13 @@ export const goalRouter = router({ create: protectedProcedure .input(createGoalSchema) - .mutation(async ({ ctx, input }) => { - const { data, error } = await ctx.db - .from("goals") - .insert({ - user_id: ctx.user.id, - title: input.title, - description: input.description || null, - status: input.status || "active", - priority: input.priority || "medium", - }) - .select() - .maybeSingle(); - - if (error) throw new Error(`Failed to create goal: ${error.message}`); - if (!data) throw new Error("Failed to create goal: No data returned"); - return mapGoalFromDb(data); - }), + .mutation(({ ctx, input }) => createGoal(ctx.db, ctx.user.id, input)), update: protectedProcedure .input(updateGoalSchema) - .mutation(async ({ ctx, input }) => { - const updateData: Record = {}; - if (input.title !== undefined) updateData.title = input.title; - if (input.description !== undefined) - updateData.description = input.description || null; - if (input.status !== undefined) updateData.status = input.status; - if (input.priority !== undefined) updateData.priority = input.priority; - - const { data, error } = await ctx.db - .from("goals") - .update(updateData) - .eq("id", input.id) - .eq("user_id", ctx.user.id) - .select() - .maybeSingle(); - - if (error) throw new Error(`Failed to update goal: ${error.message}`); - if (!data) throw new Error("Goal not found or access denied"); - return mapGoalFromDb(data); - }), + .mutation(({ ctx, input }) => updateGoal(ctx.db, ctx.user.id, input)), delete: protectedProcedure .input(z.object({ id: z.string() })) - .mutation(async ({ ctx, input }) => { - const { error } = await ctx.db - .from("goals") - .delete() - .eq("id", input.id) - .eq("user_id", ctx.user.id); - - if (error) throw new Error(`Failed to delete goal: ${error.message}`); - return { success: true, id: input.id }; - }), + .mutation(({ ctx, input }) => deleteGoal(ctx.db, ctx.user.id, input.id)), }); diff --git a/src/server/trpc/routers/mcpToken.ts b/src/server/trpc/routers/mcpToken.ts new file mode 100644 index 0000000..d0d27c7 --- /dev/null +++ b/src/server/trpc/routers/mcpToken.ts @@ -0,0 +1,87 @@ +import { z } from "zod"; +import { router, protectedProcedure } from "../context"; +import { generateMcpToken } from "@/lib/mcp/token"; + +/** + * Management API for MCP personal access tokens, used by the "Connect an agent" + * settings UI. All queries run through the cookie-scoped client, so RLS already + * confines a user to their own rows; the explicit user_id filters are + * belt-and-suspenders. The plaintext token is returned by `create` exactly once + * and never stored (only its hash is). + */ + +export const createMcpTokenSchema = z.object({ + name: z.string().min(1).max(100), + // null / omitted = never expires. Capped at 10 years. + expiresInDays: z.number().int().positive().max(3650).nullable().optional(), +}); + +export const mcpTokenRouter = router({ + list: protectedProcedure.query(async ({ ctx }) => { + const { data, error } = await ctx.db + .from("mcp_tokens") + .select("id, name, token_prefix, scopes, last_used_at, expires_at, created_at") + .eq("user_id", ctx.user.id) + .is("revoked_at", null) + .order("created_at", { ascending: false }); + + if (error) throw new Error(`Failed to list tokens: ${error.message}`); + + return (data ?? []).map((t) => ({ + id: t.id, + name: t.name, + prefix: t.token_prefix, + scopes: t.scopes, + lastUsedAt: t.last_used_at ? new Date(t.last_used_at) : null, + expiresAt: t.expires_at ? new Date(t.expires_at) : null, + createdAt: new Date(t.created_at), + })); + }), + + create: protectedProcedure + .input(createMcpTokenSchema) + .mutation(async ({ ctx, input }) => { + const { token, hash, prefix } = generateMcpToken(); + const expiresAt = input.expiresInDays + ? new Date(Date.now() + input.expiresInDays * 86_400_000).toISOString() + : null; + + const { data, error } = await ctx.db + .from("mcp_tokens") + .insert({ + user_id: ctx.user.id, + name: input.name, + token_hash: hash, + token_prefix: prefix, + scopes: ["*"], + expires_at: expiresAt, + }) + .select("id, name, token_prefix, expires_at, created_at") + .single(); + + if (error) throw new Error(`Failed to create token: ${error.message}`); + + // The one and only time the plaintext token leaves the server. + return { + token, + id: data.id, + name: data.name, + prefix: data.token_prefix, + expiresAt: data.expires_at ? new Date(data.expires_at) : null, + createdAt: new Date(data.created_at), + }; + }), + + revoke: protectedProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + const { error } = await ctx.db + .from("mcp_tokens") + .update({ revoked_at: new Date().toISOString() }) + .eq("id", input.id) + .eq("user_id", ctx.user.id); + + if (error) throw new Error(`Failed to revoke token: ${error.message}`); + return { success: true as const, id: input.id }; + }), +}); diff --git a/supabase/migrations/20260720000000_add_mcp_tokens.sql b/supabase/migrations/20260720000000_add_mcp_tokens.sql new file mode 100644 index 0000000..f8fbd4d --- /dev/null +++ b/supabase/migrations/20260720000000_add_mcp_tokens.sql @@ -0,0 +1,103 @@ +-- MCP personal access tokens. +-- +-- Lets a user connect their own AI agent (Claude Code, Cursor, custom agents) +-- to their GrindProof account via a bearer token pasted into the MCP client. +-- The token itself is a high-entropy secret shown to the user exactly once; +-- only its SHA-256 hash is stored, so a leaked DB row cannot be replayed. +-- +-- Auth flow: the MCP route resolves `Bearer ` -> sha256 -> user_id via +-- the service-role client (this lookup runs BEFORE a user context exists), then +-- mints a short-lived user-scoped JWT so every downstream query is subject to +-- the same RLS policies as the cookie-scoped app. See src/lib/mcp/auth.ts and +-- src/lib/supabase/scoped.ts. + +CREATE TABLE mcp_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + name TEXT NOT NULL, -- user label, e.g. "Claude Code laptop" + token_hash TEXT NOT NULL UNIQUE, -- SHA-256 hex of the full token + token_prefix TEXT NOT NULL, -- first chars for display, e.g. "gp_mcp_ab12" + scopes TEXT[] NOT NULL DEFAULT '{}', -- forward-compat; v1 grants '*' + last_used_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, -- NULL = never expires + revoked_at TIMESTAMPTZ, -- NULL = active + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Auth hot path: exact-match lookup by hash (also enforced UNIQUE above). +CREATE INDEX idx_mcp_tokens_hash ON mcp_tokens (token_hash); + +-- Settings list: a user's tokens, newest first. +CREATE INDEX idx_mcp_tokens_user_created ON mcp_tokens (user_id, created_at DESC); + +-- ============================================================ +-- RLS: owner-scoped, mirroring the uniform template used across +-- goals/tasks/etc. Lets the cookie-scoped settings UI (anon key + +-- protectedProcedure) manage a user's own tokens. The service-role +-- auth lookup bypasses RLS, which is required because it runs before +-- a user is known. +-- ============================================================ +ALTER TABLE mcp_tokens ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can view their own mcp tokens" + ON mcp_tokens FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert their own mcp tokens" + ON mcp_tokens FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update their own mcp tokens" + ON mcp_tokens FOR UPDATE + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can delete their own mcp tokens" + ON mcp_tokens FOR DELETE + USING (auth.uid() = user_id); + +-- ============================================================ +-- Per-token rate limiting (lightweight fixed-window counter). +-- No external infra (repo has no Upstash Redis). A single atomic +-- UPSERT per request buckets by (token, window). Upstash sliding +-- window is the documented v2 upgrade. +-- ============================================================ +CREATE TABLE mcp_rate_limits ( + token_id UUID NOT NULL REFERENCES mcp_tokens(id) ON DELETE CASCADE, + window_start TIMESTAMPTZ NOT NULL, + request_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (token_id, window_start) +); + +-- Locked down: only the SECURITY DEFINER RPC (and the service role) touch it. +ALTER TABLE mcp_rate_limits ENABLE ROW LEVEL SECURITY; + +-- Atomically record one request against a token's current window and report +-- whether the token is still under its cap. Fixed-window: bucket = floor(now / +-- window). Returns TRUE when allowed, FALSE when the cap is exceeded. +CREATE OR REPLACE FUNCTION mcp_touch_rate_limit( + p_token_id UUID, + p_window_seconds INTEGER, + p_max INTEGER +) RETURNS BOOLEAN +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_bucket TIMESTAMPTZ; + v_count INTEGER; +BEGIN + v_bucket := to_timestamp( + floor(extract(epoch FROM now()) / p_window_seconds) * p_window_seconds + ); + + INSERT INTO mcp_rate_limits (token_id, window_start, request_count) + VALUES (p_token_id, v_bucket, 1) + ON CONFLICT (token_id, window_start) + DO UPDATE SET request_count = mcp_rate_limits.request_count + 1 + RETURNING request_count INTO v_count; + + RETURN v_count <= p_max; +END; +$$;