diff --git a/.env.example b/.env.example index b6c1ad4..b64f9a6 100644 --- a/.env.example +++ b/.env.example @@ -85,17 +85,29 @@ NITRO_PORT=3000 # the server creates it `0750` and writes JSONL files `0640`. Operators # MUST configure rotation/retention (logrotate or find -mtime) — see # docs/DEPLOYMENT.md "Monitoring & logs" and docs/SECURITY-AUDIT.md. -# A webhook-only (Phase-1) deploy NEVER writes here — leave commented -# until the OAuth flow (Phase-3) is in scope. +# A webhook-only deploy NEVER writes here — leave commented until OAuth +# is enabled (`NUXT_BITRIX24_OAUTH_ENABLED=true`). # NUXT_AUDIT_DIR=/data/audit -# Bitrix24 OAuth 2.0 / multi-tenant (Phase-3, opt-in). The full surface -# (install/callback routes, token store, refresh logic) is staged across -# PR-2a..PR-2c — see `docs/OAUTH-DESIGN.md`. Until then this flag should -# stay `false`: with `=true` AND no OAuth wiring landed, every tool call -# refuses to dispatch (loud failure, not silent fallback). With `=false` -# (default) tools transparently keep using the webhook singleton. -# Enable only AFTER PR-2c merges and you have: +# Bitrix24 OAuth 2.0 / multi-tenant (opt-in). The full surface is LANDED +# and end-to-end live behind this flag — see `docs/OAUTH-DESIGN.md` and the +# "OAuth 2.0 multi-tenant" section of `docs/DEPLOYMENT.md`. With `=true`, +# each end user authorises via `/api/oauth/install` and every REST call runs +# under THAT user's Bitrix24 identity (no shared service user). With `=false` +# (the default) tools transparently keep using the webhook singleton — a +# webhook-only fork sees zero behaviour change. +# +# ⚠ MIGRATION — READ BEFORE FLIPPING THE FLAG. When NUXT_BITRIX24_OAUTH_ENABLED +# =true, NUXT_MCP_AUTH_TOKEN is BYPASSED on /mcp: the endpoint stops accepting +# the legacy shared token and accepts ONLY a per-user OAuth Bearer minted via +# /api/oauth/install → /api/oauth/callback. Every connected client (Claude / +# Cursor / Windsurf) must swap its `Authorization: Bearer ` +# header for its own per-user Bearer BEFORE you enable the flag, or it gets a +# 401 with `WWW-Authenticate: Bearer error="invalid_token", errorCode="…", +# error_description="…"`. +# Rollback is just `=false` + restart — the SQLite store stays on disk. +# +# Enable only after you have: # 1. Registered a Bitrix24 Marketplace application (CLIENT_ID/SECRET below). # 2. Persistent volume mounted at the directory holding `oauth.sqlite` # (docker-compose.yml already declares `bx24_data:/data`). @@ -107,7 +119,8 @@ NITRO_PORT=3000 # docker-compose `bx24_data:/data` volume satisfies this by default. If # you omit `_DB_DIR` AND don't mount a volume at `/data`, the DB lands in # the container's ephemeral layer and is lost on restart — a silent -# data-loss trap; PR-5 operator docs will warn explicitly. +# data-loss trap: on the next restart every minted Bearer is gone and all +# users must re-run `/api/oauth/install`. Always mount a persistent volume. # # `_ADMIN_TOKEN` gates GET /api/oauth/_health (operator-tier counts # endpoint). It is DELIBERATELY separate from NUXT_MCP_AUTH_TOKEN — the @@ -116,7 +129,8 @@ NITRO_PORT=3000 # `location /api/oauth/_health { allow ; deny all; }` block). # Set it to an `openssl rand -hex 32` value if you can't isolate the # route at the network layer. The route fails closed: with neither set, -# a non-localhost request gets 503 NOT-CONFIGURED. +# a non-localhost request gets 503 NOT-CONFIGURED. Once `_ADMIN_TOKEN` is +# set the Bearer is required uniformly — even a localhost request needs it. # # SCALING CAVEAT: the OAuth token cache + refresh state are PROCESS-LOCAL # (single-instance design, see docs/OAUTH-DESIGN.md §5). Running 2+ diff --git a/CHANGELOG.md b/CHANGELOG.md index cfa1464..7da7f6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project are documented here. Format follows [Keep a ### Added +- **Auth — OAuth 2.0 multi-tenant (opt-in, off by default).** A staged rollout (#209, #210, #213, #216, #217) lands a per-user OAuth flow behind `NUXT_BITRIX24_OAUTH_ENABLED`: each end user authorises via `/api/oauth/install → /api/oauth/callback`, receives a per-user Bearer, and every Bitrix24 REST call runs under *their own* identity (no shared service user). Tokens are stored sha256-hashed in a SQLite store on the `bx24_data:/data` volume; an audit-first JSONL log records every credential mutation; `/api/oauth/_health` (gated by a separate `NUXT_BITRIX24_OAUTH_ADMIN_TOKEN`) exposes operator-tier counts. With the flag **off** (the default) behaviour is byte-identical to the webhook-only path — existing deployments are unaffected. **Migration note:** when the flag is on, `NUXT_MCP_AUTH_TOKEN` is bypassed on `/mcp` (the endpoint accepts only per-user Bearers) — migrate clients before flipping it. Operator guide: [`docs/DEPLOYMENT.md` → OAuth 2.0 multi-tenant](./docs/DEPLOYMENT.md#oauth-20-multi-tenant-opt-in); design + threat model: [`docs/OAUTH-DESIGN.md`](./docs/OAUTH-DESIGN.md). - **CI**: `docker-smoke` job builds the production `Dockerfile`, boots two containers — one with a fresh `openssl rand -hex 32` Bearer (port `3000`), one with the `replace-with-secure-token` placeholder (port `3001`, in parallel — avoids the kernel-port-reuse race a `docker rm -f` + same-port re-run hits on busy runners) — and pins the externally-observable HTTP contract on every PR. Assertions: `/api/health` → `200 {"status":"ok"}`, container runs as non-root (Dockerfile `USER node` regression guard), `/mcp` → `401` without an `Authorization` header, `401` with a wrong length-matched Bearer (forces the comparator to look at content, not just length), non-`401`/`403`/`503` with the configured Bearer (auth passed), and `503` on the placeholder-token boot (pins the "copied-but-not-configured" gate). Closes the bring-up + Bearer-auth slice of issue #131 — the self-hosted HTTP path had never been booted in CI. - `scripts/verify-deployment.sh` — operator-runnable version of the same smoke check, intended for use on a staging host (or production, post-promotion) since it makes no Bitrix24 REST call. **TLS verification is on by default** — pass `--insecure` only for self-signed staging hosts. Token is read from `$NUXT_MCP_AUTH_TOKEN` by default (so it never appears in `/proc//cmdline` on shared hosts); `--token ` and `--token-stdin` are also accepted. Strict `jq -e '.status == "ok"'` body predicate when `jq` is on PATH, substring match otherwise. Hints distinguish `502/503/504` (proxy reaches an unhealthy upstream) from `000` (TLS / DNS / firewall) so the operator debugs the right layer first. Linked from [`docs/DEPLOYMENT.md`](./docs/DEPLOYMENT.md#verifying-your-deployment). - `.env.example`: documented (commented-out) `NUXT_AUDIT_DIR` — the OAuth-only audit-log directory knob (`server/utils/audit-log.ts`, default `/data/audit/`) was readable in code but missing from the template, a drift caught by the deploy-path audit. diff --git a/README.md b/README.md index c68ea9f..9f22a4c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ A starter template for building Model Context Protocol (MCP) servers on top of B |---|---|---| | **A non-technical Bitrix24 operator** (HR, accountant, foreman) on a single workstation | **DXT bundle** → [Desktop Extension](#desktop-extension--claude-desktop-one-file-two-clicks) | One file, two clicks. No terminal, no port, no Bearer. Webhook stored in the OS keychain. Локализованный гайд: [`INSTALL.ru.md`](./mcp-stdio/INSTALL.ru.md) · em PT-BR: [`INSTALL.pt-BR.md`](./mcp-stdio/INSTALL.pt-BR.md). | | **A developer** running an AI agent on your laptop (Claude Code / Cursor / Claude Desktop) | **Local HTTP** → [Local MCP](#local-mcp--your-own-machine-claude-desktop-cursor-claude-code-cline) | `pnpm start`, point the client at `localhost:3000`. No public domain. Stays inside your machine. | -| **A team / SaaS deploying for many users** | **Docker production** → [Remote MCP](#remote-mcp--production-server-claudeai-web) | Public URL with TLS, Bearer-protected `/mcp`, GHCR image, GitHub-Actions deploy + rollback. | +| **A team / SaaS deploying for many users** | **Docker production** → [Remote MCP](#remote-mcp--production-server-claudeai-web) | Public URL with TLS, Bearer-protected `/mcp`, GHCR image, GitHub-Actions deploy + rollback. Optional **per-user [OAuth 2.0](#multi-tenant-oauth-20--per-user-identity-opt-in)** so each user acts under their own Bitrix24 identity. | The three paths share **the same tool code** — same files in `server/mcp/tools/**`, same auth model, same logger redaction. Only the transport and packaging differ. @@ -42,7 +42,7 @@ Off-the-shelf Bitrix24 MCP servers are either toy demos or vendor-locked. This p > **Create the webhook under a dedicated service user**, not a real employee's account. The webhook inherits the creator's permissions for every call, so binding it to a personal account ties the integration to that person's role, department visibility, and tenure — anyone who leaves the company or loses rights silently breaks the MCP. Grant the service user the **minimum rights the tool set actually needs** (admin only if you need cross-user task visibility and want to avoid "task not found" / `ACCESS_DENIED` surprises on entities a non-admin user happens not to see). > -> This is a webhook-era trade-off only. When the template moves to **OAuth 2.0** in a future release, each end user logs in with their own Bitrix24 account and every REST call is executed under that user's identity and permissions — the service-user shortcut goes away, and access becomes per-user by design. +> This is a webhook-era trade-off only. The template now also ships **OAuth 2.0 multi-tenant** auth (opt-in, behind `NUXT_BITRIX24_OAUTH_ENABLED`): each end user logs in with their own Bitrix24 account and every REST call is executed under that user's identity and permissions — the service-user shortcut goes away, and access becomes per-user by design. It stays **off by default**, so webhook-only deployments are unaffected. See [`docs/OAUTH-DESIGN.md`](./docs/OAUTH-DESIGN.md) and the [OAuth 2.0 multi-tenant](./docs/DEPLOYMENT.md#oauth-20-multi-tenant-opt-in) operator guide. ```bash git clone https://github.com/bitrix24/templates-mcp.git @@ -120,6 +120,12 @@ The 8 task-mutation tools above (`start_task` / `pause_task` / `complete_task` / For production deployment, see [`docs/REVERSE-PROXY.md`](./docs/REVERSE-PROXY.md) — covers nginx-proxy (the default), Caddy, plain nginx + certbot, and Traefik. Pick whichever your hosting provider already runs. +#### Multi-tenant OAuth 2.0 — per-user identity (opt-in) + +The Remote setup above shares one `NUXT_MCP_AUTH_TOKEN` and runs every call under one webhook service user — right for a single team. For a **multi-user / SaaS** deployment, flip on OAuth (`NUXT_BITRIX24_OAUTH_ENABLED=true`) and each end user authorises once at `https:///api/oauth/install?portal=`, then pastes the per-user Bearer they receive into their connector. Every REST call then runs under *that* user's Bitrix24 identity and permissions — no shared service user. + +> ⚠️ **When the flag is on, `NUXT_MCP_AUTH_TOKEN` is bypassed on `/mcp`** — the endpoint accepts only per-user OAuth Bearers. Migrate every connected client to its own Bearer **before** flipping the flag. It stays **off by default**, so existing webhook deployments are unaffected. Full operator guide: [`docs/DEPLOYMENT.md` → OAuth 2.0 multi-tenant](./docs/DEPLOYMENT.md#oauth-20-multi-tenant-opt-in); design + threat model: [`docs/OAUTH-DESIGN.md`](./docs/OAUTH-DESIGN.md). + ### Local MCP — your own machine (Claude Desktop, Cursor, Claude Code, Cline, …) No public domain or TLS required. Run the same Nuxt build on `localhost` and point any HTTP-MCP-capable AI client at it. diff --git a/docker-compose.example.yml b/docker-compose.example.yml index b45b516..0e5466e 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -40,8 +40,9 @@ services: # and point Node at it. Leave unset for cloud portals (the Mozilla CA # list bundled with Node 22 covers every `*.bitrix24.` certificate). NODE_EXTRA_CA_CERTS: ${NODE_EXTRA_CA_CERTS:-} - # Audit log + OAuth scaffolding (Phase-3, opt-in). The flag stays off - # by default — webhook flow is unchanged. See docs/OAUTH-DESIGN.md. + # Audit log + OAuth multi-tenant (opt-in). The flag stays off by + # default — webhook flow is unchanged. See docs/DEPLOYMENT.md → + # "OAuth 2.0 multi-tenant". NUXT_AUDIT_DIR: ${NUXT_AUDIT_DIR:-/data/audit} NUXT_BITRIX24_OAUTH_ENABLED: ${NUXT_BITRIX24_OAUTH_ENABLED:-false} NUXT_BITRIX24_OAUTH_CLIENT_ID: ${NUXT_BITRIX24_OAUTH_CLIENT_ID:-} @@ -49,6 +50,7 @@ services: NUXT_BITRIX24_OAUTH_REDIRECT_URL: ${NUXT_BITRIX24_OAUTH_REDIRECT_URL:-} NUXT_BITRIX24_OAUTH_SCOPE: ${NUXT_BITRIX24_OAUTH_SCOPE:-user,task} NUXT_BITRIX24_OAUTH_DB_DIR: ${NUXT_BITRIX24_OAUTH_DB_DIR:-/data} + NUXT_BITRIX24_OAUTH_ADMIN_TOKEN: ${NUXT_BITRIX24_OAUTH_ADMIN_TOKEN:-} # Persistent storage for the audit log + OAuth token store. Required # whenever NUXT_BITRIX24_OAUTH_ENABLED=true; harmless (just empty) for # webhook-only forks. The named volume survives `docker compose down` diff --git a/docker-compose.yml b/docker-compose.yml index 0b6ef25..29e279a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,20 +31,23 @@ services: LETSENCRYPT_HOST: ${LETSENCRYPT_HOST} LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL} NUXT_AUDIT_DIR: ${NUXT_AUDIT_DIR:-/data/audit} - # OAuth 2.0 scaffolding — empty/false by default; webhook-only forks - # see no behaviour change. Enable per docs/OAUTH-DESIGN.md once - # PR-2c lands and the Marketplace app is registered. + # OAuth 2.0 multi-tenant — empty/false by default; webhook-only forks + # see no behaviour change. Enable per docs/DEPLOYMENT.md → "OAuth 2.0 + # multi-tenant" once the Marketplace app is registered. NUXT_BITRIX24_OAUTH_ENABLED: ${NUXT_BITRIX24_OAUTH_ENABLED:-false} NUXT_BITRIX24_OAUTH_CLIENT_ID: ${NUXT_BITRIX24_OAUTH_CLIENT_ID:-} NUXT_BITRIX24_OAUTH_CLIENT_SECRET: ${NUXT_BITRIX24_OAUTH_CLIENT_SECRET:-} NUXT_BITRIX24_OAUTH_REDIRECT_URL: ${NUXT_BITRIX24_OAUTH_REDIRECT_URL:-} NUXT_BITRIX24_OAUTH_SCOPE: ${NUXT_BITRIX24_OAUTH_SCOPE:-user,task} NUXT_BITRIX24_OAUTH_DB_DIR: ${NUXT_BITRIX24_OAUTH_DB_DIR:-/data} + # Gates GET /api/oauth/_health. Leave empty for localhost-only access; + # set to an `openssl rand -hex 32` value to token-gate the route. + NUXT_BITRIX24_OAUTH_ADMIN_TOKEN: ${NUXT_BITRIX24_OAUTH_ADMIN_TOKEN:-} networks: - proxy-net # Persistent storage for the audit log (`/data/audit/*.jsonl`, written # by `server/utils/audit-log.ts`) and the OAuth token store - # (`oauth.sqlite`, lands in PR-2b). Without this volume both are lost + # (`oauth.sqlite`). Without this volume both are lost # on container recreate — silently breaking compliance/forensics and, # once OAuth is on, every installed tenant. Webhook-only forks gain # durable audit logs as a side-effect. The named volume survives @@ -58,8 +61,8 @@ services: # compliance you MUST copy them out first: # docker cp bx24-template-mcp:/data/audit ./audit-backup-$(date +%F) # Then `docker compose pull && docker compose up -d`. If you have no - # earlier audit history (fresh install, or audit logger never ran - # because the only call site lands in PR-2b), no action needed. + # earlier audit history (fresh install, or a webhook-only deploy where + # the audit logger never ran), no action needed. volumes: - bx24_data:/data healthcheck: diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 0e2d90e..c1087bb 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -247,11 +247,18 @@ Set these in the `.env` file in the deploy directory (consumed by [`docker-compo | Variable | Required | Notes | |---|---|---| | `NUXT_BITRIX24_WEBHOOK_URL` | ✅ | Inbound webhook URL of your portal. Bind it to a dedicated service user, not a person. | -| `NUXT_MCP_AUTH_TOKEN` | ✅ | Bearer token MCP clients must present on `/mcp`. Generate with `openssl rand -hex 32`. `.env.example` ships the `replace-with-secure-token` **placeholder** — leaving it unchanged makes `/mcp` return **503** (treated as "not configured"), never a working endpoint. | +| `NUXT_MCP_AUTH_TOKEN` | ✅ ‡ | Bearer token MCP clients must present on `/mcp`. Generate with `openssl rand -hex 32`. `.env.example` ships the `replace-with-secure-token` **placeholder** — leaving it unchanged makes `/mcp` return **503** (treated as "not configured"), never a working endpoint. **‡ BYPASSED when `NUXT_BITRIX24_OAUTH_ENABLED=true`** — `/mcp` then accepts only per-user OAuth Bearers, not this shared token. See [OAuth 2.0 multi-tenant](#oauth-20-multi-tenant-opt-in). | | `NUXT_GITHUB_FEEDBACK_TOKEN` | ⬜ | Enables `bx24mcp_submit_feedback`. Fine-grained PAT with Issues: read/write. `.env.example` ships a `github_pat_xxx` **placeholder** — clear it or replace it; a copied placeholder is an invalid token, not "disabled". | | `NUXT_GITHUB_FEEDBACK_REPO` | ⬜ | `owner/name` for feedback issues. Defaults to `bitrix24/templates-mcp`. | | `NUXT_LOG_LEVEL` | ⬜ | `info` (default) / `debug` / `notice` / `warning` (alias `warn`) / `error` / `critical` / `alert` / `emergency`. Unset → `DEBUG` in dev, `INFO` otherwise. **An unrecognised non-empty value (typo like `debgu`, `infoo`) prints a one-line warning to `stderr` at startup** naming the variable, the value (capped at 32 chars and webhook-secret-redacted), the active `NODE_ENV`, and the level actually used — then falls back to the default. Empty / whitespace values stay silent. | -| `NUXT_AUDIT_DIR` | ⬜ | Directory for the OAuth/Bearer audit JSONL log. Defaults to `/data/audit/`. Only written by the OAuth flow (Phase 3) — a webhook-only deploy leaves it unused. See [Monitoring & logs](#monitoring--logs). | +| `NUXT_AUDIT_DIR` | ⬜ | Directory for the OAuth/Bearer audit JSONL log. Defaults to `/data/audit/`. Only written when `NUXT_BITRIX24_OAUTH_ENABLED=true` — a webhook-only deploy leaves it unused. See [Monitoring & logs](#monitoring--logs). | +| `NUXT_BITRIX24_OAUTH_ENABLED` | ⬜ | `false` (default) → webhook-only, unchanged. `true` → multi-tenant OAuth on `/mcp` (and `NUXT_MCP_AUTH_TOKEN` is bypassed). The six vars below apply **only** when this is `true`. See [OAuth 2.0 multi-tenant](#oauth-20-multi-tenant-opt-in). | +| `NUXT_BITRIX24_OAUTH_CLIENT_ID` | OAuth | Marketplace application `CLIENT_ID`. Required when the flag is on. | +| `NUXT_BITRIX24_OAUTH_CLIENT_SECRET` | OAuth | Marketplace application `CLIENT_SECRET`. Required when the flag is on. Treat as a secret — host `.env` only. | +| `NUXT_BITRIX24_OAUTH_REDIRECT_URL` | OAuth | HTTPS callback URL, must **exactly** match the one registered on the Bitrix24 application (e.g. `https://mcp.example.com/api/oauth/callback`). No default. | +| `NUXT_BITRIX24_OAUTH_SCOPE` | ⬜ | Comma-separated REST scopes requested at authorize. Defaults to `user,task`. | +| `NUXT_BITRIX24_OAUTH_DB_DIR` | ⬜ | Directory holding `oauth.sqlite` (filename fixed in code). Defaults to `/data`. **Must be a persistent, writable mount with an absolute path (no `..`)** — the `bx24_data:/data` volume in `docker-compose.yml` satisfies this. Land it on a non-ephemeral volume or every minted Bearer is lost on restart. | +| `NUXT_BITRIX24_OAUTH_ADMIN_TOKEN` | ⬜ | Gates `GET /api/oauth/_health` (operator-tier OAuth counts). **Deliberately separate** from `NUXT_MCP_AUTH_TOKEN` — the agent's token must never read fleet-level counts. Leave empty for localhost-only access (recommended: an nginx `allow ; deny all;` block); set to an `openssl rand -hex 32` value if you can't isolate the route at the network layer. Fails closed (503) for a non-localhost request when unset. **Once set, the Bearer is required even from localhost** — the gate is uniform, so a local `curl` also needs the token. | | `NITRO_PORT` | ✅ | Container listen port. Keep `3000` unless you also change `VIRTUAL_PORT` and the Dockerfile `EXPOSE`/`HEALTHCHECK`. Present in `.env.example`. | | `NODE_ENV` | ✅ † | `production`. | | `VIRTUAL_HOST` | ✅ | Hostname nginx-proxy routes to this container (e.g. `mcp.example.com`). | @@ -263,6 +270,35 @@ Set these in the `.env` file in the deploy directory (consumed by [`docker-compo > **Secrets management**: the `.env` lives only on the host, never in the repo; the image carries no secrets and reads everything from the environment at runtime. Rotating `NUXT_MCP_AUTH_TOKEN` is **not zero-downtime** — editing `.env` and running `docker compose up -d` restarts the container and severs all current MCP clients at once (no dual-accept window), so plan a short maintenance window and re-issue the new token. Rotate `NUXT_GITHUB_FEEDBACK_TOKEN` the same way. Per-secret rotation detail lives in [`SECURITY.md`](./SECURITY.md) and [`FEEDBACK.md`](./FEEDBACK.md). +## OAuth 2.0 multi-tenant (opt-in) + +By default this server authenticates `/mcp` with a single shared `NUXT_MCP_AUTH_TOKEN` and runs every Bitrix24 call under one webhook-bound service user. That is the right shape for a single team. For a **multi-user / SaaS** deployment where each end user should act under *their own* Bitrix24 identity and permissions, flip on the OAuth flow: each user authorises once via `/api/oauth/install`, receives a personal Bearer, and every REST call they trigger runs as them. The design rationale, threat model, and event taxonomy live in [`OAUTH-DESIGN.md`](./OAUTH-DESIGN.md); this section is the operator how-to. + +The flow is gated behind `NUXT_BITRIX24_OAUTH_ENABLED` and is **off by default** — a webhook-only deployment is completely unaffected and you can skip this section. + +> ### ⚠️ Migration warning — `/mcp` auth changes when the flag is on +> +> When `NUXT_BITRIX24_OAUTH_ENABLED=true`, **`NUXT_MCP_AUTH_TOKEN` is bypassed on `/mcp`.** The endpoint stops accepting the shared legacy token and accepts **only** a per-user OAuth Bearer minted via `/api/oauth/install → /api/oauth/callback`. Any client still presenting the old `Authorization: Bearer ` header gets a **401** with a `WWW-Authenticate: Bearer error="invalid_token", errorCode="BEARER-UNKNOWN", error_description="…"` header. +> +> **Migrate every connected client to its own per-user Bearer _before_ you flip the flag.** Rollback is non-destructive — set `NUXT_BITRIX24_OAUTH_ENABLED=false` and restart; the SQLite store stays on disk and the legacy token works again immediately. + +### Upgrade runbook — existing webhook deployment → OAuth + +1. **Register a marketplace application** on your Bitrix24 portal; record `CLIENT_ID` and `CLIENT_SECRET`. Set the application's redirect URL to `https:///api/oauth/callback`. +2. **Mount a persistent volume** at `/data` (or wherever `NUXT_BITRIX24_OAUTH_DB_DIR` points) — `docker-compose.yml` already declares `bx24_data:/data`. Confirm it is on local SSD, **not NFS** (`better-sqlite3` + WAL on NFS corrupts). Without a persistent mount every minted Bearer is lost on restart. +3. **Set the OAuth env vars** in the host `.env` (or your secrets manager): `NUXT_BITRIX24_OAUTH_CLIENT_ID`, `NUXT_BITRIX24_OAUTH_CLIENT_SECRET`, `NUXT_BITRIX24_OAUTH_REDIRECT_URL`, and optionally `NUXT_BITRIX24_OAUTH_SCOPE` / `NUXT_BITRIX24_OAUTH_DB_DIR` / `NUXT_BITRIX24_OAUTH_ADMIN_TOKEN`. See the [environment table](#environment-variables). +4. **Restart** with `NUXT_BITRIX24_OAUTH_ENABLED=true` (`make redeploy`). +5. **Verify the OAuth surface gates correctly** before onboarding users: `bash scripts/manual-qa-pr2c.sh http://localhost:3000` (see the script header for the Scenario-B prerequisites). It confirms `/api/oauth/install` redirects, an unknown Bearer on `/mcp` returns `401` + `WWW-Authenticate: …errorCode="BEARER-UNKNOWN"`, and `/api/oauth/_health` is reachable. (Note: the generic `scripts/verify-deployment.sh` is **not** OAuth-aware — see [Verifying your deployment](#verifying-your-deployment).) +6. **Each end user** visits `https:///api/oauth/install?portal=`, completes the authorize prompt, and copies the per-user Bearer from the callback page into their Claude / Cursor / Windsurf connector (replacing the old shared-token header). +7. **Transition window**: there is no dual-accept on `/mcp` — the moment the flag is on, the shared token stops working there. Coordinate the cutover, or migrate clients first and flip last. +8. **Rollback**: `NUXT_BITRIX24_OAUTH_ENABLED=false` + restart. The SQLite file stays on disk; nothing is lost. Audit-log JSONL entries emitted while enabled (`oauth.upsert`, `mcp.create`, `mcp.revoke`, `oauth.delete`) persist under `NUXT_AUDIT_DIR` after rollback **by design** — a SOC analyst inspecting the log post-rollback will see events that no longer map to live DB rows; that is intentional, not corruption. + +> **Single-instance only (for now).** The OAuth token cache + refresh state are **process-local** (`OAUTH-DESIGN.md` §5). Running 2+ replicas behind a load balancer means each refreshes tokens independently, `/api/oauth/_health` counts differ per replica, and a revoke on one replica isn't seen by another until its cache entry evicts. Keep OAuth to a single instance until a shared token store lands. + +### Health & observability + +`GET /api/oauth/_health` returns operator-tier OAuth counts (active tokens, last refresh, etc.), gated by `NUXT_BITRIX24_OAUTH_ADMIN_TOKEN` (or localhost-only when unset — it fails closed for non-localhost requests). Every OAuth code path emits a single structured `event: '..'` log line carrying a per-request `requestId`, so one `jq` query reconstructs a whole authorize→callback→Bearer→`/mcp` timeline. The full event taxonomy is in [`OAUTH-DESIGN.md` §11](./OAUTH-DESIGN.md). + ## Manual rollback ### With Watchtower @@ -348,6 +384,7 @@ What it does **not** do: - It makes **no Bitrix24 REST call** — safe to run against production. For a live tool call after this passes, use the canonical operator prompts in [`docs/MANUAL-TEST-PHRASES.md`](./MANUAL-TEST-PHRASES.md) through an MCP client (Claude Desktop via `mcp-remote`, MCP Inspector, etc.). - It does **not** verify the reverse-proxy config end-to-end beyond "TLS terminates and `/api/health` / `/mcp` reach the container". Header forwarding (`X-Forwarded-*`), `proxy_read_timeout` for long MCP responses, and TLS cert chain depth are out of scope here — see [`REVERSE-PROXY.md`](./REVERSE-PROXY.md). (TLS verification itself **is** on by default — a broken cert chain fails the run with a curl error; pass `--insecure` only for self-signed staging.) - It does **not** check that the container runs as a non-root user. That assertion lives in the CI `docker-smoke` job (via `docker exec ... id -u`) and is intentionally not duplicated in the operator script, which avoids `docker exec` so it can run against a remote URL the operator does not have shell access to. +- It is **not OAuth-aware.** When `NUXT_BITRIX24_OAUTH_ENABLED=true`, the "configured Bearer → not 401" assertion **will fail by design** — `/mcp` no longer accepts `NUXT_MCP_AUTH_TOKEN`, only per-user OAuth Bearers (see [OAuth 2.0 multi-tenant](#oauth-20-multi-tenant-opt-in)). To verify an OAuth deployment use `scripts/manual-qa-pr2c.sh` instead, which asserts the `401` + `WWW-Authenticate` errorCode contract. Failure behaviour: the `/api/health` step **bails early** if it can't reach `200` within the retry budget — and prints a layered hint (`502/503/504` = proxy reaches an unhealthy upstream, `000` = TLS handshake / DNS / firewall, other = cold boot / crash loop). All later assertions (three Bearer-auth checks + the JSON-RPC `initialize` + `tools/list` round-trip) **accumulate failures** instead of bailing, so a single run surfaces every regression at once with inline `✗` lines. The script exits non-zero when any assertion failed. The most common production miss is `/mcp → 503`, which means `NUXT_MCP_AUTH_TOKEN` is unset or still the `replace-with-secure-token` placeholder — that 503 is by design (see [`server/middleware/mcp-auth.ts`](../server/middleware/mcp-auth.ts)). @@ -355,7 +392,7 @@ Failure behaviour: the `/api/health` step **bails early** if it can't reach `200 - **Health**: `/api/health` is unauthenticated and returns `{ status, timestamp }` (no `service` or version field — kept minimal so the probe is not a fingerprinting surface). Point an external monitor (UptimeRobot / Healthchecks.io) at `https:///api/health` for liveness alerting; key your checks on `status: "ok"`, not on a service-name field. - **Logs**: container logs go to Docker's JSON driver (`docker compose logs -f`). Configure rotation at the daemon level. Long-term aggregation (Loki / Graylog) is out of scope for the template. -- **Audit log**: the OAuth/Bearer audit trail (`server/utils/audit-log.ts`) appends JSONL to `/data/audit/` (override with `NUXT_AUDIT_DIR`), creating the directory `0750` and files `0640`. Those modes are applied **only on creation** — if the directory already exists with broader permissions (e.g. after a redeploy or a manually-created mount), re-assert them: `chmod 0750 /data/audit && find /data/audit -name '*.jsonl' -exec chmod 0640 {} +`. **Files grow forever — operators MUST configure rotation/retention** (`logrotate` or `find -mtime`). Records carry `ip`/`ua` (GDPR personal data); cap retention at ~90 days (max 12 months absent a legal hold). Currently exercised only by the OAuth flow (Phase 3); a webhook-only Phase-1 deploy writes nothing here yet. See [`SECURITY-AUDIT.md`](./SECURITY-AUDIT.md). +- **Audit log**: the OAuth/Bearer audit trail (`server/utils/audit-log.ts`) appends JSONL to `/data/audit/` (override with `NUXT_AUDIT_DIR`), creating the directory `0750` and files `0640`. Those modes are applied **only on creation** — if the directory already exists with broader permissions (e.g. after a redeploy or a manually-created mount), re-assert them: `chmod 0750 /data/audit && find /data/audit -name '*.jsonl' -exec chmod 0640 {} +`. **Files grow forever — operators MUST configure rotation/retention** (`logrotate` or `find -mtime`). Records carry `ip`/`ua` (GDPR personal data); cap retention at ~90 days (max 12 months absent a legal hold). Currently exercised only when `NUXT_BITRIX24_OAUTH_ENABLED=true`; a webhook-only deploy writes nothing here. See [`SECURITY-AUDIT.md`](./SECURITY-AUDIT.md). - **Resources**: the compose service caps at 0.5 CPU / 512 MB — raise these in `docker-compose.yml` if your tool volume needs more. ## See also diff --git a/docs/OAUTH-DESIGN.md b/docs/OAUTH-DESIGN.md index 8699381..0c0763e 100644 --- a/docs/OAUTH-DESIGN.md +++ b/docs/OAUTH-DESIGN.md @@ -1,6 +1,6 @@ -# OAuth 2.0 design — design doc, not yet implemented +# OAuth 2.0 design — multi-tenant auth (shipped) -> **Status: DRAFT — design only.** No code in this document is shipped. The goal of this PR is to lock the contract before any implementation lands. Implementation follows in separate PRs behind a `NUXT_BITRIX24_OAUTH_ENABLED` feature flag (off by default), so this design can land in `main` without changing runtime behaviour. +> **Status: SHIPPED.** The implementation is live behind the `NUXT_BITRIX24_OAUTH_ENABLED` feature flag (off by default) — landed across #209 → #210 → #213 → #216 → #218, operator docs in #219 (§10 has the full rollout table). This document remains the **normative design reference**: threat model, token-store contract, §11 event taxonomy. > > **Doc-vs-code drift policy.** If implementation diverges from this document, `OAUTH-DESIGN.md` is updated in the same PR that introduces the divergence. This file is normative until superseded. @@ -298,8 +298,8 @@ The actual landed order **inverts** the original PR-2/PR-3/PR-4 plan after PR-2a | — | PR-2b (#210) | token store (SQLite, audit-first) | | PR-4 | PR-2d (#213) | tool-catalogue swap to `useBitrix24Tenant()` | | PR-3 | **PR-2c** (#216) | install/callback routes + B24OAuth factory + refresh + logger-redactor extension + `pruneExpiredStates` scheduler + `/api/oauth/_health` (§11). **Bearer middleware → split out** (see below). | -| — | PR-2c-bearer (#217 — this) | Bearer middleware: `Bearer → inspectBearer → runWithTenant({memberId, userId, requestId})` on `/mcp` via `server/mcp/index.ts`'s `defineMcpHandler({ middleware })`. Three §11 deny branches (bearer-unknown / -revoked / -orphan) each with a distinct errorCode + `WWW-Authenticate` header. **This is the last wire.** After this lands the OAuth flow is end-to-end usable. | -| PR-5 | PR-5 | operator docs | +| — | PR-2c-bearer (#217) | Bearer middleware: `Bearer → inspectBearer → runWithTenant({memberId, userId, requestId})` on `/mcp` via `server/mcp/index.ts`'s `defineMcpHandler({ middleware })`. Three §11 deny branches (bearer-unknown / -revoked / -orphan) each with a distinct errorCode + `WWW-Authenticate` header. **This is the last wire.** After this lands the OAuth flow is end-to-end usable. | +| PR-5 | #219 | operator docs (README + DEPLOYMENT.md OAuth section + finalized `.env.example` + migration warning). Completes the rollout. | 1. **PR-1 (this PR):** design doc only. See frontmatter. 2. **PR-2a (#209):** scaffolding behind `NUXT_BITRIX24_OAUTH_ENABLED=false`. New files compile, new env vars in `.env.example` (rebased on top of #49's `.env.example` changes — PR-2a ships only OAuth-specific lines, no overlap with stdio config), dispatcher in `bitrix24-tenant.ts`, `AsyncLocalStorage` plumbing in `request-context.ts`, `B24Client` type alias and `sdk-helpers.ts` reparameterisation. Tools still hit the webhook path because the flag is off. Zero behaviour change for existing deployments. `docker-compose.yml` and `docker-compose.example.yml` get a `volumes:` section for `oauth_data:/data`. diff --git a/docs/README.md b/docs/README.md index f157a05..5bb0f59 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,7 +10,7 @@ Start here if you are about to change code. 2. [`ARCHITECTURE.md`](./ARCHITECTURE.md) — 15-minute orientation: layers, decisions, hot spots. 3. [`ADDING-TOOLS.md`](./ADDING-TOOLS.md) — human walkthrough for adding a new MCP tool: mental model, where files go, the two registrations, anatomy of a real tool. Links to the agent skill for the full template (`callV2` / `callV3` / `batchV2` / `batchV3` helpers, error funnel, unit-test skeleton, persona walk). 4. [`EVALS.md`](./EVALS.md) — automated tool-selection eval (Evalite + DeepSeek); how to run, how to add cases. -5. [`OAUTH-DESIGN.md`](./OAUTH-DESIGN.md) — design doc (DRAFT) for the upcoming OAuth 2.0 + multi-tenant support. Contract first, code later. +5. [`OAUTH-DESIGN.md`](./OAUTH-DESIGN.md) — normative design doc for the OAuth 2.0 multi-tenant support (shipped, opt-in behind `NUXT_BITRIX24_OAUTH_ENABLED`): threat model, token-store contract, event taxonomy. 6. [`../PROJECT-BRIEF.md`](../PROJECT-BRIEF.md) — system design and roadmap, source of truth for everything that hasn't earned its own doc yet. > **Testing strategy** is not a separate doc — see `CONTRIBUTING.md` for the unit/integration split and CI gates, and `EVALS.md` for the LLM tool-selection layer. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 1627e02..2e204db 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -15,14 +15,15 @@ While the project is pre-release, only the latest tag receives fixes. Once a `v0 ## Threat model — what's in scope - **Webhook URL secret leak.** The webhook URL contains a per-user secret. Logger redaction is the primary control — see `server/utils/logger-redactor.ts` and the audit pass in [`SECURITY-AUDIT.md`](./SECURITY-AUDIT.md). Any dependency bump that touches the SDK or its logger surface MUST re-run the audit. Redaction is **URL-shaped only**: if a Bitrix24 REST endpoint ever returns a credential as a JSON value (e.g. `{ token: "…" }`) and that body lands in `getLogger().info('post/response', …)`, the redactor will not catch it. No known REST method does this today; tracked as a known limitation in [`SECURITY-AUDIT.md`](./SECURITY-AUDIT.md). -- **Bearer token leak (HTTP modes).** `NUXT_MCP_AUTH_TOKEN` is the only thing between a public `/mcp` and tool execution against your portal. It's compared with `crypto.timingSafeEqual`. Rotation procedure below. +- **Bearer token leak (HTTP modes).** With `NUXT_BITRIX24_OAUTH_ENABLED=false` (the default), `NUXT_MCP_AUTH_TOKEN` is the only thing between a public `/mcp` and tool execution against your portal. It's compared with `crypto.timingSafeEqual`. Rotation procedure below. +- **OAuth multi-tenant (opt-in).** When `NUXT_BITRIX24_OAUTH_ENABLED=true`, `/mcp` accepts per-user OAuth Bearers instead and `NUXT_MCP_AUTH_TOKEN` is **bypassed** there — each user acts under their own Bitrix24 identity. The controls: Bearers are stored as sha256 hashes (never plaintext) in a SQLite store on a persistent volume; revocation is immediate; every 401 carries a `WWW-Authenticate` errorCode; the operator `/api/oauth/_health` endpoint is gated by a *separate* `NUXT_BITRIX24_OAUTH_ADMIN_TOKEN` (privilege separation — the agent's token must never read fleet counts). The design, threat model, and event taxonomy live in [`OAUTH-DESIGN.md`](./OAUTH-DESIGN.md); the operator guide + migration warning in [`DEPLOYMENT.md` → OAuth 2.0 multi-tenant](./DEPLOYMENT.md#oauth-20-multi-tenant-opt-in). **Migration hazard:** flipping the flag on without first migrating clients to per-user Bearers 401s every connected client (no dual-accept window). - **Prompt injection via tool input.** Defensive hardening for LLM-controlled keys lives in `server/utils/v3-filter.ts` and `wire-coerce.ts`; commit history references it as "defensive hardening for toV3Filter / pick against LLM-controlled keys" (PR #41). Re-audit if a new tool builds Bitrix24 REST filters from agent input. - **Tool delete operations.** Every delete tool gates on `confirmDelete: true` (Ground Rule #9 in `skills/manage-bx24-template-mcp/SKILL.md`). Cascade-destructive deletes layer a second confirm (Rule #10). - **DXT bundle.** Webhook lives in OS keychain via Claude Desktop's `user_config` (`sensitive: true`). Unpacked bundle lives on disk as plain files — protect with full-disk encryption if the threat model includes physical access. ## Out of scope (today) -- Multi-tenant deployment. The Bearer model is single-tenant; a multi-tenant variant needs per-tenant scoping and is not on the roadmap. +- Horizontally-scaled (multi-replica) OAuth. The OAuth token cache + refresh state are process-local, so the multi-tenant flow is supported on a **single instance** only; a shared token store for 2+ replicas behind a load balancer is a known limitation (see [`OAUTH-DESIGN.md`](./OAUTH-DESIGN.md) §5 / §12). Single-instance multi-tenant OAuth itself **is** in scope and shipped — see the threat-model entry above. - DoS mitigation beyond Docker resource limits. - Audit log of tool invocations. *Planned (pre-GA): retention policy / log shipping when this lands.* @@ -33,6 +34,8 @@ While the project is pre-release, only the latest tag receives fixes. Once a `v0 | `NUXT_BITRIX24_WEBHOOK_URL` | Host `.env` (production); `.env` on laptop (local HTTP); OS keychain (DXT) | Revoke webhook in Bitrix24 portal → create new → update store → `docker compose up -d` (production) or restart client (DXT). The old URL fails closed (401/403). | | `NUXT_MCP_AUTH_TOKEN` | Host `.env` (production); `.env` on laptop (local HTTP); not used for DXT | Generate new (`openssl rand -hex 32`), update `.env`, `docker compose up -d`, update every connected client header. No revocation list — old token is dead the instant the new one is loaded. | | GitHub feedback PAT [^pat] | Host `.env` / laptop `.env` / DXT user_config | Revoke PAT on GitHub → create new → update store → restart service. | +| `NUXT_BITRIX24_OAUTH_CLIENT_SECRET` (OAuth on) | Host `.env` | Rotate the secret on the Bitrix24 Marketplace application → update `.env` → `docker compose up -d`. Existing minted Bearers keep working (they don't carry the client secret); only the authorize/refresh exchange uses it. | +| `NUXT_BITRIX24_OAUTH_ADMIN_TOKEN` (OAuth on) | Host `.env` | Generate new (`openssl rand -hex 32`), update `.env`, `docker compose up -d`. Gates `/api/oauth/_health` only — rotating it does not affect user Bearers or `/mcp`. | [^pat]: All transports use `NUXT_GITHUB_FEEDBACK_TOKEN` — HTTP modes read it via Nuxt runtime-config, and the DXT manifest injects that same name. `mcp-stdio/nuxt-shims.ts` resolves `NUXT_GITHUB_FEEDBACK_TOKEN ?? GITHUB_FEEDBACK_TOKEN`, keeping the un-prefixed `GITHUB_FEEDBACK_TOKEN` only as a back-compat fallback for older bundles. diff --git a/scripts/manual-qa-pr5.ps1 b/scripts/manual-qa-pr5.ps1 new file mode 100644 index 0000000..a7de089 --- /dev/null +++ b/scripts/manual-qa-pr5.ps1 @@ -0,0 +1,99 @@ +# scripts/manual-qa-pr5.ps1 - manual QA for PR-5 (operator docs for OAuth 2.0). +# +# PR-5 ships documentation only. This check proves the docs are internally +# consistent and match the code they describe. It greps the tree; it changes +# nothing. Run it, read ALL GREEN (or the list of mismatches), paste the output. +# +# Usage (Windows PowerShell, from the repository ROOT - the folder with docs/ +# and .env.example): +# ./scripts/manual-qa-pr5.ps1 +# If Windows blocks the script, run this once in the same window first: +# Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass +$script:pass = 0 +$script:fail = 0 +function Ok($m) { Write-Host " [PASS] $m"; $script:pass++ } +function No($m) { Write-Host " [FAIL] $m"; $script:fail++ } +function FileHas($file, $text) { + if (-not (Test-Path $file)) { return $false } + return [bool](Select-String -Path $file -SimpleMatch -Pattern $text -Quiet) +} +function Has($file, $text, $msg) { if (FileHas $file $text) { Ok $msg } else { No $msg } } +function Hasnt($file, $text, $msg) { if (FileHas $file $text) { No $msg } else { Ok $msg } } + +Write-Host "==================================================" +Write-Host " PR-5 docs verification" +Write-Host "==================================================" +if (-not (Test-Path 'docs/DEPLOYMENT.md') -or -not (Test-Path '.env.example')) { + Write-Host "ERROR: run this from the repository ROOT (docs/DEPLOYMENT.md not found)." + exit 2 +} +$branch = (git branch --show-current) 2>$null +Write-Host "Branch: $branch`n" + +Write-Host "1) ADMIN_TOKEN is forwarded into BOTH compose files (the round-1 blocker)" +Has 'docker-compose.yml' 'NUXT_BITRIX24_OAUTH_ADMIN_TOKEN' 'docker-compose.yml forwards ADMIN_TOKEN' +Has 'docker-compose.example.yml' 'NUXT_BITRIX24_OAUTH_ADMIN_TOKEN' 'docker-compose.example.yml forwards ADMIN_TOKEN' +Write-Host "" + +Write-Host "2) All 7 OAuth env vars are documented in .env.example AND DEPLOYMENT.md" +$vars = @( + 'NUXT_BITRIX24_OAUTH_ENABLED','NUXT_BITRIX24_OAUTH_CLIENT_ID','NUXT_BITRIX24_OAUTH_CLIENT_SECRET', + 'NUXT_BITRIX24_OAUTH_REDIRECT_URL','NUXT_BITRIX24_OAUTH_SCOPE','NUXT_BITRIX24_OAUTH_DB_DIR', + 'NUXT_BITRIX24_OAUTH_ADMIN_TOKEN' +) +foreach ($v in $vars) { + Has '.env.example' $v ".env.example: $v" + Has 'docs/DEPLOYMENT.md' $v "DEPLOYMENT.md: $v" +} +Write-Host "" + +Write-Host "3) The 'NUXT_MCP_AUTH_TOKEN bypassed' migration warning is in all 3 surfaces" +Has 'README.md' 'bypassed' 'README has the bypass warning' +Has 'docs/DEPLOYMENT.md' 'bypassed' 'DEPLOYMENT.md has the bypass warning' +Has '.env.example' 'BYPASSED' '.env.example has the bypass warning' +Write-Host "" + +Write-Host "4) Doc cross-links point at headings that actually exist" +Has 'docs/DEPLOYMENT.md' '## OAuth 2.0 multi-tenant (opt-in)' 'DEPLOYMENT.md heading exists' +if (FileHas 'README.md' 'Multi-tenant OAuth 2.0') { Ok 'README heading exists' } else { No 'README heading missing' } +Write-Host "" + +Write-Host "5) Variable count is correct (six, not five)" +Has 'docs/DEPLOYMENT.md' 'The six vars below' "says 'six vars'" +Hasnt 'docs/DEPLOYMENT.md' 'The five vars below' "no stale 'five vars'" +Write-Host "" + +Write-Host "6) Stale staging language was removed (OAuth is landed, not 'coming')" +Hasnt '.env.example' 'Enable only AFTER PR-2c merges' '.env.example finalized' +Hasnt 'docker-compose.yml' 'PR-2c lands' 'compose comment finalized' +Hasnt 'skills/manage-bx24-template-mcp/SKILL.md' 'OAuth (Phase 3)' 'SKILL.md finalized' +Write-Host "" + +Write-Host "7) Rollout table marks PR-5 as landed (#219)" +Has 'docs/OAUTH-DESIGN.md' '| PR-5 | #219' 'OAUTH-DESIGN section 10 shows #219' +Write-Host "" + +Write-Host "8) CHANGELOG has the OAuth entry" +Has 'CHANGELOG.md' 'OAuth 2.0 multi-tenant (opt-in' 'CHANGELOG entry present' +Write-Host "" + +Write-Host "9) Manual-QA scaffold mirrors the OAuth vars (CI scaffold-sync gate)" +Has 'skills/run-manual-qa/references/issue-scaffold.md' 'NUXT_BITRIX24_OAUTH_ADMIN_TOKEN' 'issue-scaffold mirrors OAuth vars' +Write-Host "" + +Write-Host "10) (optional) docker compose files still validate" +if (Get-Command docker -ErrorAction SilentlyContinue) { + docker compose -f docker-compose.yml config | Out-Null 2>$null + if ($LASTEXITCODE -eq 0) { Ok "docker-compose.yml valid" } else { No "docker-compose.yml INVALID" } + docker compose -f docker-compose.example.yml config | Out-Null 2>$null + if ($LASTEXITCODE -eq 0) { Ok "docker-compose.example.yml valid" } else { No "docker-compose.example.yml INVALID" } +} else { + Write-Host " [SKIP] docker not installed - compose validation skipped (not a failure)" +} +Write-Host "" + +Write-Host "==================================================" +Write-Host " SUMMARY: $($script:pass) passed, $($script:fail) failed" +if ($script:fail -eq 0) { Write-Host " RESULT: ALL GREEN" } else { Write-Host " RESULT: $($script:fail) problem(s) found" } +Write-Host "==================================================" +if ($script:fail -eq 0) { exit 0 } else { exit 1 } diff --git a/scripts/manual-qa-pr5.sh b/scripts/manual-qa-pr5.sh new file mode 100755 index 0000000..866bd69 --- /dev/null +++ b/scripts/manual-qa-pr5.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# scripts/manual-qa-pr5.sh — manual QA for PR-5 (operator docs for OAuth 2.0). +# +# PR-5 ships documentation only (README, docs/DEPLOYMENT.md, .env.example, +# compose comments, skill files). There is no runtime behaviour to smoke — +# so this check proves the DOCS are internally consistent and match the code +# they describe. It greps the tree; it changes nothing. +# +# Goal: run it, read ALL GREEN (or the list of mismatches), paste the output. +# +# Usage (from the repository root — the folder with docs/ and .env.example): +# ./scripts/manual-qa-pr5.sh +set -uo pipefail + +pass=0 +fail=0 +ok() { printf ' [PASS] %s\n' "$1"; pass=$((pass + 1)); } +no() { printf ' [FAIL] %s\n' "$1"; fail=$((fail + 1)); } + +# has FILE TEXT MSG — PASS when FILE contains the literal TEXT. +has() { + if grep -qF -- "$2" "$1" 2>/dev/null; then ok "$3"; else no "$3"; fi +} +# hasnt FILE TEXT MSG — PASS when FILE does NOT contain the literal TEXT. +hasnt() { + if grep -qF -- "$2" "$1" 2>/dev/null; then no "$3"; else ok "$3"; fi +} + +echo "==================================================" +echo " PR-5 docs verification" +echo "==================================================" +if [ ! -f docs/DEPLOYMENT.md ] || [ ! -f .env.example ]; then + echo "ERROR: run this from the repository ROOT (docs/DEPLOYMENT.md not found)." + exit 2 +fi +echo "Branch: $(git branch --show-current 2>/dev/null || echo '?')" +echo + +echo "1) ADMIN_TOKEN is forwarded into BOTH compose files (the round-1 blocker)" +has docker-compose.yml 'NUXT_BITRIX24_OAUTH_ADMIN_TOKEN' 'docker-compose.yml forwards ADMIN_TOKEN' +has docker-compose.example.yml 'NUXT_BITRIX24_OAUTH_ADMIN_TOKEN' 'docker-compose.example.yml forwards ADMIN_TOKEN' +echo + +echo "2) All 7 OAuth env vars are documented in .env.example AND DEPLOYMENT.md" +for v in NUXT_BITRIX24_OAUTH_ENABLED NUXT_BITRIX24_OAUTH_CLIENT_ID NUXT_BITRIX24_OAUTH_CLIENT_SECRET \ + NUXT_BITRIX24_OAUTH_REDIRECT_URL NUXT_BITRIX24_OAUTH_SCOPE NUXT_BITRIX24_OAUTH_DB_DIR \ + NUXT_BITRIX24_OAUTH_ADMIN_TOKEN; do + has .env.example "$v" ".env.example: $v" + has docs/DEPLOYMENT.md "$v" "DEPLOYMENT.md: $v" +done +echo + +echo "3) The 'NUXT_MCP_AUTH_TOKEN bypassed' migration warning is in all 3 surfaces" +has README.md 'bypassed' 'README has the bypass warning' +has docs/DEPLOYMENT.md 'bypassed' 'DEPLOYMENT.md has the bypass warning' +has .env.example 'BYPASSED' '.env.example has the bypass warning' +echo + +echo "4) Doc cross-links point at headings that actually exist" +has docs/DEPLOYMENT.md '## OAuth 2.0 multi-tenant (opt-in)' 'DEPLOYMENT.md heading exists' +has README.md '#### Multi-tenant OAuth 2.0 — per-user identity (opt-in)' 'README heading exists' +echo + +echo "5) Variable count is correct (six, not five)" +has docs/DEPLOYMENT.md 'The six vars below' "says 'six vars'" +hasnt docs/DEPLOYMENT.md 'The five vars below' "no stale 'five vars'" +echo + +echo "6) Stale staging language was removed (OAuth is landed, not 'coming')" +hasnt .env.example 'Enable only AFTER PR-2c merges' '.env.example finalized' +hasnt docker-compose.yml 'PR-2c lands' 'compose comment finalized' +hasnt skills/manage-bx24-template-mcp/SKILL.md 'OAuth (Phase 3)' 'SKILL.md finalized' +echo + +echo "7) Rollout table marks PR-5 as landed (#219)" +has docs/OAUTH-DESIGN.md '| PR-5 | #219' 'OAUTH-DESIGN section 10 shows #219' +echo + +echo "8) CHANGELOG has the OAuth entry" +has CHANGELOG.md 'OAuth 2.0 multi-tenant (opt-in' 'CHANGELOG entry present' +echo + +echo "9) Manual-QA scaffold mirrors the OAuth vars (CI scaffold-sync gate)" +has skills/run-manual-qa/references/issue-scaffold.md 'NUXT_BITRIX24_OAUTH_ADMIN_TOKEN' 'issue-scaffold mirrors OAuth vars' +echo + +echo "10) (optional) docker compose files still validate" +if command -v docker >/dev/null 2>&1; then + if docker compose -f docker-compose.yml config >/dev/null 2>&1; then + ok "docker-compose.yml valid" + else + no "docker-compose.yml INVALID" + fi + if docker compose -f docker-compose.example.yml config >/dev/null 2>&1; then + ok "docker-compose.example.yml valid" + else + no "docker-compose.example.yml INVALID" + fi +else + echo " [SKIP] docker not installed — compose validation skipped (not a failure)" +fi +echo + +echo "==================================================" +echo " SUMMARY: $pass passed, $fail failed" +if [ "$fail" -eq 0 ]; then + echo " RESULT: ALL GREEN ✅" +else + echo " RESULT: $fail problem(s) found ❌" +fi +echo "==================================================" +if [ "$fail" -eq 0 ]; then + exit 0 +else + exit 1 +fi diff --git a/skills/manage-bx24-template-mcp/SKILL.md b/skills/manage-bx24-template-mcp/SKILL.md index c42cc78..e584401 100644 --- a/skills/manage-bx24-template-mcp/SKILL.md +++ b/skills/manage-bx24-template-mcp/SKILL.md @@ -9,7 +9,7 @@ You are working on a Bitrix24 MCP server built on Nuxt + `@nuxtjs/mcp-toolkit`. - **Repo**: https://github.com/bitrix24/templates-mcp - **Prod**: `/mcp` — replace with your deployed instance URL - **Stack**: Nuxt 4 (Nitro `node-server`), `@nuxtjs/mcp-toolkit`, `@bitrix24/b24jssdk-nuxt` -- **Auth to Bitrix24**: incoming webhook (Phase 1), OAuth (Phase 3) +- **Auth to Bitrix24**: incoming webhook (default), or OAuth 2.0 multi-tenant (opt-in, landed — `NUXT_BITRIX24_OAUTH_ENABLED`) - **Auth from Claude to us**: Bearer token via middleware - **Deployment**: Docker behind `nginx-proxy` + `acme-companion` on shared `proxy-net` network. CI builds and pushes the image to GHCR on `v*` tag; the operator deploys via Watchtower (auto) or `make redeploy` on the host (manual) - **Dependency updates**: npm & GitHub Actions — Renovate Bot (see `renovate.json`); Dockerfile base images — Dependabot; docker-compose infra images — Renovate's `docker-compose` manager (see `renovate.json`). Transitive-dependency security advisories are patched manually via `overrides` in `pnpm-workspace.yaml` (pnpm v11 location) — Dependabot/Renovate don't open PRs for nested deps. A blocking `pnpm audit --audit-level=moderate` CI job guards against regressions. @@ -18,7 +18,7 @@ You are working on a Bitrix24 MCP server built on Nuxt + `@nuxtjs/mcp-toolkit`. ## Ground rules 1. **One tool per file** in `server/mcp/tools//.ts`. Discovery is automatic. -2. **Never call Bitrix24 directly.** Always go through `useBitrix24Tenant()` (the OAuth-aware dispatcher in `~/server/utils/bitrix24-tenant` — see [`../../docs/OAUTH-DESIGN.md`](../../docs/OAUTH-DESIGN.md) §6). With `NUXT_BITRIX24_OAUTH_ENABLED=false` (the production default) it falls back to the webhook singleton, so behaviour is byte-identical to a direct `useBitrix24()` call. When the flag flips on (PR-2c), the same call resolves to a per-tenant `B24OAuth` from the request-scoped ALS. **Never call `useBitrix24()` directly from a tool handler** — it bypasses the dispatcher and pins the tool to webhook forever. From the dispatcher result go through the typed helpers in `server/utils/sdk-helpers.ts`: `callV2(b24, method, params, errorContext)` — the **default** for classic methods (`tasks.task.add/list/update/` + the seven lifecycle verbs, `user.*`, `task.commentitem.*`, …), `callV3(…)` — **only** for methods that are v3-only (currently `tasks.task.get`, `tasks.task.result.*`), and `batchV2` / `batchV3(b24, calls, errorContext)` for bulk operations. See the **transport-convention block at the top of `sdk-helpers.ts`** for how to pick v2 vs v3. The helpers own the `isSuccess` / `getErrorMessages` / transport-error funnel — tool handlers stay short and uniform. Calling `b24.actions.*.{call,batch}.make` directly from a tool handler is forbidden (it duplicates that funnel and drifts over time); the deprecated `b24.callMethod` is doubly forbidden — it disappears in SDK 2.0. See [`adding-tools.md`](./adding-tools.md) for the canonical template. +2. **Never call Bitrix24 directly.** Always go through `useBitrix24Tenant()` (the OAuth-aware dispatcher in `~/server/utils/bitrix24-tenant` — see [`../../docs/OAUTH-DESIGN.md`](../../docs/OAUTH-DESIGN.md) §6). With `NUXT_BITRIX24_OAUTH_ENABLED=false` (the production default) it falls back to the webhook singleton, so behaviour is byte-identical to a direct `useBitrix24()` call. When the flag is on, the same call resolves to a per-tenant `B24OAuth` from the request-scoped ALS. **Never call `useBitrix24()` directly from a tool handler** — it bypasses the dispatcher and pins the tool to webhook forever. From the dispatcher result go through the typed helpers in `server/utils/sdk-helpers.ts`: `callV2(b24, method, params, errorContext)` — the **default** for classic methods (`tasks.task.add/list/update/` + the seven lifecycle verbs, `user.*`, `task.commentitem.*`, …), `callV3(…)` — **only** for methods that are v3-only (currently `tasks.task.get`, `tasks.task.result.*`), and `batchV2` / `batchV3(b24, calls, errorContext)` for bulk operations. See the **transport-convention block at the top of `sdk-helpers.ts`** for how to pick v2 vs v3. The helpers own the `isSuccess` / `getErrorMessages` / transport-error funnel — tool handlers stay short and uniform. Calling `b24.actions.*.{call,batch}.make` directly from a tool handler is forbidden (it duplicates that funnel and drifts over time); the deprecated `b24.callMethod` is doubly forbidden — it disappears in SDK 2.0. See [`adding-tools.md`](./adding-tools.md) for the canonical template. 3. **Every tool must have a unit test** in `tests/unit/tools//.test.ts` with the Bitrix24 client mocked. 4. **Every Zod field must have `.describe()`** — the LLM reads it at runtime. 5. **No secrets in code or tests.** Use `useRuntimeConfig()` and `.env`. When you add/rename/remove a `NUXT_*` / `NITRO_*` variable, change the default port or `/mcp` endpoint, change the connector auth header name/format, alter required webhook scopes, or add a tool needing upfront-seeded portal data, also update the manual-QA scaffold at [`../run-manual-qa/references/issue-scaffold.md`](../run-manual-qa/references/issue-scaffold.md) in the same PR — it mirrors these structural facts. A CI gate enforces the `.env.example` ↔ scaffold pairing. diff --git a/skills/manage-bx24-template-mcp/adding-tools.md b/skills/manage-bx24-template-mcp/adding-tools.md index 9a1b990..46bddda 100644 --- a/skills/manage-bx24-template-mcp/adding-tools.md +++ b/skills/manage-bx24-template-mcp/adding-tools.md @@ -67,8 +67,8 @@ export default defineMcpTool({ // `useBitrix24Tenant()` is the OAuth-aware dispatcher (see // `docs/OAUTH-DESIGN.md §6`). When `NUXT_BITRIX24_OAUTH_ENABLED=false` // (the production default) it returns the webhook singleton — same - // identity as a direct `useBitrix24()` call. When the flag flips on - // (PR-2c), it resolves to a per-tenant `B24OAuth` instance from the + // identity as a direct `useBitrix24()` call. When the flag is on, + // it resolves to a per-tenant `B24OAuth` instance from the // request-scoped ALS. Never call `useBitrix24()` directly from a tool // handler — it bypasses the dispatcher and pins the tool to webhook // forever. diff --git a/skills/run-manual-qa/references/issue-scaffold.md b/skills/run-manual-qa/references/issue-scaffold.md index 32d26d5..b7766e9 100644 --- a/skills/run-manual-qa/references/issue-scaffold.md +++ b/skills/run-manual-qa/references/issue-scaffold.md @@ -90,13 +90,13 @@ Optional, only if the run includes integration tests or evals (see `.env.example `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` (eval LLM), `NUXT_AUDIT_DIR` (OAuth/Bearer audit log destination; default `/data/audit/`, webhook-only manual QA ignores it). -OAuth scaffolding (Phase-3 opt-in, off by default — webhook-only manual QA leaves these unset/false): +OAuth multi-tenant (opt-in, landed and off by default — webhook-only manual QA leaves these unset/false; operator guide in `docs/DEPLOYMENT.md` → "OAuth 2.0 multi-tenant"): `NUXT_BITRIX24_OAUTH_ENABLED` (default `false`; with `=true` the OAuth surface is end-to-end live — install/callback mint a Bearer, `/mcp` accepts it via the toolkit middleware in `server/mcp/index.ts`, and `NUXT_MCP_AUTH_TOKEN` is bypassed on `/mcp`. The four §11 deny branches — `BEARER-UNKNOWN` / `BEARER-REVOKED` / `BEARER-ORPHAN` / no Bearer — all 401 with a `WWW-Authenticate` header carrying the errorCode), `NUXT_BITRIX24_OAUTH_CLIENT_ID` / `NUXT_BITRIX24_OAUTH_CLIENT_SECRET` (from a registered Bitrix24 Marketplace application, needed only when ENABLED=true), `NUXT_BITRIX24_OAUTH_REDIRECT_URL` (no default — must be set to the exact URL registered on the Bitrix24 side when `ENABLED=true`; `.env.example` shows `https://prod.example.com/api/oauth/callback` as a placeholder shape, not a value to copy verbatim), `NUXT_BITRIX24_OAUTH_SCOPE` (default `user,task`), `NUXT_BITRIX24_OAUTH_DB_DIR` (directory that holds the SQLite token store; default `/data`, filename `oauth.sqlite` is fixed in code), -`NUXT_BITRIX24_OAUTH_ADMIN_TOKEN` (operator-only token gating `GET /api/oauth/_health`; deliberately separate from `NUXT_MCP_AUTH_TOKEN`. Leave empty for localhost-only access via nginx allow/deny; the route fails closed (`503 NOT-CONFIGURED`) for a non-localhost request when unset). +`NUXT_BITRIX24_OAUTH_ADMIN_TOKEN` (operator-only token gating `GET /api/oauth/_health`; deliberately separate from `NUXT_MCP_AUTH_TOKEN`. Leave empty for localhost-only access via nginx allow/deny; the route fails closed (`503 NOT-CONFIGURED`) for a non-localhost request when unset. Once set, the Bearer is required uniformly — even a localhost request needs it). ### 4. On the Bitrix24 portal — seed upfront