diff --git a/.dockerignore b/.dockerignore index 35e9b36..9c21713 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,6 +16,7 @@ build .next out www +mcp # Environment variables .env diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 830d23d..d6fd8ac 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -153,6 +153,13 @@ jobs: bun run build echo "✓ Build complete" + - name: Build MCP bridge + working-directory: mcp + run: | + npm ci + npm run build + echo "✓ MCP bridge built" + - name: Start fake GitHub API server run: | # Start with GIT_SERVER_URL pointing to the git-server container name diff --git a/.github/workflows/mcp.yml b/.github/workflows/mcp.yml new file mode 100644 index 0000000..14decd3 --- /dev/null +++ b/.github/workflows/mcp.yml @@ -0,0 +1,120 @@ +name: MCP Server + +# Tests the stdio bridge in mcp/ and publishes it to npm as gitea-mirror-mcp +# when an mcp-v* tag is pushed. Path filters do not apply to tag pushes. The +# tools themselves live in the app (src/lib/mcp) and are tested with it. +on: + push: + branches: ['**'] + tags: ['mcp-v*'] + paths: + - 'mcp/**' + - '.github/workflows/mcp.yml' + pull_request: + branches: ['**'] + paths: + - 'mcp/**' + - '.github/workflows/mcp.yml' + +permissions: + contents: read + +env: + BUN_VERSION: '1.3.13' + NODE_VERSION: '22' + +jobs: + test: + name: Test MCP bridge + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: mcp + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Run tests + run: npm test + + - name: Build + run: npm run build + + - name: Smoke test the build on Node + # No app runs here, so the bridge must answer the request itself + # with a clear error instead of hanging or crashing. + run: | + printf '%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"ci","version":"0"}}}' \ + | GITEA_MIRROR_URL=http://127.0.0.1:9 GITEA_MIRROR_API_KEY=gm_smoke timeout 20 node dist/index.js > smoke.jsonl + node -e ' + const lines = require("fs").readFileSync("smoke.jsonl", "utf8").trim().split("\n").map(JSON.parse); + const answer = lines.find((m) => m.id === 1); + if (!answer?.error?.message?.includes("Could not reach Gitea Mirror")) { console.error(lines); process.exit(1); } + console.log(answer.error.message); + ' + + - name: Show package contents + run: npm pack --dry-run + + publish: + name: Publish to npm + needs: test + if: startsWith(github.ref, 'refs/tags/mcp-v') + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + id-token: write + defaults: + run: + working-directory: mcp + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: 'https://registry.npmjs.org' + + - name: Check the tag matches the package version + run: | + version=$(node -p 'require("./package.json").version') + if [ "mcp-v${version}" != "${GITHUB_REF_NAME}" ]; then + echo "::error::Tag ${GITHUB_REF_NAME} does not match mcp/package.json version ${version}" + exit 1 + fi + + - name: Install dependencies + run: npm ci + + - name: Publish + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 35c3824..7cc6c12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Raised the dependency floors for the advisories published on 2026-09-08: Astro 7.2.8 (remote code execution through AVIF image optimization, and an authorization bypass when stripping the configured base), @xmldom/xmldom 0.8.15 (eight parser and serializer issues), sharp 0.35.4 (libheif), svgo 4.1.0 (removeScripts sanitization) and js-yaml 4.3.2 (merge-key CPU use). Applied to both the application and the documentation site. ### Added +- Read only API keys + - **Create key** in the **API Keys** card has an **Access** choice, and read only keys show a badge in the list + - A read only key can call every `GET` endpoint; any `POST`, `PUT`, `PATCH` or `DELETE`, and every `/api/auth/*` call except `get-session`, gets `403` with the code `API_KEY_READ_ONLY` + - Existing keys keep full access +- An MCP endpoint at `/api/mcp` for Claude, Cursor and other MCP clients + - Takes an API key in `x-api-key`; clients that send headers to a remote server (Claude Code) connect to it directly + - Eight tools: `list_repositories`, `get_repository`, `list_failures`, `list_organizations`, `add_repository`, `add_organization`, `sync_repositories` and `retry_repositories`. None of them delete, archive or change settings + - The tools act through the REST API with the caller's key, so a read only key can look but every change is refused. `MCP_INTERNAL_URL` sets the address they use when the app is not reachable on its own `HOST` and `PORT` + - `gitea-mirror-mcp` in `mcp/` is a stdio bridge for clients that start a local command (Claude Desktop, Cursor). It has no dependencies and is published to npm from `mcp-v*` tags; setup is in `mcp/README.md` and on the documentation site - Choose which releases count as candidates: every one, or stable releases only - A **Pre-releases** switch sits under the two release numbers in the mirror settings, and the same choice is in the per organization and per repository **Mirror options** dialog, where **Inherit** means the global value - The release controls in the global mirror settings are listed under each other instead of beside the switch, and in the **Mirror options** dialog the release limit, asset limit and tag rules sit directly under the **Releases** row instead of on a separate tab diff --git a/docs/API.md b/docs/API.md index 2f07cfd..299b88d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -5,10 +5,18 @@ Gitea Mirror's web UI talks to a set of JSON endpoints under `/api`. With an API ## Create a key 1. Sign in and open **Settings → Authentication**. -2. In the **API Keys** section click **Create key**, give it a name and pick an expiry (never, 30 days, 90 days or a year). +2. In the **API Keys** section click **Create key**, give it a name and pick an expiry (never, 30 days, 90 days or a year) and an access level (full access or read only). 3. Copy the key. It is shown once; afterwards only its first characters are visible in the list. -Keys start with `gm_`, belong to the user who created them and carry the same rights as that user's session. Revoke a key from the same list at any time. Keys are stored hashed. They are not rate limited, so a runaway script is stopped by revoking its key. +Keys start with `gm_` and belong to the user who created them. A full access key carries the same rights as that user's session. Revoke a key from the same list at any time. Keys are stored hashed. They are not rate limited, so a runaway script is stopped by revoking its key. + +Pick **Read only** under **Access** for a key that only needs to look: it can call every `GET` endpoint and nothing else. Any `POST`, `PUT`, `PATCH` or `DELETE` with a read only key gets `403`: + +```json +{ "success": false, "error": "This API key is read only. Use a key with full access to make changes.", "code": "API_KEY_READ_ONLY" } +``` + +Use a read only key for dashboards, monitoring and the [MCP server](../mcp/README.md) when you only want an assistant to answer questions. A key's access level cannot be changed after it is created; create a new key instead. ## Send the key @@ -294,8 +302,8 @@ Manage keys through the settings UI or with a browser session. The key endpoints | Call | Body | Result | | --- | --- | --- | -| `POST /api/auth/api-key/create` | `{ "name": "ci", "expiresIn": 2592000 }` (`expiresIn` in seconds, omit for no expiry) | The new key, including `key`, shown only here. | -| `GET /api/auth/api-key/list` | | `{ "apiKeys": [...], "total": n }`. Each entry has `id`, `name`, `start`, `createdAt`, `lastRequest` and `expiresAt`, never the secret. | +| `POST /api/auth/api-key/create` | `{ "name": "ci", "expiresIn": 2592000 }` (`expiresIn` in seconds, omit for no expiry). Add `"metadata": { "access": "read" }` for a read only key. | The new key, including `key`, shown only here. | +| `GET /api/auth/api-key/list` | | `{ "apiKeys": [...], "total": n }`. Each entry has `id`, `name`, `start`, `createdAt`, `lastRequest`, `expiresAt` and `metadata` (`{ "access": "read" }` on read only keys), never the secret. | | `POST /api/auth/api-key/delete` | `{ "keyId": "..." }` | Revokes the key. | ## Notes diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index bc2ba89..ff53cdf 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -35,6 +35,7 @@ Essential application settings required for running Gitea Mirror. | `HOST` | Server host binding | `0.0.0.0` | No | | `PORT` | Server port | `4321` | No | | `BASE_URL` | Application base path. Use `/` for root deployments, or a prefix such as `/mirror` when serving behind a reverse-proxy path prefix. | `/` | No | +| `MCP_INTERNAL_URL` | Address the [MCP endpoint](../mcp/README.md) tools use to call the app's own API, including any `BASE_URL` prefix. Only needed when the app is not reachable from itself at `HOST` and `PORT`, for example when it terminates TLS itself (`https://127.0.0.1:4321`). | `http://HOST:PORT` plus `BASE_URL` | No | | `DATABASE_URL` | Database connection URL | `sqlite://data/gitea-mirror.db` | No | | `BETTER_AUTH_SECRET` | Secret key for session signing (generate with: `openssl rand -base64 32`) | - | Yes | | `BETTER_AUTH_URL` | Authentication origin (scheme + host only, e.g. `https://git.example.com`). Do **not** include a path — any path is automatically stripped, and `BASE_URL` is applied separately. | `http://localhost:4321` | No | diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 0000000..75291b8 --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,97 @@ +# gitea-mirror-mcp + +Connects AI assistants to [Gitea Mirror](https://github.com/RayLabsHQ/gitea-mirror) through [MCP](https://modelcontextprotocol.io), so Claude, Cursor or any other MCP client can check on your mirrors, find failures, add repositories and organizations, and start syncs and retries. + +The tools live in Gitea Mirror itself, at `/api/mcp`, and act through its REST API with an API key. There are two ways in: + +- **Over HTTP**, for clients that can send a header with a remote server (Claude Code): point them at `https://mirror.example.com/api/mcp` with the key in `x-api-key`. Nothing to install. +- **Through this package**, for clients that start a local command (Claude Desktop, Cursor): `gitea-mirror-mcp` passes each message to the endpoint and the answer back. It has no dependencies. + +## Requirements + +- Gitea Mirror 3.46.0 or later. +- An API key. In Gitea Mirror open the Configuration page, the **Authentication** tab, then **Create key** in the **API Keys** card. +- Node.js 20 or later, only for this package. + +Pick the key's access with care. The assistant can do whatever the key allows: + +- **Read only**: the assistant can list and inspect, nothing else. Every tool that changes something gets a clear refusal. +- **Full access**: the assistant can also add repositories and organizations and start mirrors, syncs and retries. None of the tools delete, archive or change settings, but the key itself can do anything your account can do through the API, so keep it out of shared config files. + +## Setup + +### Claude Code + +```bash +claude mcp add --transport http gitea-mirror https://mirror.example.com/api/mcp \ + --header "x-api-key: gm_..." +``` + +### Claude Desktop, Cursor and other clients + +Add the server to the client's MCP config (`claude_desktop_config.json` for Claude Desktop, `~/.cursor/mcp.json` for Cursor): + +```json +{ + "mcpServers": { + "gitea-mirror": { + "command": "npx", + "args": ["-y", "gitea-mirror-mcp"], + "env": { + "GITEA_MIRROR_URL": "https://mirror.example.com", + "GITEA_MIRROR_API_KEY": "gm_..." + } + } + } +} +``` + +| Variable | Value | +| --- | --- | +| `GITEA_MIRROR_URL` | The address you open in the browser, for example `https://mirror.example.com`. Include the path prefix if the app runs under one (`https://example.com/mirror`). | +| `GITEA_MIRROR_API_KEY` | The `gm_...` key. | + +A client that supports remote servers with custom headers can skip the package and use the URL directly, like Claude Code above. + +### From a checkout + +```bash +cd mcp +npm ci +npm run build # needs Bun +``` + +Then use `node /path/to/gitea-mirror/mcp/dist/index.js` as the command instead of `npx -y gitea-mirror-mcp`. + +## Tools + +| Tool | What it does | +| --- | --- | +| `list_repositories` | Tracked repositories with their status, newest first. Filter by `status` or `search` on owner/name; `limit` up to 200. | +| `get_repository` | One repository by id or owner/name: status, where it is mirrored to, the last error and its recent activity. | +| `list_failures` | Repositories whose last mirror or sync failed, with the error. | +| `list_organizations` | Tracked organizations with their status and repository count. | +| `add_repository` | Track a repository from a browser, clone or SSH URL (or `owner/name` for the default source) and start mirroring it. Optional `destinationOrg`; `mirror: false` only tracks it. Adding one that is already tracked returns it. | +| `add_organization` | Track an organization and its repositories and start mirroring them. `mirror: false` only tracks it. | +| `sync_repositories` | Pull the latest changes into repositories that are already mirrored (up to 50 ids). | +| `retry_repositories` | Retry failed repositories, or start ones that were added but never mirrored (up to 50 ids). | + +The first four only read. Mirrors, syncs and retries run in the background in Gitea Mirror; the tool returns once the job has started, so ask the assistant to check `get_repository` or `list_failures` afterwards. + +Things you might ask: + +- "Which of my mirrors are failing, and why?" +- "Mirror https://github.com/octocat/hello-world into the archive org." +- "Retry everything that failed with a timeout." +- "When did octocat/hello-world last sync?" + +## Notes + +- The tools call the app's REST API from inside the app, at the address it listens on (`HOST` and `PORT`, under `BASE_URL`). If that address is not reachable from the app itself, set `MCP_INTERNAL_URL` on the server, for example `http://127.0.0.1:4321`. +- Repository names, error messages and activity text come from your source and destination hosts. The server tells the assistant to treat them as data, but review what an assistant proposes before it acts on a full access key. +- This package logs to stderr only and never prints the key. +- Anything the tools do not cover is available through the [REST API](https://github.com/RayLabsHQ/gitea-mirror/blob/main/docs/API.md) with the same key. + +## License + +AGPL-3.0, like Gitea Mirror. diff --git a/mcp/package-lock.json b/mcp/package-lock.json new file mode 100644 index 0000000..cabc560 --- /dev/null +++ b/mcp/package-lock.json @@ -0,0 +1,436 @@ +{ + "name": "gitea-mirror-mcp", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gitea-mirror-mcp", + "version": "0.1.0", + "license": "AGPL-3.0", + "bin": { + "gitea-mirror-mcp": "dist/index.js" + }, + "devDependencies": { + "@types/bun": "1.3.14", + "@types/node": "26.1.1", + "typescript": "7.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@types/bun": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.14.tgz", + "integrity": "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.3.14" + } + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/bun-types": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.14.tgz", + "integrity": "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/mcp/package.json b/mcp/package.json new file mode 100644 index 0000000..28eda1a --- /dev/null +++ b/mcp/package.json @@ -0,0 +1,40 @@ +{ + "name": "gitea-mirror-mcp", + "version": "0.1.0", + "description": "Connect stdio MCP clients to Gitea Mirror: list, add, sync and retry mirrors from an AI assistant, using an API key", + "license": "AGPL-3.0", + "type": "module", + "bin": { + "gitea-mirror-mcp": "dist/index.js" + }, + "files": [ + "dist", + "README.md" + ], + "engines": { + "node": ">=20" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/RayLabsHQ/gitea-mirror.git", + "directory": "mcp" + }, + "homepage": "https://github.com/RayLabsHQ/gitea-mirror/tree/main/mcp#readme", + "keywords": [ + "mcp", + "gitea", + "forgejo", + "mirror" + ], + "scripts": { + "build": "bun build src/index.ts --target=node --format=esm --banner='#!/usr/bin/env node' --outfile=dist/index.js", + "typecheck": "tsc --noEmit -p tsconfig.json", + "test": "bun test", + "prepublishOnly": "npm run typecheck && npm test && npm run build" + }, + "devDependencies": { + "@types/bun": "1.3.14", + "@types/node": "26.1.1", + "typescript": "7.0.2" + } +} diff --git a/mcp/src/bridge.test.ts b/mcp/src/bridge.test.ts new file mode 100644 index 0000000..6a9413a --- /dev/null +++ b/mcp/src/bridge.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test"; +import { createBridge, type FetchLike } from "./bridge"; + +const BASE_URL = "http://mirror.test/base"; +const API_KEY = "gm_test-key"; + +function bridgeWith(answer: FetchLike) { + const sent: any[] = []; + const logged: string[] = []; + const requests: Array<{ url: string; init?: RequestInit }> = []; + const bridge = createBridge({ + baseUrl: BASE_URL, + apiKey: API_KEY, + send: (message) => sent.push(message), + log: (text) => logged.push(text), + fetch: async (url, init) => { + requests.push({ url, init }); + return answer(url, init); + }, + }); + return { bridge, sent, logged, requests }; +} + +const ping = JSON.stringify({ jsonrpc: "2.0", id: 7, method: "ping" }); + +describe("bridge", () => { + test("posts each message to the endpoint with the key and writes the answer back", async () => { + const { bridge, sent, requests } = bridgeWith(async () => Response.json({ jsonrpc: "2.0", id: 7, result: {} })); + await bridge.relay(ping); + + expect(requests).toHaveLength(1); + expect(requests[0].url).toBe(`${BASE_URL}/api/mcp`); + const headers = new Headers(requests[0].init?.headers); + expect(headers.get("x-api-key")).toBe(API_KEY); + expect(headers.get("content-type")).toBe("application/json"); + expect(requests[0].init?.body).toBe(ping); + expect(sent).toEqual([{ jsonrpc: "2.0", id: 7, result: {} }]); + }); + + test("a notification the endpoint accepts writes nothing", async () => { + const { bridge, sent } = bridgeWith(async () => new Response(null, { status: 202 })); + await bridge.relay(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })); + expect(sent).toEqual([]); + }); + + test("a rejected key comes back as an error for the request that hit it", async () => { + const { bridge, sent } = bridgeWith(async () => + Response.json( + { jsonrpc: "2.0", id: null, error: { code: -32001, message: "Gitea Mirror rejected the API key." } }, + { status: 401 } + ) + ); + await bridge.relay(ping); + expect(sent).toEqual([{ jsonrpc: "2.0", id: 7, error: { code: -32001, message: "Gitea Mirror rejected the API key." } }]); + }); + + test("an unreachable app answers the request without echoing the key", async () => { + const { bridge, sent } = bridgeWith(async () => { + throw new TypeError("fetch failed"); + }); + await bridge.relay(ping); + expect(sent[0].id).toBe(7); + expect(sent[0].error.message).toBe(`Could not reach Gitea Mirror at ${BASE_URL}: fetch failed`); + expect(JSON.stringify(sent)).not.toContain(API_KEY); + }); + + test("a slow app is reported as a timeout", async () => { + const sent: any[] = []; + const bridge = createBridge({ + baseUrl: BASE_URL, + apiKey: API_KEY, + send: (message) => sent.push(message), + log: () => {}, + timeoutMs: 50, + fetch: (_url, init) => + new Promise((_resolve, reject) => init?.signal?.addEventListener("abort", () => reject(init.signal!.reason))), + }); + await bridge.relay(ping); + expect(sent[0].error.message).toBe(`Gitea Mirror at ${BASE_URL} did not answer within 0.05 seconds.`); + }); + + test("an app without the endpoint is told to update", async () => { + const { bridge, sent } = bridgeWith(async () => + Response.json({ success: false, error: "No API endpoint for POST /api/mcp." }, { status: 404 }) + ); + await bridge.relay(ping); + expect(sent[0].error.message).toContain("has no MCP endpoint. Update it to 3.46.0 or later"); + }); + + test("an HTML page points at the URL", async () => { + const { bridge, sent } = bridgeWith(async () => new Response("", { status: 200 })); + await bridge.relay(ping); + expect(sent[0].error.message).toContain("Check that GITEA_MIRROR_URL is the address of the app"); + }); + + test("a failed notification is logged, not sent", async () => { + const { bridge, sent, logged } = bridgeWith(async () => { + throw new TypeError("fetch failed"); + }); + await bridge.relay(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })); + expect(sent).toEqual([]); + expect(logged[0]).toContain("Could not reach Gitea Mirror"); + }); + + test("a line that is not JSON gets a parse error and no request", async () => { + const { bridge, sent, requests } = bridgeWith(async () => Response.json({})); + await bridge.relay("{not json"); + await bridge.relay(" "); + expect(requests).toHaveLength(0); + expect(sent).toEqual([{ jsonrpc: "2.0", id: null, error: { code: -32700, message: "The message is not JSON." } }]); + }); +}); diff --git a/mcp/src/bridge.ts b/mcp/src/bridge.ts new file mode 100644 index 0000000..4def8ee --- /dev/null +++ b/mcp/src/bridge.ts @@ -0,0 +1,118 @@ +/** + * Relays MCP messages between a stdio client and the app's MCP endpoint. + * The tools live in Gitea Mirror itself (POST /api/mcp), so this side only + * forwards each JSON-RPC line with the API key and writes back the answer. + */ + +export type FetchLike = (input: string, init?: RequestInit) => Promise; + +export interface BridgeOptions { + /** App origin plus any BASE_URL prefix, without a trailing slash. */ + baseUrl: string; + apiKey: string; + /** Writes one JSON-RPC message to the client (stdout). */ + send: (message: unknown) => void; + /** Reports something to the person running the client (stderr). */ + log: (text: string) => void; + fetch?: FetchLike; + timeoutMs?: number; +} + +type JsonRpcId = string | number | null; + +const DEFAULT_TIMEOUT_MS = 60_000; + +function requestId(message: unknown): JsonRpcId | undefined { + if (!message || typeof message !== "object" || Array.isArray(message)) return undefined; + const { id, method } = message as { id?: unknown; method?: unknown }; + if (typeof method !== "string") return undefined; + return typeof id === "string" || typeof id === "number" ? id : undefined; +} + +export function createBridge(options: BridgeOptions) { + const endpoint = `${options.baseUrl}/api/mcp`; + const fetchImpl = options.fetch ?? ((input, init) => fetch(input, init)); + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + /** Answer a request with an error, or log it when the message was a notification. */ + function fail(id: JsonRpcId | undefined, text: string): void { + if (id === undefined) { + options.log(text); + return; + } + options.send({ jsonrpc: "2.0", id, error: { code: -32000, message: text } }); + } + + async function relay(line: string): Promise { + if (!line.trim()) return; + + let message: unknown; + try { + message = JSON.parse(line); + } catch { + options.send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "The message is not JSON." } }); + return; + } + const id = requestId(message); + + let response: Response; + try { + response = await fetchImpl(endpoint, { + method: "POST", + headers: { + "x-api-key": options.apiKey, + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: line, + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (error) { + if (error instanceof Error && error.name === "TimeoutError") { + fail(id, `Gitea Mirror at ${options.baseUrl} did not answer within ${timeoutMs / 1000} seconds.`); + return; + } + const reason = error instanceof Error ? error.message : String(error); + fail(id, `Could not reach Gitea Mirror at ${options.baseUrl}: ${reason}`); + return; + } + + if (response.status === 202) return; + + const text = await response.text(); + let body: unknown; + try { + body = JSON.parse(text); + } catch { + fail( + id, + `${endpoint} answered ${response.status} with something other than JSON. Check that GITEA_MIRROR_URL is the address of the app, including any path prefix.` + ); + return; + } + + const isRpc = !!body && typeof body === "object" && (body as { jsonrpc?: unknown }).jsonrpc === "2.0"; + if (!isRpc) { + fail( + id, + response.status === 404 + ? `Gitea Mirror at ${options.baseUrl} has no MCP endpoint. Update it to 3.46.0 or later, and check that GITEA_MIRROR_URL includes any path prefix.` + : `${endpoint} answered ${response.status}: ${text.slice(0, 300)}` + ); + return; + } + + // Errors the endpoint raises before reading the message (a rejected key) + // carry no id; give them the request's so the client can match them. + const rpc = body as { id?: JsonRpcId; error?: { message?: string } }; + if (rpc.id === null && id !== undefined) { + options.send({ ...rpc, id }); + } else if (id === undefined && rpc.error) { + options.log(rpc.error.message ?? JSON.stringify(rpc.error)); + } else { + options.send(rpc); + } + } + + return { endpoint, relay }; +} diff --git a/mcp/src/config.test.ts b/mcp/src/config.test.ts new file mode 100644 index 0000000..ee730c2 --- /dev/null +++ b/mcp/src/config.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; +import { ConfigError, readConfig } from "./config"; + +const KEY = "gm_test"; + +describe("readConfig", () => { + test("keeps the origin and the BASE_URL prefix, without a trailing slash", () => { + expect(readConfig({ GITEA_MIRROR_URL: "https://mirror.example.com/", GITEA_MIRROR_API_KEY: KEY })).toEqual({ + baseUrl: "https://mirror.example.com", + apiKey: KEY, + }); + expect( + readConfig({ GITEA_MIRROR_URL: " http://nas.local:4321/mirror// ", GITEA_MIRROR_API_KEY: ` ${KEY} ` }) + ).toEqual({ baseUrl: "http://nas.local:4321/mirror", apiKey: KEY }); + }); + + test("needs both variables", () => { + expect(() => readConfig({ GITEA_MIRROR_API_KEY: KEY })).toThrow(/GITEA_MIRROR_URL is not set/); + expect(() => readConfig({ GITEA_MIRROR_URL: "https://mirror.example.com" })).toThrow( + /GITEA_MIRROR_API_KEY is not set/ + ); + expect(() => readConfig({ GITEA_MIRROR_URL: "https://mirror.example.com", GITEA_MIRROR_API_KEY: " " })).toThrow( + ConfigError + ); + }); + + test("rejects addresses that are not a plain http(s) URL", () => { + expect(() => readConfig({ GITEA_MIRROR_URL: "mirror.example.com", GITEA_MIRROR_API_KEY: KEY })).toThrow( + /not a URL/ + ); + expect(() => readConfig({ GITEA_MIRROR_URL: "ftp://mirror.example.com", GITEA_MIRROR_API_KEY: KEY })).toThrow( + /http:\/\/ or https:\/\// + ); + expect(() => + readConfig({ GITEA_MIRROR_URL: "https://mirror.example.com/?x=1", GITEA_MIRROR_API_KEY: KEY }) + ).toThrow(/without credentials, a query/); + expect(() => + readConfig({ GITEA_MIRROR_URL: "https://user:pw@mirror.example.com", GITEA_MIRROR_API_KEY: KEY }) + ).toThrow(ConfigError); + }); +}); diff --git a/mcp/src/config.ts b/mcp/src/config.ts new file mode 100644 index 0000000..cbe4f88 --- /dev/null +++ b/mcp/src/config.ts @@ -0,0 +1,53 @@ +/** + * Settings come from the environment the MCP client starts the bridge with, + * so the key never appears in a tool call or a prompt. + */ + +export interface McpConfig { + /** App origin plus any BASE_URL prefix, without a trailing slash. */ + baseUrl: string; + apiKey: string; +} + +export class ConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "ConfigError"; + } +} + +export function readConfig(env: Record): McpConfig { + const rawUrl = env.GITEA_MIRROR_URL?.trim(); + if (!rawUrl) { + throw new ConfigError( + "GITEA_MIRROR_URL is not set. Point it at your Gitea Mirror, for example https://mirror.example.com." + ); + } + + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new ConfigError(`GITEA_MIRROR_URL is not a URL: ${rawUrl}`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new ConfigError("GITEA_MIRROR_URL must start with http:// or https://."); + } + if (url.username || url.password || url.search || url.hash) { + throw new ConfigError( + "GITEA_MIRROR_URL should be the address you open in the browser, without credentials, a query or a #fragment." + ); + } + + const apiKey = env.GITEA_MIRROR_API_KEY?.trim(); + if (!apiKey) { + throw new ConfigError( + "GITEA_MIRROR_API_KEY is not set. Create a key under Configuration, Authentication, API Keys." + ); + } + + return { + baseUrl: `${url.origin}${url.pathname.replace(/\/+$/, "")}`, + apiKey, + }; +} diff --git a/mcp/src/index.ts b/mcp/src/index.ts new file mode 100644 index 0000000..fec054d --- /dev/null +++ b/mcp/src/index.ts @@ -0,0 +1,41 @@ +import { createInterface } from "node:readline"; +import { createBridge } from "./bridge"; +import { ConfigError, readConfig } from "./config"; + +// stdout carries the protocol, so every message for people goes to stderr. +function main(): void { + let config; + try { + config = readConfig(process.env); + } catch (error) { + if (error instanceof ConfigError) { + console.error(`gitea-mirror-mcp: ${error.message}`); + process.exit(1); + } + throw error; + } + + const bridge = createBridge({ + ...config, + send: (message) => process.stdout.write(`${JSON.stringify(message)}\n`), + log: (text) => console.error(`gitea-mirror-mcp: ${text}`), + }); + console.error(`gitea-mirror-mcp: relaying to ${bridge.endpoint}`); + + // Messages are relayed as they arrive; answers carry their request id, so + // they may go back in any order. + const pending = new Set>(); + const lines = createInterface({ input: process.stdin, crlfDelay: Infinity }); + lines.on("line", (line) => { + const relayed = bridge.relay(line).finally(() => pending.delete(relayed)); + pending.add(relayed); + }); + lines.on("close", async () => { + await Promise.allSettled([...pending]); + // Writes to a pipe are asynchronous on macOS; exit once the last + // answer has been flushed. + process.stdout.write("", () => process.exit(0)); + }); +} + +main(); diff --git a/mcp/tsconfig.json b/mcp/tsconfig.json new file mode 100644 index 0000000..2e0fb8e --- /dev/null +++ b/mcp/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": [ + "bun", + "node" + ] + }, + "include": [ + "src" + ] +} diff --git a/src/components/config/ApiKeysSettings.tsx b/src/components/config/ApiKeysSettings.tsx index 117593f..1e5c453 100644 --- a/src/components/config/ApiKeysSettings.tsx +++ b/src/components/config/ApiKeysSettings.tsx @@ -18,19 +18,24 @@ import { SelectValue, } from '@/components/ui/select'; import { Skeleton } from '@/components/ui/skeleton'; +import { Badge } from '@/components/ui/badge'; import { toast } from 'sonner'; import { Check, Copy, Info, Key, Loader2, Plus, Trash2 } from 'lucide-react'; import { authClient } from '@/lib/auth-client'; import { cn, formatDateShort, formatLastSyncTime } from '@/lib/utils'; import { + API_KEY_ACCESS_OPTIONS, API_KEY_EXPIRY_OPTIONS, API_KEY_HEADER, API_KEY_NAME_MAX_LENGTH, + accessToKeyMetadata, describeKeyExpiry, expiryOptionToSeconds, isKeyExpired, + keyAccessFromMetadata, maskKeyStart, normalizeKeyName, + type ApiKeyAccess, type ApiKeyExpiryOption, } from '@/lib/api-keys'; @@ -45,6 +50,7 @@ interface ApiKeyRow { lastRequest: string | Date | null; expiresAt: string | Date | null; enabled: boolean; + metadata?: unknown; } function toRows(data: unknown): ApiKeyRow[] { @@ -70,9 +76,9 @@ function errorMessage(error: unknown, fallback: string): string { /** * Personal API keys for scripts and CI (issue #314). Keys belong to the - * signed-in user and carry the same rights as their session. The secret - * is shown once, right after creation, and only its first characters are - * stored afterwards. + * signed-in user and carry the same rights as their session, unless made + * read only. The secret is shown once, right after creation, and only its + * first characters are stored afterwards. */ export function ApiKeysSettings() { const [keys, setKeys] = useState([]); @@ -82,6 +88,7 @@ export function ApiKeysSettings() { const [dialogOpen, setDialogOpen] = useState(false); const [name, setName] = useState(''); const [expiry, setExpiry] = useState('never'); + const [access, setAccess] = useState('full'); const [nameError, setNameError] = useState(null); const [isCreating, setIsCreating] = useState(false); const [createdKey, setCreatedKey] = useState<{ name: string; secret: string } | null>(null); @@ -113,6 +120,7 @@ export function ApiKeysSettings() { const resetDialog = () => { setName(''); setExpiry('never'); + setAccess('full'); setNameError(null); setCreatedKey(null); setCopied(false); @@ -135,9 +143,11 @@ export function ApiKeysSettings() { setIsCreating(true); try { const expiresIn = expiryOptionToSeconds(expiry); + const metadata = accessToKeyMetadata(access); const result = await authClient.apiKey.create({ name: normalized.value, ...(expiresIn !== undefined ? { expiresIn } : {}), + ...(metadata ? { metadata } : {}), }); if (result.error || !result.data?.key) { toast.error(errorMessage(result.error, 'Could not create the API key.')); @@ -235,7 +245,8 @@ export function ApiKeysSettings() { Create API key - The key acts as you: anything your account can do through the app, a script holding this key can do too. + A full access key acts as you: anything your account can do through the app, a script holding it can do + too. A read only key can look but not change anything.
@@ -272,6 +283,24 @@ export function ApiKeysSettings() {
+
+ + +

+ {API_KEY_ACCESS_OPTIONS.find((option) => option.value === access)?.description} +

+