From 6cb8d92858c9bf6b05ada22b6641abe8fb41dce6 Mon Sep 17 00:00:00 2001 From: Pavel Sokolov Date: Tue, 1 Sep 2026 08:58:39 +0300 Subject: [PATCH] feat(mcp-server): restore the remote MCP server package Reverts commit 4c57cdb (#162), bringing @1c-odata/mcp-server back exactly as removed: the package tree, CI gates 2c/2d/8b, the deploy-pg-smoke / deploy-package-smoke jobs, deploy-compose.yml, release-image.yml and the publish-image job in release.yml, the dependabot docker/docker-compose entries and better-auth exclusions, the root .dockerignore, the deploy/ci Biome override, and the mcp-server changesets (fixed group back to five packages). This PR is parked deliberately (draft, no merge) until the server is production-ready. --- .changeset/config.json | 2 +- .changeset/mcp-connection-source-seam.md | 2 +- ...b-secret-source-and-verify-connectivity.md | 2 +- .changeset/mcp-internal-reexports.md | 12 - .changeset/mcp-server-admin-panel.md | 33 + .changeset/mcp-server-auth-layer.md | 24 + .changeset/mcp-server-deploy-hardening.md | 12 + .changeset/mcp-server-ghcr-image.md | 10 + .changeset/mcp-server-http-transport.md | 25 + .changeset/mcp-server-in-process-jwks.md | 19 + .changeset/mcp-server-jwks-read-timeout.md | 21 + .changeset/mcp-server-multi-tenancy.md | 37 + .changeset/node-24-lts-floor.md | 6 +- .dockerignore | 25 + .github/dependabot.yml | 108 +- .github/workflows/ci.yml | 195 ++ .github/workflows/deploy-compose.yml | 105 + .github/workflows/release-image.yml | 138 + .github/workflows/release.yml | 29 + CLAUDE.md | 19 +- README.md | 3 +- STABILITY.md | 23 +- biome.json | 8 + packages/mcp-server/LICENSE | 21 + packages/mcp-server/README.md | 207 ++ packages/mcp-server/auth-schema.ts | 277 ++ packages/mcp-server/auth.config.ts | 29 + packages/mcp-server/deploy/.env.example | 57 + packages/mcp-server/deploy/Caddyfile | 17 + packages/mcp-server/deploy/Dockerfile | 42 + packages/mcp-server/deploy/README.md | 309 ++ packages/mcp-server/deploy/ci/Caddyfile.ci | 16 + .../mcp-server/deploy/ci/compose-smoke.sh | 75 + packages/mcp-server/deploy/ci/compose.ci.yml | 29 + packages/mcp-server/deploy/ci/mcp-flow.mjs | 223 ++ .../mcp-server/deploy/ci/package-smoke.sh | 81 + packages/mcp-server/deploy/ci/smoke.sh | 105 + packages/mcp-server/deploy/compose.prod.yml | 19 + packages/mcp-server/deploy/compose.yml | 94 + packages/mcp-server/drizzle.config.ts | 20 + .../mcp-server/drizzle/0000_goofy_rhodey.sql | 159 + .../drizzle/0001_right_captain_marvel.sql | 41 + .../0002_huge_supreme_intelligence.sql | 5 + .../drizzle/meta/0000_snapshot.json | 1103 +++++++ .../drizzle/meta/0001_snapshot.json | 1368 +++++++++ .../drizzle/meta/0002_snapshot.json | 1400 +++++++++ .../mcp-server/drizzle/meta/_journal.json | 27 + packages/mcp-server/package.json | 88 + packages/mcp-server/src/auth/better-auth.ts | 143 + packages/mcp-server/src/auth/config.ts | 73 + packages/mcp-server/src/auth/pages/consent.ts | 60 + packages/mcp-server/src/auth/pages/sign-in.ts | 122 + .../mcp-server/src/auth/resource-metadata.ts | 51 + packages/mcp-server/src/auth/verifier.ts | 267 ++ packages/mcp-server/src/cli.ts | 427 +++ .../mcp-server/src/http/account/router.ts | 119 + .../src/http/admin/admin-js-asset.ts | 170 ++ packages/mcp-server/src/http/admin/bases.ts | 399 +++ .../mcp-server/src/http/admin/dashboard.ts | 62 + packages/mcp-server/src/http/admin/grants.ts | 87 + .../mcp-server/src/http/admin/health-job.ts | 163 + .../mcp-server/src/http/admin/htmx-asset.ts | 24 + .../mcp-server/src/http/admin/middleware.ts | 188 ++ packages/mcp-server/src/http/admin/router.ts | 270 ++ .../mcp-server/src/http/admin/server-info.ts | 12 + .../mcp-server/src/http/admin/templates.ts | 240 ++ packages/mcp-server/src/http/admin/users.ts | 399 +++ packages/mcp-server/src/http/admin/views.ts | 130 + packages/mcp-server/src/http/app.ts | 143 + packages/mcp-server/src/http/auth-mount.ts | 89 + packages/mcp-server/src/http/discovery.ts | 78 + packages/mcp-server/src/http/mcp-route.ts | 261 ++ .../mcp-server/src/http/session-registry.ts | 260 ++ packages/mcp-server/src/http/setup/router.ts | 197 ++ packages/mcp-server/src/index.ts | 283 ++ packages/mcp-server/src/logger.ts | 8 + packages/mcp-server/src/server-factory.ts | 79 + packages/mcp-server/src/store/crypto.ts | 220 ++ packages/mcp-server/src/store/db.ts | 52 + packages/mcp-server/src/store/migrate.ts | 62 + packages/mcp-server/src/store/repos.ts | 314 ++ packages/mcp-server/src/store/schema.ts | 21 + .../mcp-server/src/store/tenancy-schema.ts | 125 + .../src/tenancy/db-connection-source.ts | 86 + packages/mcp-server/src/tenancy/grants.ts | 19 + .../mcp-server/src/tenancy/scoped-pool.ts | 62 + packages/mcp-server/src/ui/shell.ts | 346 +++ packages/mcp-server/src/version.ts | 22 + packages/mcp-server/test/e2e/_harness.ts | 374 +++ packages/mcp-server/test/e2e/account.test.ts | 145 + .../mcp-server/test/e2e/admin-create.test.ts | 77 + .../mcp-server/test/e2e/admin-panel.test.ts | 515 ++++ .../test/e2e/create-http-server.test.ts | 195 ++ .../mcp-server/test/e2e/jwks-offline.test.ts | 75 + .../mcp-server/test/e2e/mcp-auth-gate.test.ts | 146 + .../mcp-server/test/e2e/mcp-endpoint.test.ts | 164 ++ .../mcp-server/test/e2e/oauth-jwt.test.ts | 36 + .../test/e2e/session-binding.test.ts | 159 + .../mcp-server/test/e2e/set-password.test.ts | 79 + .../mcp-server/test/e2e/setup-wizard.test.ts | 151 + .../mcp-server/test/unit/admin-bases.test.ts | 494 ++++ .../mcp-server/test/unit/admin-csrf.test.ts | 57 + .../mcp-server/test/unit/admin-gate.test.ts | 53 + .../mcp-server/test/unit/admin-grants.test.ts | 134 + .../test/unit/admin-health-job.test.ts | 194 ++ .../test/unit/admin-probe-classify.test.ts | 34 + .../test/unit/admin-server-info.test.ts | 30 + .../mcp-server/test/unit/admin-users.test.ts | 406 +++ .../test/unit/auth-canonical-urls.test.ts | 48 + .../test/unit/auth-schema-parity.test.ts | 210 ++ .../test/unit/cli-allowed-hosts.test.ts | 62 + .../mcp-server/test/unit/cli-keyring.test.ts | 61 + packages/mcp-server/test/unit/crypto.test.ts | 168 ++ .../mcp-server/test/unit/local-jwks.test.ts | 276 ++ .../mcp-server/test/unit/mcp-route.test.ts | 130 + packages/mcp-server/test/unit/migrate.test.ts | 14 + .../test/unit/resource-metadata.test.ts | 42 + .../test/unit/session-registry.test.ts | 205 ++ .../mcp-server/test/unit/setup-token.test.ts | 93 + .../mcp-server/test/unit/sign-in-page.test.ts | 52 + packages/mcp-server/test/unit/tenancy.test.ts | 193 ++ packages/mcp-server/tsconfig.json | 15 + packages/mcp-server/tsconfig.test.json | 17 + packages/mcp-server/tsdown.config.ts | 12 + packages/mcp-server/vitest.config.ts | 28 + packages/mcp/src/connection-pool.ts | 2 +- packages/mcp/src/connection-source.ts | 2 +- packages/mcp/src/index.ts | 6 +- packages/mcp/src/internal.ts | 8 +- packages/mcp/src/secret-store.ts | 2 +- .../mcp/test/unit/connection-source.test.ts | 6 +- .../mcp/test/unit/internal-surface.test.ts | 4 +- pnpm-lock.yaml | 2609 ++++++++++++++++- 133 files changed, 20507 insertions(+), 79 deletions(-) delete mode 100644 .changeset/mcp-internal-reexports.md create mode 100644 .changeset/mcp-server-admin-panel.md create mode 100644 .changeset/mcp-server-auth-layer.md create mode 100644 .changeset/mcp-server-deploy-hardening.md create mode 100644 .changeset/mcp-server-ghcr-image.md create mode 100644 .changeset/mcp-server-http-transport.md create mode 100644 .changeset/mcp-server-in-process-jwks.md create mode 100644 .changeset/mcp-server-jwks-read-timeout.md create mode 100644 .changeset/mcp-server-multi-tenancy.md create mode 100644 .dockerignore create mode 100644 .github/workflows/deploy-compose.yml create mode 100644 .github/workflows/release-image.yml create mode 100644 packages/mcp-server/LICENSE create mode 100644 packages/mcp-server/README.md create mode 100644 packages/mcp-server/auth-schema.ts create mode 100644 packages/mcp-server/auth.config.ts create mode 100644 packages/mcp-server/deploy/.env.example create mode 100644 packages/mcp-server/deploy/Caddyfile create mode 100644 packages/mcp-server/deploy/Dockerfile create mode 100644 packages/mcp-server/deploy/README.md create mode 100644 packages/mcp-server/deploy/ci/Caddyfile.ci create mode 100755 packages/mcp-server/deploy/ci/compose-smoke.sh create mode 100644 packages/mcp-server/deploy/ci/compose.ci.yml create mode 100644 packages/mcp-server/deploy/ci/mcp-flow.mjs create mode 100755 packages/mcp-server/deploy/ci/package-smoke.sh create mode 100755 packages/mcp-server/deploy/ci/smoke.sh create mode 100644 packages/mcp-server/deploy/compose.prod.yml create mode 100644 packages/mcp-server/deploy/compose.yml create mode 100644 packages/mcp-server/drizzle.config.ts create mode 100644 packages/mcp-server/drizzle/0000_goofy_rhodey.sql create mode 100644 packages/mcp-server/drizzle/0001_right_captain_marvel.sql create mode 100644 packages/mcp-server/drizzle/0002_huge_supreme_intelligence.sql create mode 100644 packages/mcp-server/drizzle/meta/0000_snapshot.json create mode 100644 packages/mcp-server/drizzle/meta/0001_snapshot.json create mode 100644 packages/mcp-server/drizzle/meta/0002_snapshot.json create mode 100644 packages/mcp-server/drizzle/meta/_journal.json create mode 100644 packages/mcp-server/package.json create mode 100644 packages/mcp-server/src/auth/better-auth.ts create mode 100644 packages/mcp-server/src/auth/config.ts create mode 100644 packages/mcp-server/src/auth/pages/consent.ts create mode 100644 packages/mcp-server/src/auth/pages/sign-in.ts create mode 100644 packages/mcp-server/src/auth/resource-metadata.ts create mode 100644 packages/mcp-server/src/auth/verifier.ts create mode 100644 packages/mcp-server/src/cli.ts create mode 100644 packages/mcp-server/src/http/account/router.ts create mode 100644 packages/mcp-server/src/http/admin/admin-js-asset.ts create mode 100644 packages/mcp-server/src/http/admin/bases.ts create mode 100644 packages/mcp-server/src/http/admin/dashboard.ts create mode 100644 packages/mcp-server/src/http/admin/grants.ts create mode 100644 packages/mcp-server/src/http/admin/health-job.ts create mode 100644 packages/mcp-server/src/http/admin/htmx-asset.ts create mode 100644 packages/mcp-server/src/http/admin/middleware.ts create mode 100644 packages/mcp-server/src/http/admin/router.ts create mode 100644 packages/mcp-server/src/http/admin/server-info.ts create mode 100644 packages/mcp-server/src/http/admin/templates.ts create mode 100644 packages/mcp-server/src/http/admin/users.ts create mode 100644 packages/mcp-server/src/http/admin/views.ts create mode 100644 packages/mcp-server/src/http/app.ts create mode 100644 packages/mcp-server/src/http/auth-mount.ts create mode 100644 packages/mcp-server/src/http/discovery.ts create mode 100644 packages/mcp-server/src/http/mcp-route.ts create mode 100644 packages/mcp-server/src/http/session-registry.ts create mode 100644 packages/mcp-server/src/http/setup/router.ts create mode 100644 packages/mcp-server/src/index.ts create mode 100644 packages/mcp-server/src/logger.ts create mode 100644 packages/mcp-server/src/server-factory.ts create mode 100644 packages/mcp-server/src/store/crypto.ts create mode 100644 packages/mcp-server/src/store/db.ts create mode 100644 packages/mcp-server/src/store/migrate.ts create mode 100644 packages/mcp-server/src/store/repos.ts create mode 100644 packages/mcp-server/src/store/schema.ts create mode 100644 packages/mcp-server/src/store/tenancy-schema.ts create mode 100644 packages/mcp-server/src/tenancy/db-connection-source.ts create mode 100644 packages/mcp-server/src/tenancy/grants.ts create mode 100644 packages/mcp-server/src/tenancy/scoped-pool.ts create mode 100644 packages/mcp-server/src/ui/shell.ts create mode 100644 packages/mcp-server/src/version.ts create mode 100644 packages/mcp-server/test/e2e/_harness.ts create mode 100644 packages/mcp-server/test/e2e/account.test.ts create mode 100644 packages/mcp-server/test/e2e/admin-create.test.ts create mode 100644 packages/mcp-server/test/e2e/admin-panel.test.ts create mode 100644 packages/mcp-server/test/e2e/create-http-server.test.ts create mode 100644 packages/mcp-server/test/e2e/jwks-offline.test.ts create mode 100644 packages/mcp-server/test/e2e/mcp-auth-gate.test.ts create mode 100644 packages/mcp-server/test/e2e/mcp-endpoint.test.ts create mode 100644 packages/mcp-server/test/e2e/oauth-jwt.test.ts create mode 100644 packages/mcp-server/test/e2e/session-binding.test.ts create mode 100644 packages/mcp-server/test/e2e/set-password.test.ts create mode 100644 packages/mcp-server/test/e2e/setup-wizard.test.ts create mode 100644 packages/mcp-server/test/unit/admin-bases.test.ts create mode 100644 packages/mcp-server/test/unit/admin-csrf.test.ts create mode 100644 packages/mcp-server/test/unit/admin-gate.test.ts create mode 100644 packages/mcp-server/test/unit/admin-grants.test.ts create mode 100644 packages/mcp-server/test/unit/admin-health-job.test.ts create mode 100644 packages/mcp-server/test/unit/admin-probe-classify.test.ts create mode 100644 packages/mcp-server/test/unit/admin-server-info.test.ts create mode 100644 packages/mcp-server/test/unit/admin-users.test.ts create mode 100644 packages/mcp-server/test/unit/auth-canonical-urls.test.ts create mode 100644 packages/mcp-server/test/unit/auth-schema-parity.test.ts create mode 100644 packages/mcp-server/test/unit/cli-allowed-hosts.test.ts create mode 100644 packages/mcp-server/test/unit/cli-keyring.test.ts create mode 100644 packages/mcp-server/test/unit/crypto.test.ts create mode 100644 packages/mcp-server/test/unit/local-jwks.test.ts create mode 100644 packages/mcp-server/test/unit/mcp-route.test.ts create mode 100644 packages/mcp-server/test/unit/migrate.test.ts create mode 100644 packages/mcp-server/test/unit/resource-metadata.test.ts create mode 100644 packages/mcp-server/test/unit/session-registry.test.ts create mode 100644 packages/mcp-server/test/unit/setup-token.test.ts create mode 100644 packages/mcp-server/test/unit/sign-in-page.test.ts create mode 100644 packages/mcp-server/test/unit/tenancy.test.ts create mode 100644 packages/mcp-server/tsconfig.json create mode 100644 packages/mcp-server/tsconfig.test.json create mode 100644 packages/mcp-server/tsdown.config.ts create mode 100644 packages/mcp-server/vitest.config.ts diff --git a/.changeset/config.json b/.changeset/config.json index de7150c..f3999fd 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -2,7 +2,7 @@ "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", "changelog": ["@changesets/changelog-github", { "repo": "hacker-cb/1c-odata" }], "commit": false, - "fixed": [["@1c-odata/client", "@1c-odata/metadata", "@1c-odata/cli", "@1c-odata/mcp"]], + "fixed": [["@1c-odata/client", "@1c-odata/metadata", "@1c-odata/cli", "@1c-odata/mcp", "@1c-odata/mcp-server"]], "linked": [], "access": "public", "baseBranch": "master", diff --git a/.changeset/mcp-connection-source-seam.md b/.changeset/mcp-connection-source-seam.md index e36da8f..bcd6b67 100644 --- a/.changeset/mcp-connection-source-seam.md +++ b/.changeset/mcp-connection-source-seam.md @@ -2,7 +2,7 @@ "@1c-odata/mcp": minor --- -Refactor `@1c-odata/mcp` for multi-tenant reuse (prep for a remote multi-tenant host). +Refactor `@1c-odata/mcp` for multi-tenant reuse (prep for `@1c-odata/mcp-server`). - New `@1c-odata/mcp/internal` subpath exposing the reusable building blocks — `ConnectionPool`, the new `ConnectionSource`/`FileConnectionSource` seam, `ReadPool`, the read-only tool registrators (`registerSchemaTools`/`registerDataTools`/`registerServerInfoTool`), and the response-limit helpers. The connection-management tools stay off this surface (admin-only). - `ConnectionPool` now takes a `ConnectionSource` (where connections and secrets come from) instead of `{ dataDir }`; the local stdio server injects a `FileConnectionSource` (config.json + keychain) and is unchanged. The read-only tool registrators now accept a `ReadPool`. diff --git a/.changeset/mcp-db-secret-source-and-verify-connectivity.md b/.changeset/mcp-db-secret-source-and-verify-connectivity.md index 8c8a04a..fd10ca1 100644 --- a/.changeset/mcp-db-secret-source-and-verify-connectivity.md +++ b/.changeset/mcp-db-secret-source-and-verify-connectivity.md @@ -2,7 +2,7 @@ "@1c-odata/mcp": patch --- -Prep `@1c-odata/mcp/internal` for a DB-backed multi-tenant host. Additive only — no CLI, MCP-tool, on-disk, or local stdio behavior changed. +Prep `@1c-odata/mcp/internal` for a DB-backed multi-tenant host (`@1c-odata/mcp-server`). Additive only — no CLI, MCP-tool, on-disk, or local stdio behavior changed. - `SecretSource` gains a `'db'` variant so a DB-backed `ConnectionSource` can report the real password origin in `list_connections`. `SecretStore` itself never returns it (file/keychain/env only). - `@1c-odata/mcp/internal` now re-exports `verifyConnectivity` — the standalone `$metadata` reachability probe (no `dataDir` dependency), reused by a remote host to verify a base before saving it. The connection-management functions (`upsertConnection`/`removeConnection`/`updateConnectionCredentials`/`setConnectionLabel`) stay private — they are bound to the file-backed config. diff --git a/.changeset/mcp-internal-reexports.md b/.changeset/mcp-internal-reexports.md deleted file mode 100644 index 3656f0a..0000000 --- a/.changeset/mcp-internal-reexports.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@1c-odata/mcp": patch ---- - -`@1c-odata/mcp/internal` re-exports for alternate hosts: - -- `assertValidConnectionName` / `isValidConnectionName`, so a DB-backed admin - write path enforces the same ASCII connection-name rule as the file-backed - store. -- `InvalidArgumentError` (from `@1c-odata/client`), so a scoping wrapper throws - the pool's canonical not-found error without a new dependency — an ungranted - base is then byte-identical to a missing one. diff --git a/.changeset/mcp-server-admin-panel.md b/.changeset/mcp-server-admin-panel.md new file mode 100644 index 0000000..77c59fa --- /dev/null +++ b/.changeset/mcp-server-admin-panel.md @@ -0,0 +1,33 @@ +--- +"@1c-odata/mcp-server": minor +"@1c-odata/mcp": patch +--- + +feat(mcp-server): admin panel — role-gated server-rendered console (Slice 4) + +Add an internal, `admin`-role-gated admin console to the multi-tenant HTTP +server (mounted at `/admin`, only on the DB-tenancy path). Server-rendered with +Express + Eta + a vendored, CSP-safe htmx (no CDN; `script-src 'self'`). + +- **Dashboard** with a DB-aware `server_info` and a health table that polls the + `health` table every 10 s. +- **Base CRUD** with verify-before-save: every create/edit runs + `verifyConnectivity` first and persists nothing on failure; the 1С password is + sealed write-only (AES-256-GCM via the keyring) and the process-global + connection pool is `refresh()`-ed after each edit. +- **Grant editor** — user × base matrix backed by `GrantRepo` (adds + `listByBase`); toggling a cell grants/revokes immediately. +- **User management** via the better-auth admin API (`createUser` / `setRole`). +- **Health job** — a single-instance `setInterval` that periodically probes each + base with `verifyReachability` (a light GET on the OData service root, not a full + `$metadata` download) and records `ok`/`auth_failed`/`unreachable`; + started/stopped with the server lifecycle. +- **`admin-create` CLI subcommand** — header-less first-admin bootstrap seed + (better-auth ships no CLI for this). + +The gate reads the better-auth browser session (`getSession` + `admin` role), +distinct from the Bearer/JWT machine path on `/mcp`. + +`@1c-odata/mcp` (patch): re-export `assertValidConnectionName` / +`isValidConnectionName` from `/internal` so the admin write path enforces the +same ASCII connection-name rule as the file-backed store. diff --git a/.changeset/mcp-server-auth-layer.md b/.changeset/mcp-server-auth-layer.md new file mode 100644 index 0000000..c3a0791 --- /dev/null +++ b/.changeset/mcp-server-auth-layer.md @@ -0,0 +1,24 @@ +--- +"@1c-odata/mcp-server": minor +--- + +feat(mcp-server): OAuth 2.1 authorization on the HTTP MCP server + +The Streamable HTTP MCP server can now require a Bearer JWT on `/mcp`. Enable it +by passing `--public-url ` (or `ONEC_MCP_PUBLIC_URL`) to `serve`; +`BETTER_AUTH_SECRET` is then required. + +- Embeds a better-auth authorization server (`jwt()` + `admin()` + + `@better-auth/oauth-provider`) mounted at `/api/auth`, backed by a Postgres + store — embedded PGlite for dev/tests, `pg` (via `--pg-url`/`DATABASE_URL`) for + prod. better-auth's own tables only; no per-user base scoping yet. +- Dynamic Client Registration, `/sign-in` and `/consent` pages, RFC 8414 AS + metadata and RFC 9728 Protected Resource Metadata + (`/.well-known/oauth-protected-resource/mcp`) served CORS-open at the root. +- `/mcp` is gated by MCP's `requireBearerAuth`; access tokens are asymmetric JWTs + whose `aud` is the MCP resource id (`${publicUrl}/mcp`), verified offline with + `jose` against the AS's JWKS (issuer + audience pinned, JWT-only). + +Without `--public-url` the server behaves exactly as before (no auth). + +Internal: `createHttpServer` is now async and returns `{ server, close }`. diff --git a/.changeset/mcp-server-deploy-hardening.md b/.changeset/mcp-server-deploy-hardening.md new file mode 100644 index 0000000..d74a4e4 --- /dev/null +++ b/.changeset/mcp-server-deploy-hardening.md @@ -0,0 +1,12 @@ +--- +"@1c-odata/mcp-server": patch +--- + +fix(mcp-server): deploy-stack hardening surfaced by CI smoke coverage + +- `compose.yml`: give the `mcp` service a `/healthz` healthcheck and make `caddy` + wait for `service_healthy`, so first requests don't 502 during boot/migrations + and a boot crash-loop is visible in `docker compose ps`. +- Close idle keep-alive sockets on SIGTERM (`server.closeIdleConnections()`) so + shutdown completes within an orchestrator's grace window; in-flight requests + still drain normally. diff --git a/.changeset/mcp-server-ghcr-image.md b/.changeset/mcp-server-ghcr-image.md new file mode 100644 index 0000000..d5246fd --- /dev/null +++ b/.changeset/mcp-server-ghcr-image.md @@ -0,0 +1,10 @@ +--- +"@1c-odata/mcp-server": minor +--- + +Publish an official multi-arch (`amd64` + `arm64`) container image to GHCR at +`ghcr.io/hacker-cb/1c-odata-mcp-server` on every release, tagged with the exact +version (`X.Y.Z`), the minor (`X.Y`), and `latest`. Run the Compose stack straight +from the image via the new `deploy/compose.prod.yml` overlay — no repo checkout or +build toolchain required. The image carries SLSA build provenance, matching the npm +packages. diff --git a/.changeset/mcp-server-http-transport.md b/.changeset/mcp-server-http-transport.md new file mode 100644 index 0000000..a3c4ff3 --- /dev/null +++ b/.changeset/mcp-server-http-transport.md @@ -0,0 +1,25 @@ +--- +"@1c-odata/mcp-server": minor +--- + +Add `@1c-odata/mcp-server`: a stateful Streamable HTTP MCP server that exposes the +read-only 1С:Enterprise OData V3 tools (`query`, `get_entity`, `count`, +`list_entities`, `describe_entity`, `list_enums`, `list_connections`, +`refresh_metadata`, `register_query`, `server_info`) over HTTP at `/mcp`, for +Claude custom connectors. Read-only surface — the connection-management tools are +intentionally excluded. + +Three run modes, each opting into the next: + +- **no-auth** — loopback only, over a `FileConnectionSource` from `--data-dir`. +- **OAuth 2.1** (`--public-url`) — an embedded better-auth authorization server with + Dynamic Client Registration + PKCE; tokens are verified locally against JWKS. +- **multi-tenancy** (`--enc-key`) — bases live in Postgres with their 1С passwords + encrypted at rest (AES-256-GCM, bound to the base name, key rotatable), per-user + grants, and a server-rendered `/admin` panel whose first admin is bootstrapped + through a one-time `/setup` token. + +Programmatic entry point: `createHttpServer()` is **async** and resolves to a handle +— `{ server, close }` — where `server` is an unstarted `http.Server` you `listen()` +yourself and `close()` releases the auth store. Turnkey self-host (server + Postgres ++ Caddy auto-HTTPS) ships in `packages/mcp-server/deploy`. diff --git a/.changeset/mcp-server-in-process-jwks.md b/.changeset/mcp-server-in-process-jwks.md new file mode 100644 index 0000000..a67389b --- /dev/null +++ b/.changeset/mcp-server-in-process-jwks.md @@ -0,0 +1,19 @@ +--- +'@1c-odata/mcp-server': patch +--- + +Fix JWT verification behind a reverse proxy without hairpin-NAT. + +The resource server used to verify bearer tokens by fetching its own **public** +origin — first `${issuer}/.well-known/oauth-authorization-server`, then the +`jwks_uri` it advertises. In a single-host deploy the container often cannot +resolve or reach that origin (no hairpin-NAT / split-horizon DNS), so every +bearer check failed and OAuth mode was effectively dead. + +The authorization server runs in the same process, so its signing keys are now +read in-process from better-auth instead of over the network. Public discovery is +unchanged — `/.well-known/*` still advertises the public `jwks_uri` that external +clients need. This also removes the URL-driven fetch, and with it the SSRF surface +that the `jwks_uri` origin-pin existed to contain. + +No configuration change is required. diff --git a/.changeset/mcp-server-jwks-read-timeout.md b/.changeset/mcp-server-jwks-read-timeout.md new file mode 100644 index 0000000..8efd3d2 --- /dev/null +++ b/.changeset/mcp-server-jwks-read-timeout.md @@ -0,0 +1,21 @@ +--- +'@1c-odata/mcp-server': patch +--- + +Bound the JWKS read on the bearer-auth path. + +Token verification shares a single in-flight read of the authorization server's +signing keys across every concurrent request. That read had no deadline, so a read +that *hung* — rather than failed — held every bearer check for the life of the +process, and none of the retry paths could run, because they all sit downstream of +that promise settling. A hang is reachable in a Postgres deploy: the connection +pool has no checkout deadline by default, so a saturated pool waits indefinitely, +and a lock can stall the query after checkout. + +Each read now has a 5s deadline. Exceeding it frees the waiting requests and clears +the shared promise, so the next request retries; on the max-age refresh path the +timeout is absorbed and the last good key set keeps serving. + +The deadline is per read, and one request can make two — an aged set whose refresh +times out, then a miss on a rotated-in key — so a single bearer check is bounded at +10s in that case rather than 5s. diff --git a/.changeset/mcp-server-multi-tenancy.md b/.changeset/mcp-server-multi-tenancy.md new file mode 100644 index 0000000..b2c753e --- /dev/null +++ b/.changeset/mcp-server-multi-tenancy.md @@ -0,0 +1,37 @@ +--- +'@1c-odata/mcp-server': minor +'@1c-odata/mcp': patch +--- + +feat(mcp-server): multi-tenant, per-user base scoping with encrypted secrets + +Adds a database-backed multi-tenancy layer to the remote MCP server, active +**only when auth is enabled** (a `--public-url` deployment that also supplies an +encryption key). Without auth, the server is unchanged: the file-backed +`FileConnectionSource` and an unscoped connection pool. + +- **Encrypted secrets at rest.** 1С passwords are sealed with AES-256-GCM + (`src/store/crypto.ts`). The base name is the AAD, so a stored secret is + cryptographically bound to its base — a swapped ciphertext fails to decrypt. Each + row records the `key_id` that sealed it, so the KEK can be rotated: supply the + current key via `--enc-key` or `ONEC_MCP_ENC_KEY` (base64 32-byte; + `openssl rand -base64 32`) and any retired keys via `ONEC_MCP_ENC_KEYS_PREVIOUS`. + A missing/malformed key fails boot loudly. +- **Our tables** (`bases`, `base_secrets`, `grants`, `health`) live in a + hand-written `src/store/tenancy-schema.ts`, merged with better-auth's generated + schema. `grants.sub` FKs `user.id`. The committed `drizzle/0001_*.sql` ships the + DDL and is applied by the drizzle-orm migrator on BOTH paths — pglite (dev/tests) + and Postgres (prod) run the exact same SQL. +- **Per-user scoping.** A `DbConnectionSource` decrypts secrets at read time; a + per-session `ScopedPool` fronts the shared pool and restricts every operation + to the caller's granted bases, resolving grants **fresh on every tool-call** so + a revoked grant takes effect on the user's next call — no reconnect. An + ungranted base yields the **same** `No connection named "…"` error as a base + that does not exist, so scoping never leaks base existence. +- **Session ↔ subject binding.** Each MCP session is pinned to the `sub` that + opened it; a different valid token replaying that session id is rejected with + `403` — a valid bearer token can no longer hijack another user's session. + +`@1c-odata/mcp` gets a one-line internal re-export (`InvalidArgumentError` from +`/internal`) so the scoped pool throws the pool's canonical not-found error +without a new dependency. diff --git a/.changeset/node-24-lts-floor.md b/.changeset/node-24-lts-floor.md index 33834ba..1523d0a 100644 --- a/.changeset/node-24-lts-floor.md +++ b/.changeset/node-24-lts-floor.md @@ -3,6 +3,7 @@ '@1c-odata/metadata': minor '@1c-odata/cli': minor '@1c-odata/mcp': minor +'@1c-odata/mcp-server': minor --- **Breaking:** the minimum supported Node version is now 24.18.0 (was 22.21.0). @@ -12,8 +13,9 @@ the active LTS line and is supported until 2028-04-30. Installing on Node 22 will now be refused or warned about by npm/pnpm, depending on your client. No source change accompanies this. The library does not yet use any API that -Node 22 lacks — the floor moves so that the version CI exercises and the version -the packages advertise are one and the same. Keeping the advertised floor below the tested one meant the +Node 22 lacks — the floor moves so that the version CI exercises, the version +the published container image runs, and the version the packages advertise are +one and the same. Keeping the advertised floor below the tested one meant the promise was never actually verified, which is the defect this closes. `@types/node` is pinned to the matching major for the same reason: types diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..890eaa6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +# Keep the mcp-server Docker build context lean + hermetic — dependencies and +# every package's dist/ are reinstalled/rebuilt inside the image, not copied. + +**/node_modules +**/dist +**/.turbo +**/coverage +**/*.tsbuildinfo +**/*.log + +# VCS / CI / editor / agent noise +.git +.github +.claude +.vscode +.DS_Store + +# Large trees not needed to build the server (kept out of context) +docs +snapshots + +# Never bake local env/secrets into image layers (but keep the templates) +**/.env +**/.env.* +!**/.env.example diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c93125a..cb0ca27 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,10 +2,20 @@ # # Scope note: an entry is matched by (package-ecosystem, directory) — a manifest # is only covered when BOTH match. Everything this repo actually consumes is -# enumerated below: workflows + composite actions, and the pnpm workspace. +# enumerated below: workflows + composite actions, the pnpm workspace, and the +# mcp-server deploy tree's container images. # -# Cooldown rationale (repeated per ecosystem below): this repo PUBLISHES four -# npm packages, so a compromised upstream release propagates +# NOT coverable by Dependabot, must be bumped by hand: +# - the `deploy-pg-smoke` service container in .github/workflows/ci.yml, at +# `jobs.deploy-pg-smoke.services.postgres.image` (grep for `image: postgres`). +# Service container images live inside a workflow file: `github-actions` +# only tracks `uses:` references, and the `docker` ecosystem never scans +# .github/workflows. Keep it in lock-step with the `postgres` image in +# packages/mcp-server/deploy/compose.yml — a version skew there means the +# deploy-pg-smoke gate stops testing what production runs. +# +# Cooldown rationale (repeated per ecosystem below): this repo PUBLISHES five +# npm packages and a GHCR image, so a compromised upstream release propagates # straight into artifacts other people install. Malicious npm versions are # typically detected and unpublished within a few days, so holding new releases # for a cooldown window is a cheap, high-value supply-chain mitigation. Cooldown @@ -94,23 +104,105 @@ updates: - dependency-name: '@types/node' update-types: [version-update:semver-major] groups: - # Minor + patch bumps are grouped weekly, production deps separately from - # dev deps ("batch the boring, isolate the risky" — same principle as the - # ungrouped github-actions entry above). Majors match no group and arrive - # as individual PRs. + # The better-auth family is excluded from BOTH groups — it is the one + # dependency set whose bumps can change GENERATED, COMMITTED artifacts. + # `packages/mcp-server/auth-schema.ts` is emitted from the betterAuth plugin + # set, so a version move on either side of that seam can alter the emitted + # tables and turn CI gate 2c red: + # - runtime (`better-auth`, `@better-auth/drizzle-adapter`, + # `@better-auth/oauth-provider`) declares WHICH tables the plugins need; + # - `@better-auth/cli` (dev) is the generator that RENDERS them, and it is + # versioned on its own line (1.4.x while the runtime is on 1.6.x), so the + # two move independently. + # Batched into a group, such a bump arrives alongside biome/vitest/pino and a + # red gate 2c has an ambiguous cause and cannot be reverted in isolation. This + # is the same "batch the boring, isolate the risky" principle the + # github-actions entry above applies. Excluded from every group, these arrive + # as individual PRs with one unambiguous signal each. npm-prod-minor-patch: dependency-type: production update-types: [minor, patch] + exclude-patterns: + - better-auth + - '@better-auth/*' npm-dev-minor-patch: dependency-type: development update-types: [minor, patch] + exclude-patterns: + - '@better-auth/*' + + # Container base images in the mcp-server deploy tree. + # + # `docker` and `docker-compose` are two DISTINCT ecosystems (Compose reached + # GA as its own ecosystem in Feb 2025) and both are needed here: `docker` + # fetches Dockerfile/Containerfile, `docker-compose` fetches + # compose*.y[a]ml. Pointing both at the same directory is safe — the `docker` + # fetcher's YAML branch only accepts Kubernetes manifests (it requires + # top-level `apiVersion` + `kind`), which a Compose file never has, so the + # compose files are not picked up twice and no duplicate PRs result. + # + # This entry covers packages/mcp-server/deploy/Dockerfile + # (`node:22-bookworm-slim`, builder + runtime stages). + - package-ecosystem: docker + directory: /packages/mcp-server/deploy + schedule: + interval: weekly + cooldown: + default-days: 7 + commit-message: + prefix: chore + include: scope + ignore: + # The runtime image must track the Node line the packages declare in + # `engines`, and Node's release cadence makes an automated major bump + # actively unsafe: ODD majors are NEVER promoted to LTS and are supported + # for about eight months. The first PR this entry ever opened proposed + # node:24 -> node:25 — a line that had already reached end-of-life, which + # would have put the shipped GHCR image on an unsupported runtime. + # + # Minor/patch bumps still flow (that is where the CVE fixes are). A major + # move is a deliberate change that must happen in the same commit as the + # `engines.node` floor and .nvmrc — see the Node 24 migration for the full + # set of places that have to move together. + - dependency-name: node + update-types: [version-update:semver-major] + + # Compose files under the same directory: compose.yml + # (`postgres:17-bookworm`, `caddy:2`) and compose.prod.yml. + # + # deploy/ci/compose.ci.yml is intentionally NOT listed: its only image is the + # locally-built `onec-mcp:ci`, which resolves against no registry. + - package-ecosystem: docker-compose + directory: /packages/mcp-server/deploy + schedule: + interval: weekly + cooldown: + default-days: 7 + commit-message: + prefix: chore + include: scope + ignore: + # Our own published image, referenced as + # `ghcr.io/hacker-cb/1c-odata-mcp-server:${MCP_IMAGE_TAG:-latest}` in + # compose.prod.yml. Its tag is chosen by release-image.yml, not by + # Dependabot; listing it explicitly documents that rather than relying on + # the env-substitution happening to be unparseable as a version. + - dependency-name: ghcr.io/hacker-cb/1c-odata-mcp-server + # Postgres majors are a data migration (pg_upgrade / dump+restore), never + # a dependency bump — an auto-merged `postgres:17 -> 18` would break every + # existing self-host volume on restart. Minor/patch bumps still flow. + # When a major is genuinely wanted, do it as a deliberate PR that also + # moves the ci.yml service container in the same commit. + - dependency-name: postgres + update-types: [version-update:semver-major] # Not configured, deliberately: # - `target-branch`: omitted, so updates land on the default branch (master), # which is this repo's trunk and the only branch updates belong on. # - `labels`: left at Dependabot's default (`dependencies`). A custom list here # would replace that default rather than add to it, and this repo classifies # issues with the stock labels only. -# - `registries`: nothing to declare — every source is public (npmjs). +# - `registries`: nothing to declare — every source is public (npmjs, Docker +# Hub, GHCR public image). # - `insecure-external-code-execution`: not applicable; it is only honoured by # bundler, mix and pip, none of which this repo uses. # diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07682cb..f967202 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,6 +120,119 @@ jobs: run: | pnpm --filter basic-example typecheck pnpm --filter dynamic-example typecheck + - name: Auth schema + drizzle migrations re-derive clean (gate 2c) + # Both generated-and-committed artifacts must reproduce from their inputs: + # auth.config.ts --(@better-auth/cli)--> auth-schema.ts --(drizzle-kit)--> drizzle/*.sql + # drizzle/*.sql is the SINGLE source of truth applied at runtime by BOTH + # dev/tests (pglite) and prod (pg) — see src/store/migrate.ts — so a stale + # committed SQL would only surface as a prod boot failure otherwise. + # + # This INVOKES the package's own `auth:schema` script rather than + # re-implementing a stage. The original defect was exactly a gate that ran + # `drizzle-kit generate` directly and skipped stage 1: the one command that + # would have exercised the (broken) generator was routed around, so the gate + # stayed green while `auth:schema` — the command its own error message tells + # you to run — could not run at all. A gate that runs the documented command + # cannot drift from the docs. Ubuntu-only — deterministic, OS-independent. + if: matrix.os == 'ubuntu-latest' + run: | + # stdin from /dev/null covers BOTH generators in the script. `better-auth + # generate` would prompt before overwriting auth-schema.ts, which the + # script's `--yes` suppresses; `drizzle-kit generate` prompts only on an + # ambiguous rename. With no TTY either read gets EOF and fails fast + # instead of hanging to the job timeout. The common add/modify drift case + # (what this guard targets) never prompts. + pnpm -F @1c-odata/mcp-server auth:schema < /dev/null + if [ -n "$(git status --porcelain packages/mcp-server/auth-schema.ts packages/mcp-server/drizzle)" ]; then + echo "::error::auth schema / drizzle migrations out of sync — run 'pnpm -F @1c-odata/mcp-server auth:schema' and commit packages/mcp-server/{auth-schema.ts,drizzle}" + git status --porcelain packages/mcp-server/auth-schema.ts packages/mcp-server/drizzle + git --no-pager diff packages/mcp-server/auth-schema.ts packages/mcp-server/drizzle + exit 1 + fi + - name: Applied migrations are append-only (gate 2d) + # drizzle-orm decides what to apply from `created_at` ALONE. Its migrator + # (drizzle-orm/pg-core/dialect.js) reads only the newest applied row — + # `select ... order by created_at desc limit 1` — and runs a migration iff + # `lastDbMigration.created_at < migration.folderMillis`. It WRITES each + # migration's `hash` but never compares it again. + # + # So editing a migration a database has already applied is SILENTLY SKIPPED + # there, while a fresh database (CI, pglite, a new self-host) applies the + # edited version. Prod and CI then diverge with nothing failing — and since + # mcp-server ships `files: ["dist","drizzle",...]`, the blast radius includes + # other people's databases. Gate 2c cannot see this: it only proves the SQL + # re-derives from the schema, not that history stayed stable. + # + # Hence: migrations are APPEND-ONLY once merged. To change applied DDL, add + # a NEW migration; never edit or delete an existing one. `_journal.json` may + # only grow — its existing entries carry the `when`/folderMillis values the + # comparison above depends on. + if: github.event_name == 'pull_request' && matrix.os == 'ubuntu-latest' + env: + # Via env rather than inline `${{ }}`, so nothing from the event + # interpolates into the shell. + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + d=packages/mcp-server/drizzle + # The PR checkout is shallow and sits on the merge ref, so the base commit + # is usually absent — fetch just that one object. + git fetch --no-tags --depth=1 origin "$BASE_SHA" + # ALLOWLIST the only legitimate status: `A` (added). Append-only means a + # new file is the sole permitted change, so everything else is rejected — + # modify, delete, rename (`R###`), copy, type-change. Enumerating the + # FORBIDDEN statuses instead would leak: rename detection is on by + # default since git 2.9, so `git mv` of an applied migration reports + # `R100` with no accompanying `D` line and would slip through an `^[MD]` + # test. No `|| true` on the diff itself either — a git failure must abort + # the step (set -e), never yield an empty result that reads as "clean". + changed=$(git diff --name-status "$BASE_SHA" HEAD -- "$d/*.sql" "$d/meta/*_snapshot.json") + illegal=$(printf '%s\n' "$changed" | grep -vE '^(A|$)' || true) + if [ -n "$illegal" ]; then + echo "::error::applied migrations are append-only — an existing migration or snapshot was modified, deleted or renamed. Add a NEW migration instead of touching one that databases may already have applied." + printf '%s\n' "$illegal" + exit 1 + fi + # `_journal.json` may change, but only by appending, and an appended + # entry's `when` must be strictly greater than every entry before it. + # A merely-appended entry is NOT enough: `when` IS the folderMillis the + # migrator compares, so a migration generated before one already on the + # base branch (two concurrent migration PRs, the second rebased) carries + # a LOWER `when`. Databases that already applied the newer one would then + # skip it forever while fresh databases apply it — the very divergence + # this gate exists to stop, reached by an append rather than an edit. + # (Single-quoted node script: the `$`/backtick forms below are JS, not + # shell — nothing here is meant to expand.) + j="$d/meta/_journal.json" + if [ -n "$(git diff --name-only "$BASE_SHA" HEAD -- "$j")" ]; then + git show "$BASE_SHA:$j" > /tmp/journal-base.json + # shellcheck disable=SC2016 + node -e ' + const fs = require("node:fs") + const read = (p) => JSON.parse(fs.readFileSync(p, "utf8")).entries ?? [] + const base = read("/tmp/journal-base.json") + const head = read(process.argv[1]) + const fail = (msg) => { console.error("::error::" + msg); process.exit(1) } + if (head.length < base.length) { + fail("_journal.json lost entries (" + base.length + " -> " + head.length + ") — migrations are append-only") + } + for (let i = 0; i < base.length; i++) { + if (JSON.stringify(base[i]) !== JSON.stringify(head[i])) { + fail("_journal.json entry " + i + " was modified — migrations are append-only. before: " + + JSON.stringify(base[i]) + " after: " + JSON.stringify(head[i])) + } + } + for (let i = 1; i < head.length; i++) { + if (!(head[i].when > head[i - 1].when)) { + fail("_journal.json entry " + i + " (" + head[i].tag + ", when=" + head[i].when + + ") does not come strictly after entry " + (i - 1) + " (" + head[i - 1].tag + + ", when=" + head[i - 1].when + "). drizzle applies a migration only when" + + " created_at < folderMillis, so this one would be skipped on any database that" + + " already applied the previous entry. Regenerate it so its timestamp is newest.") + } + } + ' "$j" + fi test-and-build: runs-on: ${{ matrix.os }} @@ -140,6 +253,22 @@ jobs: coverage: ${{ matrix.os == 'ubuntu-latest' }} - name: E2E tests (gate 8) run: pnpm turbo test:e2e --filter='./packages/*' + - name: mcp-server bin boots on Windows (gate 8b) + # The Linux deploy tiers (deploy-pg-smoke / deploy-package-smoke) never run + # on Windows, yet the bin's OS-sensitive bits regress on NTFS: the shebang + + # realpathSync entry guard, and migrationsFolder()'s dirname/join walk over + # backslash paths. admin-create exercises exactly those — it drives + # runAuthMigrations from the built dist against a throwaway pglite store, + # with NO port bind (so no server/curl flake). `gates` already built dist/. + if: matrix.os == 'windows-latest' + shell: bash + env: + BETTER_AUTH_SECRET: ci-windows-secret-not-for-prod-0123456789 + run: | + node packages/mcp-server/dist/cli.js --help | grep -q admin-create + node packages/mcp-server/dist/cli.js admin-create \ + --auth-data-dir "$RUNNER_TEMP/mcp-store" --public-url http://127.0.0.1:3000 \ + --email a@b.co --password 'Password123!' | grep -qi 'admin user created' - name: Upload coverage if: success() && matrix.os == 'ubuntu-latest' uses: actions/upload-artifact@v7 @@ -150,6 +279,72 @@ jobs: # masked the fact that coverage was never generated at all. if-no-files-found: error + deploy-pg-smoke: + runs-on: ubuntu-latest + needs: lint-and-typecheck + timeout-minutes: 10 + # Boots the shipped `pnpm deploy --prod` tree against a REAL Postgres and walks + # the deploy paths the pglite unit/e2e suite structurally cannot: the + # node-postgres migrator, the setup token's single-use guarantee under TRUE + # concurrency (pglite serializes; a prod pg.Pool does not), the Host guard + # end-to-end, and SIGTERM + idempotent re-migration. + # + # SECRET-FREE by construction: a throwaway `services:` Postgres + app secrets + # from `openssl rand`; no 1С base, no proxy. => NO `needs: detect-secrets`; runs + # on fork PRs; CAN be a required check. + # GUARDRAIL: never add any `secrets.*` here — that demands the owner gate and + # forfeits fork coverage. A secret-bearing deploy variant belongs in its own file. + services: + postgres: + image: postgres:17-bookworm + env: + POSTGRES_USER: mcp + POSTGRES_PASSWORD: mcp + POSTGRES_DB: mcp + ports: + - 5433:5432 # host 5433 dodges any preinstalled pg on 5432 + options: >- + --health-cmd "pg_isready -U mcp -d mcp" + --health-interval 5s --health-timeout 5s --health-retries 10 + steps: + - uses: actions/checkout@v7 + - uses: ./.github/actions/setup + - name: Emit the --prod deploy tree + run: pnpm --filter @1c-odata/mcp-server deploy --prod --legacy /tmp/app + - name: Boot on real pg + walk security-critical paths + env: + APP_DIR: /tmp/app + DATABASE_URL: postgres://mcp:mcp@127.0.0.1:5433/mcp + ONEC_MCP_PUBLIC_URL: http://127.0.0.1:3000 + run: bash packages/mcp-server/deploy/ci/smoke.sh + - name: Dump server logs on failure + if: failure() + # Redact the first-run setup token: a failure BEFORE the race consumes it + # would otherwise print a still-live one-time token into the CI log. + run: cat /tmp/mcp1.log /tmp/mcp2.log 2>/dev/null | sed -E 's/token=[A-Za-z0-9_-]+/token=REDACTED/g' || true + + deploy-package-smoke: + runs-on: ubuntu-latest + needs: lint-and-typecheck + timeout-minutes: 10 + # Sole owner of the published-package path: pack the four @1c-odata/* tarballs, + # install them into a scratch project (pinned to the local tarballs so npm never + # hits the registry for the unpublished siblings), and drive the AUTH path so + # the migrator resolves `drizzle/` from the real node_modules layout. Catches a + # dropped `files` entry / broken `bin` / misfiled runtime dep that `package:lint` + # (types + exports only) cannot. Secret-free; fork-safe. + steps: + - uses: actions/checkout@v7 + - uses: ./.github/actions/setup + - name: Pack + install the tarball, boot the bin + env: + REPO: ${{ github.workspace }} + TMP: ${{ runner.temp }}/pkgsmoke + run: bash packages/mcp-server/deploy/ci/package-smoke.sh + - name: Dump boot log on failure + if: failure() + run: cat "${{ runner.temp }}/pkgsmoke/pk.log" 2>/dev/null || true + test-example: runs-on: ubuntu-latest needs: [detect-secrets, test-and-build] diff --git a/.github/workflows/deploy-compose.yml b/.github/workflows/deploy-compose.yml new file mode 100644 index 0000000..893ff39 --- /dev/null +++ b/.github/workflows/deploy-compose.yml @@ -0,0 +1,105 @@ +name: Deploy compose smoke + +# Brings up the FULL shipped Docker Compose stack (db + mcp + caddy) and drives it +# through Caddy over real TLS (Caddy's internal CA, verified chain — never -k). The +# ONLY prod-vs-CI delta is the cert issuer: real ACME/Let's Encrypt needs public +# DNS + inbound 80/443, which a hosted runner cannot provide. Everything else — the +# image, compose orchestration, auto-HTTPS wiring, Host preservation through the +# proxy, the wizard, the break-glass CLI — is exercised as shipped. +# +# Path-scoped + nightly + manual, NOT a required check: a path-scoped required +# check skips-and-blocks on unrelated PRs, so it lives in its own file rather than +# in ci.yml's required matrix. +# +# SECURITY — READ BEFORE EDITING (mirrors ci.yml's maintainer warning): +# This job BUILDS AND RUNS fork-PR code (Dockerfile RUN steps + the app). It is +# safe ONLY because it uses `pull_request` (never pull_request_target), +# `permissions: contents: read`, and references ZERO secrets. Do NOT add +# `pull_request_target`. Do NOT add any `secrets.*` — a secret-bearing variant +# (e.g. a live-1С egress overlay) belongs in a SEPARATE file with the +# detect-secrets owner gate. +on: + pull_request: + paths: + - "packages/mcp-server/deploy/**" + - "packages/mcp-server/drizzle/**" + - "packages/mcp-server/src/**" + - "pnpm-lock.yaml" + - ".github/workflows/deploy-compose.yml" + push: + branches: [master] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + deploy-compose-smoke: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false # the job never uses the git token + - uses: docker/setup-buildx-action@v4 + - name: Lint both Caddyfiles (compensating control for un-runnable real ACME) + # Validate the SHIPPED Caddyfile AND the CI-only root config that actually + # drives this run (Caddyfile.ci imports the shipped file as /etc/caddy/site.caddy) + # — else a syntax error in the file that DIFFERS from prod would only surface + # as a slow crash-loop that times out `up --wait`, not a fast, attributed error. + # MCP_PUBLIC_DOMAIN MUST be set: the shipped Caddyfile's site address is + # {$MCP_PUBLIC_DOMAIN}{…}; unset → empty → a leading {…} parsed as a bogus + # global-options block → validate fails for the WRONG reason. + run: | + d=packages/mcp-server/deploy + docker run --rm -e MCP_PUBLIC_DOMAIN=mcp.test \ + -v "$PWD/$d/Caddyfile:/f:ro" \ + caddy:2 caddy validate --config /f --adapter caddyfile + docker run --rm -e MCP_PUBLIC_DOMAIN=mcp.test \ + -v "$PWD/$d/ci/Caddyfile.ci:/etc/caddy/Caddyfile:ro" \ + -v "$PWD/$d/Caddyfile:/etc/caddy/site.caddy:ro" \ + caddy:2 caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile + - name: Build the image (GHA-cached) + uses: docker/build-push-action@v7 + with: + context: . # repo root — the workspace build needs the sibling packages + file: packages/mcp-server/deploy/Dockerfile + load: true + tags: onec-mcp:ci + cache-from: type=gha + # Restrict cache WRITE to trusted push events — on a fork PR, mode=max + # would cache builder stages built from PR-controlled RUN steps. + cache-to: ${{ github.event_name == 'push' && 'type=gha,mode=max' || '' }} + - name: Up (internal-CA TLS) + smoke through Caddy + working-directory: packages/mcp-server/deploy + run: | + # POSTGRES_PASSWORD must be URL-safe: compose.yml interpolates it RAW into + # DATABASE_URL, so `openssl rand -hex` (never -base64, which can emit '/'). + { + echo "MCP_PUBLIC_DOMAIN=mcp.test" + echo "MCP_PUBLIC_URL=https://mcp.test" + echo "BETTER_AUTH_SECRET=$(openssl rand -base64 32)" + echo "ONEC_MCP_ENC_KEY=$(openssl rand -base64 32)" + echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" + } > .env + # The smoke reaches Caddy's base 0.0.0.0:443 publish via curl --resolve, + # so no /etc/hosts entry is needed. + docker compose -f compose.yml -f ci/compose.ci.yml up -d --wait --wait-timeout 300 + bash ci/compose-smoke.sh + - name: Dump compose logs on failure + if: failure() + working-directory: packages/mcp-server/deploy + run: | + # Redact the first-run setup token: a failure before the wizard POST + # consumes it would otherwise print a still-live one-time token. + docker compose -f compose.yml -f ci/compose.ci.yml logs --no-color 2>&1 \ + | sed -E 's/token=[A-Za-z0-9_-]+/token=REDACTED/g' || true + docker compose -f compose.yml -f ci/compose.ci.yml ps || true + - name: Tear down + if: always() + working-directory: packages/mcp-server/deploy + run: docker compose -f compose.yml -f ci/compose.ci.yml down -v || true diff --git a/.github/workflows/release-image.yml b/.github/workflows/release-image.yml new file mode 100644 index 0000000..3377a8a --- /dev/null +++ b/.github/workflows/release-image.yml @@ -0,0 +1,138 @@ +name: Release image + +# Builds and pushes the multi-arch @1c-odata/mcp-server container image to GHCR. +# Two entry points: +# - workflow_call — invoked by release.yml right after the npm publish + tag, so +# the image tag tracks the changesets `fixed` version 1:1 (image X.Y.Z == +# npm X.Y.Z == git tag vX.Y.Z). +# - workflow_dispatch — manual backfill / re-publish of a given version (e.g. if +# an earlier push failed after the tag was created). +# +# No secrets: pushes to ghcr.io under the repo owner with the built-in GITHUB_TOKEN +# (packages: write). Idempotent — skips the expensive multi-arch build entirely when +# the image tag already exists, mirroring release.yml's `npm view` self-healing. +on: + workflow_call: + inputs: + version: + description: Version to tag the image with (blank → packages/client/package.json). + type: string + required: false + default: "" + workflow_dispatch: + inputs: + version: + description: Version to tag the image with (blank → packages/client/package.json). + type: string + required: false + default: "" + +# Least privilege: read the tree, write the package. No id-token — build-push-action's +# `provenance: true` is BuildKit's UNSIGNED SLSA attestation (no OIDC/sigstore), so no +# OIDC token is minted. Add `id-token: write` only if cosign keyless signing lands. +permissions: + contents: read + packages: write + +env: + IMAGE: ghcr.io/${{ github.repository_owner }}/1c-odata-mcp-server + +concurrency: + # Serialize per ref so a manual dispatch can't race the release-driven call. + group: release-image-${{ github.ref }} + cancel-in-progress: false + +jobs: + image: + if: github.repository == 'hacker-cb/1c-odata' + runs-on: ubuntu-latest + timeout-minutes: 30 # arm64 under QEMU roughly doubles the amd64-only build time + steps: + # Build the SOURCE that matches the version, not whatever HEAD happens to be: + # when a version is supplied, check out its release tag `v`. This makes + # the release-driven call race-proof (a master push landing between the tag and + # this job can't leak into the image) and makes a dispatch backfill build the + # OLD version's real source instead of current master. Blank version (bare + # dispatch) → default ref, and Resolve version reads the tree's package.json. + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.version != '' && format('v{0}', inputs.version) || '' }} + persist-credentials: false # the build never uses the git token + # Prefer the caller-supplied version (release.yml passes the just-released + # fixed-group version); fall back to the checked-out tree for a bare dispatch. + # inputs.version reaches the shell via env (never inline `${{ }}` in run:) so a + # crafted dispatch input can't inject shell — the job holds `packages: write`. + - name: Resolve version + id: v + env: + INPUT_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + v="$INPUT_VERSION" + if [ -z "$v" ]; then v=$(node -p "require('./packages/client/package.json').version"); fi + echo "version=$v" >> "$GITHUB_OUTPUT" + echo "mm=$(printf '%s' "$v" | cut -d. -f1,2)" >> "$GITHUB_OUTPUT" # 0.7.0 → 0.7 + echo "image version: $v" + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + # Idempotency BEFORE the costly QEMU/buildx setup: a repeated master push (or a + # dispatch of an already-built version) is a fast no-op instead of a full rebuild. + - name: Skip if this version is already published + id: exists + env: + VERSION: ${{ steps.v.outputs.version }} # via env, never inline in run: + run: | + set -euo pipefail + if docker buildx imagetools inspect "$IMAGE:$VERSION" >/dev/null 2>&1; then + echo "already published: $IMAGE:$VERSION" + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + - name: Set up QEMU (arm64 emulation) + if: steps.exists.outputs.skip != 'true' + uses: docker/setup-qemu-action@v4 + - name: Set up Buildx + if: steps.exists.outputs.skip != 'true' + uses: docker/setup-buildx-action@v4 + - name: Image metadata (tags + OCI labels) + if: steps.exists.outputs.skip != 'true' + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ env.IMAGE }} + # type=raw with a pre-resolved value is unconditionally emitted on ANY event + # (workflow_call/dispatch) — unlike type=semver, whose default source is the + # git tag ref. The immutable `X.Y.Z` and `sha-*` tags are always safe. The + # FLOATING `X.Y` and `latest` tags move only on the release-driven path + # (workflow_call from release.yml, whose event is `push`); a workflow_dispatch + # backfill of an older version must NOT roll them back, so they're gated off + # for dispatch. No bare-major `0` tag: during 0.x every minor is breaking + # (STABILITY.md), so a moving `0` would span incompatible releases. + tags: | + type=raw,value=${{ steps.v.outputs.version }} + type=raw,value=${{ steps.v.outputs.mm }},enable=${{ github.event_name != 'workflow_dispatch' }} + type=raw,value=latest,enable=${{ github.event_name != 'workflow_dispatch' }} + type=sha,format=short + labels: | + org.opencontainers.image.title=1c-odata-mcp-server + org.opencontainers.image.description=Remote MCP server for 1С:Enterprise OData bases + org.opencontainers.image.licenses=MIT + - name: Build & push (amd64 + arm64) + if: steps.exists.outputs.skip != 'true' + uses: docker/build-push-action@v7 + with: + context: . # repo root — the workspace build needs the sibling packages + file: packages/mcp-server/deploy/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + provenance: true # SLSA build provenance, aligned with npm's --provenance + sbom: true + cache-from: type=gha,scope=release-image + cache-to: type=gha,mode=max,scope=release-image diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc5665e..1d6802a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,12 @@ jobs: contents: write pull-requests: write id-token: write # for npm provenance + outputs: + # Consumed by the publish-image job below. `hasChangesets == 'false'` is the + # same signal the npm-publish step gates on (release PR merged, or a plain + # master push). `version` is the fixed-group version (client stands in for all). + has_changesets: ${{ steps.changesets.outputs.hasChangesets }} + version: ${{ steps.relmeta.outputs.version }} steps: - uses: actions/checkout@v7 with: @@ -32,6 +38,14 @@ jobs: # OIDC token is fetched directly by `npm publish` via the # `id-token: write` permission — no .npmrc auth needed. - uses: ./.github/actions/setup + # The fixed-group version (all packages share it) — surfaced as a job output + # so publish-image tags the container image with the same version npm gets. + # On a release-PR run the tree still holds the OLD version, but publish-image + # only consumes this when has_changesets=='false' (post-merge), where master + # already carries the bumped version. + - name: Resolve release version + id: relmeta + run: echo "version=$(node -p "require('./packages/client/package.json').version")" >> "$GITHUB_OUTPUT" # Same gate sequence as ci.yml, shared via ./.github/actions/gates (each # gate is a separate turbo invocation — the action documents why). Typecheck # stays inline here because ci.yml runs it in a separate matrix job. @@ -137,3 +151,18 @@ jobs: if [ -z "$PR_NUM" ]; then exit 0; fi VERSION=$(gh api "repos/${{ github.repository }}/contents/packages/client/package.json?ref=changeset-release/master" --jq '.content' | base64 -d | jq -r '.version') gh pr edit "$PR_NUM" --title "chore(release): v${VERSION}" + + # Publish the multi-arch container image to GHCR, tagged with the just-released + # fixed-group version. Gated the same way as the npm publish step (release PR + # merged, or a plain master push) — release-image.yml is itself idempotent, so a + # no-op push is cheap. A separate job (not a step) because a reusable workflow is + # invoked at job level; keeps the image build logic in its own file. + publish-image: + needs: release + if: needs.release.outputs.has_changesets == 'false' + permissions: + contents: read + packages: write # GHCR push; no id-token (build-push provenance is unsigned) + uses: ./.github/workflows/release-image.yml + with: + version: ${{ needs.release.outputs.version }} diff --git a/CLAUDE.md b/CLAUDE.md index 4d44926..691c798 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is -TypeScript library for the 1С:Enterprise REST/OData V3 interface. Four-package pnpm + turbo monorepo: +TypeScript library for the 1С:Enterprise REST/OData V3 interface. Five-package pnpm + turbo monorepo: | Package | Role | |---|---| @@ -12,6 +12,7 @@ TypeScript library for the 1С:Enterprise REST/OData V3 interface. Four-package | [`@1c-odata/metadata`](./packages/metadata/src) | Schema toolkit — EDMX (`$metadata`) parser, `buildMetadataIndex`, `fetchMetadataIndex`, `createDynamicClient`, entity-kind classification. Deps: client + fast-xml-parser | | [`@1c-odata/cli`](./packages/cli/src) | `1c-odata fetch` + `1c-odata generate` binaries; codegen library at [`@1c-odata/cli/codegen`](./packages/cli/src/codegen). Consumes `@1c-odata/metadata` | | [`@1c-odata/mcp`](./packages/mcp/src) | Local (stdio) MCP server — `1c-odata-mcp` bin: read-only schema + query tools for AI agents against any base via live `$metadata`, plus a connection-manager CLI (keychain-backed secrets). Adds a `/internal` seam. Deps: client + metadata + `@modelcontextprotocol/sdk` | +| [`@1c-odata/mcp-server`](./packages/mcp-server/src) | Remote Streamable-HTTP MCP server — `1c-odata-mcp-server` bin exposing mcp's read-only tools over HTTP (`/mcp`) for Claude custom connectors. Three modes: no-auth, better-auth OAuth 2.1 (DCR+PKCE), Postgres-backed multi-tenancy (per-user grants, AES-256-GCM secrets, `/admin` panel). Docker/Caddy deploy. Deps: mcp + better-auth, drizzle, express, pg | Server-side only. Pure ESM. Node ≥ 24.18.0, pnpm ≥ 10. @@ -24,15 +25,15 @@ Server-side only. Pure ESM. Node ≥ 24.18.0, pnpm ≥ 10. **Codegen is the DX layer.** The user writes `1c-odata.config.ts` (via `defineCodegenConfig` from `@1c-odata/cli`) declaring codegen targets — each a `{ connection, include? }` wrapping a runtime `Connection`; the CLI fetches each base's `$metadata` (EDMX XML) into `metadata/.xml`, then emits per-target TS in `generated///.ts` (or only `__metadata.json` with `--metadata-only`). The runtime `ODataV3Client` is generic over the user's emitted `Functions` type — full IDE completion against the live schema. **Build vs runtime split:** `1c-odata.config.ts` is build-only (CLI); the app runtime builds its own `Connection` (via `defineConnection` from `@1c-odata/client`) and never imports the config file. -**MCP surface (read-only).** `@1c-odata/mcp` wraps client+metadata as a local (stdio) MCP server (`1c-odata-mcp` bin) — schema + query tools for AI agents against any base via live `$metadata`, plus a connection-manager CLI whose passwords go to the OS keychain, never argv. The `@1c-odata/mcp/internal` seam (`ConnectionSource` + read-only tool registrators) exists so an alternate host can re-expose the same tool set. +**MCP surface (read-only).** `@1c-odata/mcp` wraps client+metadata as a local (stdio) MCP server (`1c-odata-mcp` bin) — schema + query tools for AI agents against any base via live `$metadata`, plus a connection-manager CLI whose passwords go to the OS keychain, never argv. `@1c-odata/mcp-server` re-exposes that same tool set over Streamable HTTP (`/mcp`) for remote clients (Claude custom connectors), in three progressively-secure modes: no-auth (loopback only), embedded better-auth OAuth 2.1 (DCR + PKCE, JWT verified locally against JWKS), and Postgres-backed multi-tenancy (bases in DB, 1С passwords AES-256-GCM-encrypted at rest, per-user grants, a server-rendered `/admin` panel, first admin bootstrapped via a one-time `/setup` token). Write/management tools are never exposed over HTTP. Turnkey self-host (server + Postgres + Caddy auto-HTTPS) in [`packages/mcp-server/deploy`](./packages/mcp-server/deploy). **Three-tier API boundary** (enforced by `package.json#exports`): - `@1c-odata/client` — stable surface (semver-protected per STABILITY.md) - `@1c-odata/client/filter` — separate entrypoint for the filter DSL (`and`, `or`, `any`, `all`, `not`, `raw`) - `@1c-odata/client/internal` — escape hatch consumed by `@1c-odata/cli`, `@1c-odata/metadata`, and integration tests; MAY break in minor versions (safe: all packages are a changesets-`fixed` group and release in lock-step) -- `@1c-odata/mcp` mirrors this — a public `.` plus `@1c-odata/mcp/internal` (the `ConnectionSource` seam + read-only tool registrators) for alternate hosts and tests. All four packages are one `fixed` group, so these `/internal` seams break safely in minors. +- `@1c-odata/mcp` mirrors this — a public `.` plus `@1c-odata/mcp/internal` (the `ConnectionSource` seam + read-only tool registrators) consumed by `@1c-odata/mcp-server`; `@1c-odata/mcp-server` ships a single public `.` (no `/internal`). All five packages are one `fixed` group, so these `/internal` seams break safely in minors. -**Workspace deps via `workspace:*`.** `prepare` hook on `pnpm install` builds all packages' `dist/` automatically (topological order client → metadata → {cli, mcp}) — running tests / typecheck on a fresh clone "just works" without an explicit build step. Don't manually run `pnpm build` unless investigating dist output. +**Workspace deps via `workspace:*`.** `prepare` hook on `pnpm install` builds all packages' `dist/` automatically (topological order client → metadata → {cli, mcp} → mcp-server) — running tests / typecheck on a fresh clone "just works" without an explicit build step. Don't manually run `pnpm build` unless investigating dist output. ## Commands @@ -41,7 +42,7 @@ pnpm turbo build # workspace build (tsdown) pnpm turbo typecheck # tsc --noEmit, all packages pnpm turbo test:unit # vitest, fast, deterministic pnpm turbo test:integration:offline # codegen + parser against snapshots/*.xml -pnpm turbo test:e2e # CLI e2e (MSW-stubbed 1С upstreams) +pnpm turbo test:e2e # CLI + mcp-server e2e (real loopback server; MSW-stubbed 1С upstreams) pnpm turbo test:integration:live # gated on .env.local; skips cleanly without it pnpm turbo test:integration:write # gated on ONEC_TESTS_ALLOW_WRITES=true pnpm turbo package:lint # publint + arethetypeswrong @@ -52,7 +53,9 @@ pnpm snapshots:refresh # refresh snapshots/*.xml against live ba pnpm changeset # add a release note (consumer-facing) ``` -**Single test**: `pnpm -F @1c-odata/client vitest run test/unit/filter.test.ts` (or any path glob). `-F` is the pnpm filter for workspace packages; replace with `@1c-odata/cli` or `@1c-odata/mcp` as needed. +**Single test**: `pnpm -F @1c-odata/client vitest run test/unit/filter.test.ts` (or any path glob). `-F` is the pnpm filter for workspace packages; replace with `@1c-odata/cli`, `@1c-odata/mcp`, or `@1c-odata/mcp-server` as needed. + +**mcp-server dev**: `pnpm -F @1c-odata/mcp-server dev serve …` runs `src/cli.ts` via tsx; `… start serve …` runs the built `dist/`. After editing the better-auth plugin set (`src/auth/better-auth.ts`) or the hand-written tenancy tables (`src/store/tenancy-schema.ts`), run `pnpm -F @1c-odata/mcp-server auth:schema` and commit both `packages/mcp-server/auth-schema.ts` and `packages/mcp-server/drizzle/` — `auth:schema` regenerates `auth-schema.ts` from `auth.config.ts` (which reuses `buildAuth`, so the plugin set never drifts) via the `@better-auth/cli` devDependency, then the drizzle SQL. CI gate 2c runs this same script and fails if either artifact drifts. **Live/write tests** need `.env.local` at repo root with `ONEC_TRADE_V11_5_URL`, `ONEC_BP_V3_0_URL`, and optionally `ONEC_TESTS_ALLOW_WRITES=true` — see [`snapshots/README.md`](./snapshots/README.md) for the full env contract. Without `.env.local` they skip cleanly. @@ -64,6 +67,8 @@ pnpm changeset # add a release note (consumer-facing) Windows runner is materially slower than Linux/Mac for `tsc --noEmit` over thousands of generated `.ts` files (NTFS overhead). Tsc-validate timeouts reflect this: 90s for `trade_v11.5` (always-on), 60s for `bp_v3.0` (`CI=true || CI_RUN_BIG_FIXTURES=1`). +**mcp-server CI:** `deploy-pg-smoke` (the shipped `--prod` tree against a real Postgres — migrator, single-use setup token under concurrency, Host guard, SIGTERM) and `deploy-package-smoke` (pack tarballs → install → boot the bin) are required, secret-free, fork-safe. `lint-and-typecheck` adds a drizzle-migrations-in-sync gate; `test-and-build` boots the mcp-server bin on Windows. Two workflows outside `ci.yml`: [`deploy-compose.yml`](.github/workflows/deploy-compose.yml) brings up the full Compose stack (db + mcp + Caddy) over internal-CA TLS — path-scoped + nightly + manual, **not** a required check; [`release-image.yml`](.github/workflows/release-image.yml) builds/pushes the multi-arch `ghcr.io//1c-odata-mcp-server` image, invoked by `release.yml` right after the npm publish so image `X.Y.Z` == npm `X.Y.Z` == tag `vX.Y.Z`. + ## Conventions - **Cyrillic identifiers everywhere.** 1С metadata uses Russian (`Catalog_Валюты`, `ФайлХранилище`, `Document_РеализацияТоваровУслуг`). Codegen emits Cyrillic filenames. `.gitattributes` forces `eol=lf` + `UTF-8 working-tree-encoding` for `.ts`. @@ -78,3 +83,5 @@ Windows runner is materially slower than Linux/Mac for `tsc --noEmit` over thous - [`examples/basic/README.md`](./examples/basic/README.md) — runnable end-to-end codegen consumer - [`examples/dynamic/README.md`](./examples/dynamic/README.md) — runnable zero-codegen `createDynamicClient` consumer - [`packages/mcp/README.md`](./packages/mcp/README.md) — local MCP server: tool catalogue, CLI, keychain-backed secrets +- [`packages/mcp-server/README.md`](./packages/mcp-server/README.md) — remote MCP server: run modes, OAuth, multi-tenancy +- [`packages/mcp-server/deploy/README.md`](./packages/mcp-server/deploy/README.md) — turnkey Docker Compose self-host (server + Postgres + Caddy auto-HTTPS) diff --git a/README.md b/README.md index fe02ccf..31daded 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,9 @@ TypeScript library for the standard OData interface of 1С:Enterprise 8 — ODat | [`@1c-odata/metadata`](./packages/metadata) | [![npm](https://img.shields.io/npm/v/@1c-odata/metadata)](https://www.npmjs.com/package/@1c-odata/metadata) | Run the client against any base at runtime — `createDynamicClient` / `fetchMetadataIndex`, no codegen; also the EDMX (`$metadata`) parser + `buildMetadataIndex` schema toolkit | | [`@1c-odata/cli`](./packages/cli) | [![npm](https://img.shields.io/npm/v/@1c-odata/cli)](https://www.npmjs.com/package/@1c-odata/cli) | `1c-odata fetch` + `1c-odata generate` binaries; codegen lib at [`@1c-odata/cli/codegen`](./packages/cli/src/codegen) | | [`@1c-odata/mcp`](./packages/mcp) | [![npm](https://img.shields.io/npm/v/@1c-odata/mcp)](https://www.npmjs.com/package/@1c-odata/mcp) | Local (stdio) MCP server for AI agents — read-only schema introspection + OData queries against any base via live `$metadata`, plus a connection-manager CLI. Built on `client` + `metadata` | +| [`@1c-odata/mcp-server`](./packages/mcp-server) | [![npm](https://img.shields.io/npm/v/@1c-odata/mcp-server)](https://www.npmjs.com/package/@1c-odata/mcp-server) | Remote Streamable-HTTP MCP server — `mcp`'s read-only tools over HTTP for a Claude custom connector, with OAuth 2.1 (DCR + PKCE), optional Postgres multi-tenancy, and an admin panel. Docker/Caddy self-host | -**AI agents:** `@1c-odata/mcp` (local, stdio) exposes read-only schema introspection + queries against any base — see its package README. +**AI agents:** `@1c-odata/mcp` (local, stdio) and `@1c-odata/mcp-server` (remote HTTP / Claude custom connector) expose read-only schema introspection + queries against any base — see their package READMEs. JSDoc on the public API is the canonical reference. Hover anything imported from `@1c-odata/client` in your IDE. diff --git a/STABILITY.md b/STABILITY.md index a152481..30a8f3e 100644 --- a/STABILITY.md +++ b/STABILITY.md @@ -4,7 +4,7 @@ What is and isn't covered by semver across the `@1c-odata/*` monorepo. ## Public API surface -Public surface = every symbol reachable via a package's `package.json#exports` entrypoints (including subpaths like `@1c-odata/client/filter` and `@1c-odata/cli/codegen`), minus symbols tagged `@internal` in JSDoc. This covers the three library packages: `@1c-odata/client`, `@1c-odata/metadata`, `@1c-odata/cli`. The application package — `@1c-odata/mcp` (local CLI + stdio MCP server) — has an operational contract instead; see [`@1c-odata/mcp` surface](#1c-odatamcp-surface-cli--mcp-server) below. +Public surface = every symbol reachable via a package's `package.json#exports` entrypoints (including subpaths like `@1c-odata/client/filter` and `@1c-odata/cli/codegen`), minus symbols tagged `@internal` in JSDoc. This covers the three library packages: `@1c-odata/client`, `@1c-odata/metadata`, `@1c-odata/cli`. The two application packages — `@1c-odata/mcp` (local CLI + stdio MCP server) and `@1c-odata/mcp-server` (remote Streamable-HTTP MCP server) — have operational contracts instead; see [`@1c-odata/mcp` surface](#1c-odatamcp-surface-cli--mcp-server) and [`@1c-odata/mcp-server` surface](#1c-odatamcp-server-surface-remote-mcp-server) below. Semver-applicable: @@ -30,7 +30,7 @@ NOT covered: - **v0.x (current)** — API is unstable. Minor versions MAY contain breaking changes. Every break is documented in [GitHub Releases](https://github.com/hacker-cb/1c-odata/releases) with a migration example. Patch versions are NEVER breaking. - **v1.0+ (future)** — strict semver. -Workspace deps use `workspace:*`. All four `@1c-odata/*` packages are one changesets `fixed` group, so they always release together at the same version. In v0.x a breaking change therefore ships as a **minor** bump across all four at once (a major bump is reserved for v1.0) — never as a major in 0.x. +Workspace deps use `workspace:*`. All five `@1c-odata/*` packages are one changesets `fixed` group, so they always release together at the same version. In v0.x a breaking change therefore ships as a **minor** bump across all five at once (a major bump is reserved for v1.0) — never as a major in 0.x. ## Error contract @@ -94,7 +94,24 @@ Semver-applicable: NOT covered: - The OS-keychain entry naming (the `service` / `account` strings) is an implementation detail — it MAY change, and a change orphans previously stored keychain secrets (re-add them, or use `ONEC__PASSWORD`). -- Programmatic exports of `@1c-odata/mcp`. The `.` entrypoint (`createMcpServer`, `runServe`, plus the config / data-dir helpers) is a convenience for embedding the local stdio server and MAY change. The `@1c-odata/mcp/internal` subpath — the connection pool and its `ConnectionSource` seam, the read-only tool registrators, `SecretStore`, `passwordEnvVar`, and the response-limit helpers — is an explicit escape hatch for alternate hosts and tests/tooling, on the same footing as `@1c-odata/client/internal` (MAY break in a minor release; safe because the packages release in lock-step). +- Programmatic exports of `@1c-odata/mcp`. The `.` entrypoint (`createMcpServer`, `runServe`, plus the config / data-dir helpers) is a convenience for embedding the local stdio server and MAY change. The `@1c-odata/mcp/internal` subpath — the connection pool and its `ConnectionSource` seam, the read-only tool registrators, `SecretStore`, `passwordEnvVar`, and the response-limit helpers — is an explicit escape hatch consumed by `@1c-odata/mcp-server` and tests/tooling, on the same footing as `@1c-odata/client/internal` (MAY break in a minor release; safe because the packages release in lock-step). - Tool / CLI output text and error-message wording. The per-data-dir keychain namespacing is a **behavioral break with no migration**: a secret stored under the previous flat `1c-odata` keychain service is not found after the upgrade — re-add the password (`1c-odata-mcp add `) or set `ONEC__PASSWORD`. `config.json`, the `credentials.json` file backend, and env-var passwords are unaffected. + +## `@1c-odata/mcp-server` surface (remote MCP server) + +`@1c-odata/mcp-server` is an application — the `1c-odata-mcp-server` bin plus a Docker/Compose deploy — versioned under the same [Versioning](#versioning) policy (a break ships as a minor in v0.x, documented in the release). + +Semver-applicable: + +- The CLI command set and primary flags: `serve`, `admin-create`, `set-password`; `--public-url`, `--pg-url`, `--auth-data-dir`, `--enc-key`, `--data-dir`, `--host`, `--port`, and the env vars that back those flags (`ONEC_MCP_DATA_DIR`, `ONEC_MCP_PUBLIC_URL`, `BETTER_AUTH_SECRET` (or its `AUTH_SECRET` alias), `ONEC_MCP_ENC_KEY`, `DATABASE_URL`, `ONEC_MCP_AUTH_DATA_DIR`, `ONEC_MCP_ALLOWED_HOSTS` — see the package README for the full set). +- The HTTP surface a client depends on: the MCP endpoint (`POST`/`GET`/`DELETE /mcp`, Streamable HTTP), `GET /healthz`, and the OAuth discovery documents (`/.well-known/oauth-*`, `/api/auth/*`) — OAuth 2.1 with RFC 8707 resource + PKCE. The read-only MCP tool set is `@1c-odata/mcp`'s (above); management/write tools are never exposed over HTTP. +- The deploy env contract: `BETTER_AUTH_SECRET`, `ONEC_MCP_ENC_KEY`, `DATABASE_URL`, `ONEC_MCP_PUBLIC_URL` (see [`deploy/README.md`](./packages/mcp-server/deploy/README.md)). + +NOT covered: + +- The `/admin` panel and the `/sign-in` / `/consent` / `/setup` pages — their markup, routes, and styling are operational UI and MAY change freely. +- The OAuth authorization-server internals (better-auth), the database schema, and the shipped `drizzle/` migrations — implementation details (migrations run automatically on boot; the store is not a public API). +- The deploy artifacts (`deploy/Dockerfile`, `compose.yml`, `compose.prod.yml`, `Caddyfile`) and the published `ghcr.io/hacker-cb/1c-odata-mcp-server` image beyond the promise that each release publishes the `X.Y.Z` / `X.Y` / `latest` tags. +- The `.` programmatic export (`createHttpServer`) — a convenience for embedding, MAY change. There is no `@1c-odata/mcp-server/internal`. diff --git a/biome.json b/biome.json index 6703e5b..6130b46 100644 --- a/biome.json +++ b/biome.json @@ -71,6 +71,14 @@ "complexity": { "useLiteralKeys": "off" } } } + }, + { + "includes": ["**/deploy/ci/**"], + "linter": { + "rules": { + "suspicious": { "noConsole": "off" } + } + } } ] } diff --git a/packages/mcp-server/LICENSE b/packages/mcp-server/LICENSE new file mode 100644 index 0000000..cac5665 --- /dev/null +++ b/packages/mcp-server/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Pavel Sokolov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md new file mode 100644 index 0000000..cd7c39e --- /dev/null +++ b/packages/mcp-server/README.md @@ -0,0 +1,207 @@ +# @1c-odata/mcp-server + +Streamable HTTP [MCP](https://modelcontextprotocol.io) server for +[`@1c-odata`](https://github.com/hacker-cb/1c-odata) — exposes the **read-only** +1С:Enterprise OData V3 tools (schema introspection + data queries) over HTTP so a +remote MCP client (e.g. a **Claude custom connector**) can reach a 1С base. + +> Server-side only, pure ESM, Node ≥ 24.18.0. Part of the `@1c-odata` monorepo and +> released in lock-step with it. + +The connection-management tools (`add` / `remove` / `set_credentials` / +`set_label`) are intentionally **not** exposed over MCP — this is a read-only +surface. The MCP endpoint is `POST/GET/DELETE /mcp` (stateful, per-session); +`GET /healthz` is a liveness probe. + +## Running the CLI + +`serve`, `admin-create`, and `set-password` are subcommands of the +`1c-odata-mcp-server` bin. How you invoke it depends on context — pick one and read +the examples below as the command that follows: + +- **Installed / published** — `npx @1c-odata/mcp-server serve …`, or after a + global install simply `1c-odata-mcp-server serve …`. Runs the compiled `dist/` + that the package ships; no TypeScript toolchain needed. The Docker image + (`deploy/`) runs `dist/` too. +- **From a clone, production-like** — `pnpm -F @1c-odata/mcp-server start serve …` + runs the built `dist/cli.js` (`pnpm install` builds it via the `prepare` hook). +- **From a clone, development** — `pnpm -F @1c-odata/mcp-server dev serve …` runs + `src/cli.ts` through `tsx` — no build step, always the current source. Types + aren't checked on the fly (run `pnpm -F @1c-odata/mcp-server typecheck` + separately), and the workspace deps still need to be built once. + + (Append the CLI's args directly — no `--` separator, which pnpm would forward + literally into the process.) + +## Three run modes + +The `serve` bin has three progressively-more-secure modes, selected by flags. + +### 1. No auth — local / trusted network only + +```bash +1c-odata-mcp-server serve --data-dir /path/to/data --port 3000 +``` + +Reads bases + credentials from a local data dir (the same `config.json` + +credential store the `@1c-odata/mcp` CLI writes; `--data-dir` is optional and +resolves like that CLI — honors `ONEC_MCP_DATA_DIR`, defaults to the per-OS +config dir). **No authentication** — anyone who can reach `/mcp` can query every +configured base. Do **not** expose this mode publicly; keep it on loopback / a +trusted network, behind your own gateway. + +### 2. OAuth — a Claude custom connector by URL + +```bash +BETTER_AUTH_SECRET=... \ +1c-odata-mcp-server serve --public-url https://mcp.example.com +``` + +Passing `--public-url` (or `ONEC_MCP_PUBLIC_URL`) mounts an embedded +[better-auth](https://better-auth.com) OAuth 2.1 authorization server (Dynamic +Client Registration + PKCE) alongside the resource server. A user adds the +connector to Claude with **just the URL** — Claude self-registers via DCR, the +user logs in on `/sign-in`, consents on `/consent`, and `/mcp` then requires a +valid JWT — verified locally against the AS's JWKS (no per-request introspection +call). The authorization server runs in the same process, so its signing keys are +read in-process rather than fetched back over the network: verification works +behind a reverse proxy even when the server cannot reach its own public URL (no +hairpin-NAT or split-horizon DNS required). `BETTER_AUTH_SECRET` is required. + +Without a keyring (mode 3) this still uses the file data dir, so **every +authenticated user sees every base** — sign-up is closed, so users are +admin-provisioned. Reach for mode 3 for per-user isolation. + +### 3. Multi-tenant — per-user bases + admin panel + +```bash +BETTER_AUTH_SECRET=... ONEC_MCP_ENC_KEY="$(openssl rand -base64 32)" \ +1c-odata-mcp-server serve \ + --public-url https://mcp.example.com \ + --pg-url postgres://user:pass@host/db # else embedded PGlite (dev) +``` + +Adding `--enc-key` (or `ONEC_MCP_ENC_KEY`, a base64 32-byte AES-256 key) turns on +DB-backed multi-tenancy: bases live in the database, 1С passwords are encrypted +at rest (AES-256-GCM, bound to the base name), and each user sees only the bases +they are **granted**. Users are managed through an admin panel gated by the +better-auth `admin` role (server-rendered, internal-only) — CRUD of bases / +grants / users, connection health, all under `/admin`. + +**Rotating the encryption key.** Every sealed secret records the id of the key that +sealed it, so old and new keys can coexist: `ONEC_MCP_ENC_KEY` is always the key new +secrets are sealed with, and `ONEC_MCP_ENC_KEYS_PREVIOUS` holds retired keys for +decryption only. + +```bash +# Before: ONEC_MCP_ENC_KEY= ONEC_MCP_ENC_KEY_ID=1 +ONEC_MCP_ENC_KEY="$(openssl rand -base64 32)" # the new key… +ONEC_MCP_ENC_KEY_ID=2 # …under a NEW id +ONEC_MCP_ENC_KEYS_PREVIOUS=1: # the old one, decrypt-only +``` + +Re-sealing is **lazy**: a base's secret moves to the new key when its password is +next saved in `/admin`. Keep the retired key in `ONEC_MCP_ENC_KEYS_PREVIOUS` until +every base has been re-saved — dropping it earlier leaves those secrets unreadable. +A malformed key, a duplicate id, or a key that is not 32 bytes fails the boot loudly +rather than stranding data. + +**Bootstrap the first admin — the setup wizard.** On boot, while no admin exists, +the server prints a one-time `…/setup?token=…` URL to its log (at `warn` level, +re-printed on every restart until an admin is created). Open it in a browser to +create the first admin. The wizard is reachable **only** while no admin exists +**and** the token matches; it 404s forever once the first admin is created, and +the token is single-use. `/sign-in` shows a "first-run setup pending" hint (the +token is only ever in the log, never in a page). + +Break-glass alternatives, both requiring a PERSISTENT store: + +```bash +# Non-interactive first-admin seed (equivalent to the wizard, for automation). +# Both commands build the auth store, so they need the same public URL + secret +# `serve` uses (or pass --public-url instead of the env var): +BETTER_AUTH_SECRET=... ONEC_MCP_PUBLIC_URL=https://mcp.example.com \ +1c-odata-mcp-server admin-create --email admin@example.com --password '…' \ + --pg-url postgres://… + +# Reset a forgotten password for an existing user: +BETTER_AUTH_SECRET=... ONEC_MCP_PUBLIC_URL=https://mcp.example.com \ +1c-odata-mcp-server set-password --email admin@example.com --password '…' \ + --pg-url postgres://… +``` + +The store is Postgres in production (`--pg-url` / `DATABASE_URL`) or embedded +PGlite for dev (`--auth-data-dir` to persist, else in-memory). Single instance: +session state, the `$metadata` cache, and the health job are per-process. + +**Connection health.** The dashboard shows each base's reachability, kept current by +a background job that probes every base on an interval, plus a **Check connections +now** button for an on-demand sweep (with a per-base "checking" spinner). The probe +is a lightweight `GET` on the OData service root — *not* a full `$metadata` download +(~20× less data on real bases), so it's cheap on the 1С server and safe under a short +timeout. Both knobs are tunable via process-wide env: + +| Env var | Default | Meaning | +|---|---|---| +| `ONEC_MCP_HEALTH_INTERVAL_MS` | `60000` | Background probe interval (ms). | +| `ONEC_MCP_HEALTH_TIMEOUT_MS` | `5000` | Per-base probe timeout (ms). | + +**Session limits.** Each MCP client `initialize` opens a live session (its own +`McpServer` + transport, dispatched by the `Mcp-Session-Id` header). Two guards keep +one tenant from exhausting the process and reclaim abandoned sessions: + +- a **per-principal quota** so a single `sub` can't occupy every global slot and 503 + everyone else (the no-auth loopback principal is exempt — one trusted owner), and +- an **idle sweeper** that reaps sessions **with no open SSE stream** left untouched + beyond a TTL — reclaiming POST-only sessions that never send `DELETE`. A client + holding a live GET stream is a connected client and is exempt (its socket is + reclaimed when the stream closes). A client whose session was swept (or that + reconnects after a long pause) transparently re-initializes — the server returns + `404` for the stale id, the spec's "start a new session" signal (safe because + sessions aren't resumable). + +| Env var | Default | Meaning | +|---|---|---| +| `ONEC_MCP_MAX_SESSIONS` | `1024` | Global concurrent-session ceiling (all principals). | +| `ONEC_MCP_MAX_SESSIONS_PER_SUB` | `32` | Per-principal concurrent-session quota. | +| `ONEC_MCP_SESSION_IDLE_MS` | `1800000` | Reap a session after this much inactivity (30 min). | +| `ONEC_MCP_SESSION_SWEEP_MS` | `60000` | Idle-sweeper period (ms). | + +For a turnkey self-hosted stack (server + Postgres + Caddy auto-HTTPS) see +[`deploy/README.md`](./deploy/README.md) — `docker compose up` from `.env`. + +## Programmatic + +`createHttpServer` is **async** and returns a handle (the server is unstarted — +call `.listen(...)`; `close()` also tears down the auth store): + +```ts +import { createHttpServer } from '@1c-odata/mcp-server' +import { FileConnectionSource } from '@1c-odata/mcp/internal' + +const source = new FileConnectionSource({ dataDir: '/path/to/data' }) +// No-auth: omit `auth`. Add `auth: { publicUrl, dialect, secret, keyring? }` +// for OAuth (+ a keyring for multi-tenancy). +const { server, close } = await createHttpServer({ source, dataDir: '/path/to/data' }) +server.listen(3000) +// On shutdown, stop accepting connections FIRST, then drain the auth store — +// `server.close` is callback-based, so wait for it before calling `close()`: +// server.close(() => { void close() }) +``` + +## Hardening + +- **DNS-rebinding protection** (via `serve`): the transport validates the `Host` + header against an allowlist. It is derived from the bound address (`host:port` + + `localhost`/`127.0.0.1`) plus — when `--public-url` is set — the public origin's + `Host`, so a reverse proxy that forwards the original `Host` needs no extra + config. Only when the proxy presents a *different* `Host` set + `ONEC_MCP_ALLOWED_HOSTS` — comma-separated raw `Host` values (`host` with the + default port omitted, as clients send it, or `host:port`), respected verbatim as + an override. +- Sessions are pinned to the authenticated principal (`sub`): a request whose + token belongs to a different user is rejected. 1С passwords are write-only in + the admin UI and never returned in any response. +- The public surface is `/mcp`, `/.well-known/*`, `/api/auth/*`, `/sign-in`, + `/consent`, and the token-gated `/setup` (first-run only); keep `/admin` and the + database internal. diff --git a/packages/mcp-server/auth-schema.ts b/packages/mcp-server/auth-schema.ts new file mode 100644 index 0000000..b96d2f2 --- /dev/null +++ b/packages/mcp-server/auth-schema.ts @@ -0,0 +1,277 @@ +import { relations } from 'drizzle-orm' +import { boolean, index, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core' + +export const user = pgTable('user', { + id: text('id').primaryKey(), + name: text('name').notNull(), + email: text('email').notNull().unique(), + emailVerified: boolean('email_verified').default(false).notNull(), + image: text('image'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at') + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + role: text('role'), + banned: boolean('banned').default(false), + banReason: text('ban_reason'), + banExpires: timestamp('ban_expires'), +}) + +export const session = pgTable( + 'session', + { + id: text('id').primaryKey(), + expiresAt: timestamp('expires_at').notNull(), + token: text('token').notNull().unique(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at') + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + impersonatedBy: text('impersonated_by'), + }, + (table) => [index('session_userId_idx').on(table.userId)], +) + +export const account = pgTable( + 'account', + { + id: text('id').primaryKey(), + accountId: text('account_id').notNull(), + providerId: text('provider_id').notNull(), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + accessToken: text('access_token'), + refreshToken: text('refresh_token'), + idToken: text('id_token'), + accessTokenExpiresAt: timestamp('access_token_expires_at'), + refreshTokenExpiresAt: timestamp('refresh_token_expires_at'), + scope: text('scope'), + password: text('password'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at') + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index('account_userId_idx').on(table.userId)], +) + +export const verification = pgTable( + 'verification', + { + id: text('id').primaryKey(), + identifier: text('identifier').notNull(), + value: text('value').notNull(), + expiresAt: timestamp('expires_at').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at') + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index('verification_identifier_idx').on(table.identifier)], +) + +export const jwks = pgTable('jwks', { + id: text('id').primaryKey(), + publicKey: text('public_key').notNull(), + privateKey: text('private_key').notNull(), + createdAt: timestamp('created_at').notNull(), + expiresAt: timestamp('expires_at'), +}) + +export const oauthClient = pgTable( + 'oauth_client', + { + id: text('id').primaryKey(), + clientId: text('client_id').notNull().unique(), + clientSecret: text('client_secret'), + disabled: boolean('disabled').default(false), + skipConsent: boolean('skip_consent'), + enableEndSession: boolean('enable_end_session'), + subjectType: text('subject_type'), + scopes: text('scopes').array(), + userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at'), + updatedAt: timestamp('updated_at'), + name: text('name'), + uri: text('uri'), + icon: text('icon'), + contacts: text('contacts').array(), + tos: text('tos'), + policy: text('policy'), + softwareId: text('software_id'), + softwareVersion: text('software_version'), + softwareStatement: text('software_statement'), + redirectUris: text('redirect_uris').array().notNull(), + postLogoutRedirectUris: text('post_logout_redirect_uris').array(), + tokenEndpointAuthMethod: text('token_endpoint_auth_method'), + grantTypes: text('grant_types').array(), + responseTypes: text('response_types').array(), + public: boolean('public'), + type: text('type'), + requirePKCE: boolean('require_pkce'), + referenceId: text('reference_id'), + metadata: jsonb('metadata'), + }, + (table) => [index('oauthClient_userId_idx').on(table.userId)], +) + +export const oauthRefreshToken = pgTable( + 'oauth_refresh_token', + { + id: text('id').primaryKey(), + token: text('token').notNull().unique(), + clientId: text('client_id') + .notNull() + .references(() => oauthClient.clientId, { onDelete: 'cascade' }), + sessionId: text('session_id').references(() => session.id, { + onDelete: 'set null', + }), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + referenceId: text('reference_id'), + expiresAt: timestamp('expires_at'), + createdAt: timestamp('created_at'), + revoked: timestamp('revoked'), + authTime: timestamp('auth_time'), + scopes: text('scopes').array().notNull(), + }, + (table) => [ + index('oauthRefreshToken_clientId_idx').on(table.clientId), + index('oauthRefreshToken_sessionId_idx').on(table.sessionId), + index('oauthRefreshToken_userId_idx').on(table.userId), + ], +) + +export const oauthAccessToken = pgTable( + 'oauth_access_token', + { + id: text('id').primaryKey(), + token: text('token').unique(), + clientId: text('client_id') + .notNull() + .references(() => oauthClient.clientId, { onDelete: 'cascade' }), + sessionId: text('session_id').references(() => session.id, { + onDelete: 'set null', + }), + userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }), + referenceId: text('reference_id'), + refreshId: text('refresh_id').references(() => oauthRefreshToken.id, { + onDelete: 'cascade', + }), + expiresAt: timestamp('expires_at'), + createdAt: timestamp('created_at'), + scopes: text('scopes').array().notNull(), + }, + (table) => [ + index('oauthAccessToken_clientId_idx').on(table.clientId), + index('oauthAccessToken_sessionId_idx').on(table.sessionId), + index('oauthAccessToken_userId_idx').on(table.userId), + index('oauthAccessToken_refreshId_idx').on(table.refreshId), + ], +) + +export const oauthConsent = pgTable( + 'oauth_consent', + { + id: text('id').primaryKey(), + clientId: text('client_id') + .notNull() + .references(() => oauthClient.clientId, { onDelete: 'cascade' }), + userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }), + referenceId: text('reference_id'), + scopes: text('scopes').array().notNull(), + createdAt: timestamp('created_at'), + updatedAt: timestamp('updated_at'), + }, + (table) => [index('oauthConsent_clientId_idx').on(table.clientId), index('oauthConsent_userId_idx').on(table.userId)], +) + +export const userRelations = relations(user, ({ many }) => ({ + sessions: many(session), + accounts: many(account), + oauthClients: many(oauthClient), + oauthRefreshTokens: many(oauthRefreshToken), + oauthAccessTokens: many(oauthAccessToken), + oauthConsents: many(oauthConsent), +})) + +export const sessionRelations = relations(session, ({ one, many }) => ({ + user: one(user, { + fields: [session.userId], + references: [user.id], + }), + oauthRefreshTokens: many(oauthRefreshToken), + oauthAccessTokens: many(oauthAccessToken), +})) + +export const accountRelations = relations(account, ({ one }) => ({ + user: one(user, { + fields: [account.userId], + references: [user.id], + }), +})) + +export const oauthClientRelations = relations(oauthClient, ({ one, many }) => ({ + user: one(user, { + fields: [oauthClient.userId], + references: [user.id], + }), + oauthRefreshTokens: many(oauthRefreshToken), + oauthAccessTokens: many(oauthAccessToken), + oauthConsents: many(oauthConsent), +})) + +export const oauthRefreshTokenRelations = relations(oauthRefreshToken, ({ one, many }) => ({ + oauthClient: one(oauthClient, { + fields: [oauthRefreshToken.clientId], + references: [oauthClient.clientId], + }), + session: one(session, { + fields: [oauthRefreshToken.sessionId], + references: [session.id], + }), + user: one(user, { + fields: [oauthRefreshToken.userId], + references: [user.id], + }), + oauthAccessTokens: many(oauthAccessToken), +})) + +export const oauthAccessTokenRelations = relations(oauthAccessToken, ({ one }) => ({ + oauthClient: one(oauthClient, { + fields: [oauthAccessToken.clientId], + references: [oauthClient.clientId], + }), + session: one(session, { + fields: [oauthAccessToken.sessionId], + references: [session.id], + }), + user: one(user, { + fields: [oauthAccessToken.userId], + references: [user.id], + }), + oauthRefreshToken: one(oauthRefreshToken, { + fields: [oauthAccessToken.refreshId], + references: [oauthRefreshToken.id], + }), +})) + +export const oauthConsentRelations = relations(oauthConsent, ({ one }) => ({ + oauthClient: one(oauthClient, { + fields: [oauthConsent.clientId], + references: [oauthClient.clientId], + }), + user: one(user, { + fields: [oauthConsent.userId], + references: [user.id], + }), +})) diff --git a/packages/mcp-server/auth.config.ts b/packages/mcp-server/auth.config.ts new file mode 100644 index 0000000..492cb70 --- /dev/null +++ b/packages/mcp-server/auth.config.ts @@ -0,0 +1,29 @@ +// auth.config.ts +/** + * Build-only config module for `@better-auth/cli generate`. The CLI needs a + * module exporting `auth` to introspect the plugin set and emit the Drizzle + * schema (auth-schema.ts). + * + * It calls the SAME `buildAuth` factory the runtime uses (src/auth/better-auth.ts), + * so the plugin set (jwt + admin + oauthProvider) CANNOT drift from the runtime — + * there is nothing to keep in sync by hand. Not imported by any runtime code: this + * is the build side of CLAUDE.md's build-vs-runtime split. + * + * The urls/db/secret are throwaway. Schema generation reads the plugin and option + * shapes, never the database — the drizzle adapter is constructed but never + * queried during `generate` — so a localhost url, an in-memory PGlite, and a dummy + * secret are enough to reproduce the exact tables the runtime expects. + */ +import { PGlite } from '@electric-sql/pglite' +import { drizzle } from 'drizzle-orm/pglite' +import { buildAuth } from './src/auth/better-auth.js' +import { resolveCanonicalUrls } from './src/auth/config.js' +import type { AuthDb } from './src/store/db.js' + +export const auth = buildAuth({ + urls: resolveCanonicalUrls('http://localhost:3000'), + // `generate` never runs a query, so cast a schema-less PGlite handle to AuthDb + // rather than importing the (generated) schema barrel just to satisfy the type. + db: drizzle(new PGlite()) as unknown as AuthDb, + secret: 'schema-generation-only-not-a-real-secret-0123456789', +}) diff --git a/packages/mcp-server/deploy/.env.example b/packages/mcp-server/deploy/.env.example new file mode 100644 index 0000000..da4e797 --- /dev/null +++ b/packages/mcp-server/deploy/.env.example @@ -0,0 +1,57 @@ +# Copy to .env and fill in. NEVER commit the filled-in .env. + +# ── Public origin ───────────────────────────────────────────────────────────── +# Domain Caddy serves + provisions TLS for. Must have a DNS A/AAAA record +# pointing at this host, and ports 80/443 reachable (for the ACME challenge). +MCP_PUBLIC_DOMAIN=mcp.example.com +# External HTTPS origin — the OAuth issuer + MCP resource id. This is the URL you +# add to Claude as the connector. Almost always https://${MCP_PUBLIC_DOMAIN}. +MCP_PUBLIC_URL=https://mcp.example.com + +# ── Secrets — generate fresh, keep out of version control ───────────────────── +# Signs better-auth sessions/JWTs. Keep STABLE across restarts (rotating it logs +# everyone out). Generate: openssl rand -base64 32 +BETTER_AUTH_SECRET= +# AES-256 key encrypting 1С passwords at rest. LOSING THIS makes every stored 1С +# password unrecoverable — back it up in a secret manager. openssl rand -base64 32 +ONEC_MCP_ENC_KEY= + +# ── Optional: encryption-key rotation ──────────────────────────────────────── +# Every sealed secret records which key sealed it, so old and new keys coexist: +# ONEC_MCP_ENC_KEY is always what NEW secrets are sealed with; retired keys go in +# ONEC_MCP_ENC_KEYS_PREVIOUS (id:key pairs, comma-separated) and are used only to +# DECRYPT. To rotate: generate a new key, bump the id, move the old pair here. +# ONEC_MCP_ENC_KEY= ONEC_MCP_ENC_KEY_ID=2 ONEC_MCP_ENC_KEYS_PREVIOUS=1: +# Re-sealing is LAZY — a base moves to the new key when its password is next saved +# in /admin. Keep the retired key here until every base has been re-saved; dropping +# it earlier leaves those secrets unreadable. Blank = id 1, no retired keys. +ONEC_MCP_ENC_KEY_ID= +ONEC_MCP_ENC_KEYS_PREVIOUS= +# Password for the bundled Postgres. Use a URL-SAFE value — it is interpolated raw +# into DATABASE_URL, so a base64 value (may contain / + =) would corrupt the URI +# and the server won't connect. Hex is safe: openssl rand -hex 24 +POSTGRES_PASSWORD= + +# ── Optional: connection-health tuning ─────────────────────────────────────── +# The dashboard probes each 1С base for reachability on an interval (a lightweight +# GET on the OData service root, not a full $metadata download). Leave BOTH blank +# to use the defaults; positive integers (ms) override them. The probe timeout is +# clamped below the interval. +# ONEC_MCP_HEALTH_INTERVAL_MS background probe interval (default 60000) +# ONEC_MCP_HEALTH_TIMEOUT_MS per-base probe timeout (default 5000) +ONEC_MCP_HEALTH_INTERVAL_MS= +ONEC_MCP_HEALTH_TIMEOUT_MS= + +# ── Optional: session limits ───────────────────────────────────────────────── +# Each MCP client `initialize` opens a live session. A per-principal quota keeps +# one tenant from occupying every global slot (the no-auth loopback owner is +# exempt), and an idle sweeper reclaims sessions abandoned past a TTL (the client +# transparently re-initializes). Leave blank for defaults; positive integers only. +# ONEC_MCP_MAX_SESSIONS global concurrent-session cap (default 1024) +# ONEC_MCP_MAX_SESSIONS_PER_SUB per-principal quota (default 32) +# ONEC_MCP_SESSION_IDLE_MS idle TTL before reap (ms) (default 1800000) +# ONEC_MCP_SESSION_SWEEP_MS idle-sweeper period (ms) (default 60000) +ONEC_MCP_MAX_SESSIONS= +ONEC_MCP_MAX_SESSIONS_PER_SUB= +ONEC_MCP_SESSION_IDLE_MS= +ONEC_MCP_SESSION_SWEEP_MS= diff --git a/packages/mcp-server/deploy/Caddyfile b/packages/mcp-server/deploy/Caddyfile new file mode 100644 index 0000000..04b98a3 --- /dev/null +++ b/packages/mcp-server/deploy/Caddyfile @@ -0,0 +1,17 @@ +{$MCP_PUBLIC_DOMAIN} { + # Caddy auto-provisions (and auto-renews) a TLS cert for this domain via ACME. + # Caddy's default CA is Let's Encrypt (with ZeroSSL as a fallback); override it in + # a global-options block if you need to. Set an ACME account email there too for + # expiry notices — optional, certs work without it: + # { email you@example.com } + # + # This default (HTTP-01) needs public 80/443. For an internal / grey-IP host use + # DNS-01, or mount a cert you already hold — see the README "TLS certificate + # options" section. + # + # reverse_proxy PRESERVES the original Host header upstream by default — so the + # server's DNS-rebinding allowlist (auto-derived from ONEC_MCP_PUBLIC_URL) matches + # without any ONEC_MCP_ALLOWED_HOSTS. TLS terminates here; the app speaks plain + # HTTP on the internal network. + reverse_proxy mcp:3000 +} diff --git a/packages/mcp-server/deploy/Dockerfile b/packages/mcp-server/deploy/Dockerfile new file mode 100644 index 0000000..8fb3bab --- /dev/null +++ b/packages/mcp-server/deploy/Dockerfile @@ -0,0 +1,42 @@ +# syntax=docker/dockerfile:1 +# +# Production image for @1c-odata/mcp-server. Build context is the REPO ROOT (the +# mcp-server build needs its workspace deps @1c-odata/client + @1c-odata/mcp), so +# build via the bundled compose.yml or: +# docker build -f packages/mcp-server/deploy/Dockerfile -t onec-mcp . + +# ── Builder: install the workspace, build, emit a self-contained deploy tree ──── +FROM node:24-bookworm-slim AS builder +WORKDIR /repo +# corepack pins pnpm from the root package.json "packageManager" field. +RUN corepack enable +# Source only — node_modules/dist are .dockerignore'd, so the build is hermetic. +COPY . . +# Install (per-package `prepare` builds each dist/ topologically) then build +# explicitly for good measure (turbo-cached, so it's a no-op if prepare ran). +RUN pnpm install --frozen-lockfile +RUN pnpm turbo build +# Emit dist/ + drizzle/ + a resolved production node_modules (workspace deps +# injected, verified to run standalone) into /app. +RUN pnpm --filter @1c-odata/mcp-server deploy --prod --legacy /app + +# ── Runtime: just Node + the deploy tree, unprivileged ────────────────────────── +FROM node:24-bookworm-slim AS runtime +ENV NODE_ENV=production +# Static OCI labels so a local `docker compose --build` image is traceable and the +# GHCR package auto-links to the repo. CI (docker/metadata-action in +# release-image.yml) overrides the dynamic ones (version/revision/created) per release. +LABEL org.opencontainers.image.source="https://github.com/hacker-cb/1c-odata" \ + org.opencontainers.image.title="1c-odata-mcp-server" \ + org.opencontainers.image.description="Remote MCP server for 1С:Enterprise OData bases" \ + org.opencontainers.image.licenses="MIT" +WORKDIR /app +COPY --from=builder /app ./ +# The node image ships an unprivileged `node` user; drop root. +USER node +EXPOSE 3000 +# `serve` reads the public URL / secrets / DATABASE_URL from env (see compose.yml). +# Bind all interfaces so the proxy on the compose network can reach it. ENTRYPOINT +# is the bin, so `docker compose run --rm mcp admin-create …` works too. +ENTRYPOINT ["node", "dist/cli.js"] +CMD ["serve", "--host", "0.0.0.0", "--port", "3000"] diff --git a/packages/mcp-server/deploy/README.md b/packages/mcp-server/deploy/README.md new file mode 100644 index 0000000..724e36e --- /dev/null +++ b/packages/mcp-server/deploy/README.md @@ -0,0 +1,309 @@ +# Deploy: Docker Compose (self-host) + +A single-command, self-contained production stack for `@1c-odata/mcp-server`: + +- **mcp** — the server in multi-tenant mode (DB-backed bases, per-user grants, admin panel), built from this repo. +- **db** — Postgres for the auth/tenancy store. Schema migrations run automatically on boot. +- **caddy** — reverse proxy that terminates TLS with an **auto-provisioned, auto-renewed** certificate via ACME (Caddy's default CA is **Let's Encrypt**, with a ZeroSSL fallback) and forwards to the server. + +> Single instance by design — session state, the `$metadata` cache and the health job are per-process. Run one stack; do not scale `mcp` to >1 replica. + +## Prerequisites + +- Docker + Docker Compose **v2.24+** on the host (the published-image overlay uses the `!reset` merge tag, added in Compose 2.24). +- A domain (`MCP_PUBLIC_DOMAIN`) with a DNS record pointing at the host, and inbound **80/443** open — Caddy needs both to solve the default ACME (HTTP-01) challenge and serve HTTPS. Serving an **internal-only host** (LAN/VPN clients, no public 80/443) or bringing **your own certificate**? See [TLS certificate options](#tls-certificate-options). + +## 1. Configure + +From this directory (`packages/mcp-server/deploy/`): + +```bash +cp .env.example .env +# then edit .env — set the domain and generate the three secrets: +# BETTER_AUTH_SECRET=$(openssl rand -base64 32) +# ONEC_MCP_ENC_KEY=$(openssl rand -base64 32) +# POSTGRES_PASSWORD=$(openssl rand -hex 24) # hex — URL-safe for DATABASE_URL +``` + +`MCP_PUBLIC_URL` is the origin you'll add to Claude — normally `https://${MCP_PUBLIC_DOMAIN}`. + +## 2. Start + +```bash +docker compose up -d --build +``` + +First run builds the image (installs the workspace, builds, emits a self-contained tree) and boots Postgres → mcp → Caddy. Caddy issues the TLS cert on first HTTPS hit. + +Check health: + +```bash +docker compose ps +curl -fsS https://$MCP_PUBLIC_DOMAIN/healthz && echo OK # liveness probe +``` + +### Run from the published image (no build) + +Each release publishes a multi-arch (`amd64` + `arm64`) image to GHCR: +`ghcr.io/hacker-cb/1c-odata-mcp-server`, tagged `X.Y.Z` (exact), `X.Y` (latest +patch), and `latest`. The `compose.prod.yml` overlay swaps the source build for a +pull, so a host needs only `compose.yml`, `compose.prod.yml`, `Caddyfile`, and +`.env` — no monorepo checkout, no build toolchain: + +```bash +export MCP_IMAGE_TAG=0.7.0 # pin an exact version +docker compose -f compose.yml -f compose.prod.yml pull +docker compose -f compose.yml -f compose.prod.yml up -d +``` + +Updating is then just `MCP_IMAGE_TAG= docker compose -f compose.yml -f compose.prod.yml pull && … up -d` — migrations re-run idempotently on boot. + +## 3. Bootstrap the first admin + +Sign-up is closed, so the server seeds the first admin through a **one-time setup +wizard** gated by a token it prints to its own log at boot (and re-prints on every +restart until an admin exists). No crafted console command is needed. + +After `docker compose up`, find the printed URL: + +```bash +docker compose logs mcp | grep 'FIRST-RUN SETUP' +# → …/setup?token= +``` + +Open that `https://$MCP_PUBLIC_DOMAIN/setup?token=…` URL in a browser and create +the first admin (email + password). The wizard is reachable **only** while no +admin exists **and** the token matches; it 404s the instant the first admin is +created, and the token is single-use. Then sign in at +`https://$MCP_PUBLIC_DOMAIN/sign-in` and add your 1С bases + user grants under +`/admin`. + +> **Treat the printed URL as a secret** — it carries the one-time token (and so +> can leak via browser history, `Referer`, or a proxy log). Don't paste it into +> shared tools; open it directly. It self-closes once the first admin is created. + +**Alternatives (break-glass):** + +```bash +# Non-interactive seed (equivalent to the wizard, for automation): +docker compose run --rm mcp admin-create \ + --email you@example.com --password 'a-strong-password' + +# Forgotten password — reset an existing user's password directly in the store: +docker compose run --rm mcp set-password \ + --email you@example.com --password 'a-new-strong-password' +``` + +## 4. Connect Claude + +Add a **custom connector** in Claude with the URL `MCP_PUBLIC_URL` (e.g. `https://mcp.example.com`). Claude self-registers (DCR), you log in on `/sign-in`, consent, and its queries then hit `/mcp` with a verified token — scoped to the bases you granted that user. + +## TLS certificate options + +Caddy terminates TLS; the app only ever speaks plain HTTP on the internal +network. How Caddy gets the certificate is up to you — three paths, pick by how +the host is reachable. + +### Default — automatic HTTP-01 (public host) + +The out-of-the-box behaviour described above: on the first HTTPS hit Caddy +provisions a Let's Encrypt certificate via the **HTTP-01** challenge and renews +it before expiry. Needs the domain to resolve to this host **and inbound 80/443 +reachable from the internet** (80 carries the challenge). Nothing to configure — +this is what `.env.example` + the shipped `Caddyfile` do. + +### Internal / grey-IP host — DNS-01 + +For a host on a private ("grey") IP — clients on the LAN or over VPN, nothing +exposed to the internet — HTTP-01 can't work (the CA can't reach port 80). The +**DNS-01** challenge proves domain ownership with a DNS `TXT` record instead, so +the host never needs to be publicly reachable. The CA validates purely through +the `TXT` record and **never queries your `A` record**, so the `A` record may +resolve to an RFC1918 address (`10.x` / `192.168.x`) and you still get a real, +publicly-trusted Let's Encrypt certificate — **nothing to install on any client**. + +Requirements: + +- The domain's zone is hosted at a DNS provider with an **API** (Cloudflare, + Route53, DigitalOcean, …). +- The `_acme-challenge.` `TXT` record must resolve in the **public** + authoritative DNS (that's what the CA reads). Because the CA never looks at the + `A` record, you're free where it lives: publish it (pointing at the private IP) + for simplicity, or keep it split-horizon / internal-only so the private IP is + never exposed in public DNS. + +The stock `caddy:2` image has no DNS-provider plugins, so build a small custom +image with [`xcaddy`](https://github.com/caddyserver/xcaddy) (Cloudflare shown; +swap in your provider's [`caddy-dns/*`](https://github.com/orgs/caddy-dns/repositories) +module): + +```dockerfile +# Caddyfile.dns.Dockerfile — add it here in packages/mcp-server/deploy/ +FROM caddy:2-builder AS builder +RUN xcaddy build --with github.com/caddy-dns/cloudflare +FROM caddy:2 +COPY --from=builder /usr/bin/caddy /usr/bin/caddy +``` + +Point the `caddy` service at it (`build:` instead of `image: caddy:2`), tell +Caddy to use DNS-01 globally, and pass the provider token: + +```caddyfile +# Caddyfile — add a global-options block above the site block +{ + acme_dns cloudflare {env.CF_API_TOKEN} +} + +{$MCP_PUBLIC_DOMAIN} { + reverse_proxy mcp:3000 +} +``` + +Set `CF_API_TOKEN` in the `caddy` service's environment. With DNS-01 you no +longer need port **80** at all — only **443**, and only reachable from your +LAN/VPN clients. + +> **Client reachability, not TLS, is the real constraint here.** An internal-only +> host works with MCP clients that run *inside* the network — **Claude Code** / +> **Claude Desktop** on a machine on the LAN or VPN make the `/mcp` calls +> themselves. The **hosted claude.ai** connector calls `/mcp` from Anthropic's +> servers over the public internet, so it can't reach a grey IP no matter the +> certificate — that path needs a publicly-exposed host. + +### Bring your own / wildcard certificate — no ACME + +If you already hold a certificate for the domain — e.g. a corporate wildcard +`*.example.com` — skip ACME entirely: mount the cert + key and point Caddy at +them. Works fully offline / air-gapped (no CA round-trip). + +```caddyfile +# Caddyfile +{$MCP_PUBLIC_DOMAIN} { + tls /etc/caddy/cert.pem /etc/caddy/key.pem + reverse_proxy mcp:3000 +} +``` + +Mount the files into the `caddy` service (read-only) and keep them current +yourself — Caddy won't renew a cert it didn't provision: + +```yaml +# compose.yml — caddy service +volumes: + - ./cert.pem:/etc/caddy/cert.pem:ro + - ./key.pem:/etc/caddy/key.pem:ro +``` + +## Behind your own reverse proxy + +The bundled Caddy is optional — front the server with **any** reverse proxy +(nginx, HAProxy, a corporate load balancer, a cloud ingress). The app only ever +speaks **plain HTTP** and never terminates TLS. Crucially, it does **not** trust +or read `X-Forwarded-*` headers and needs no `trust proxy` setting: its external +identity — the OAuth `iss`/`aud`, the Protected Resource Metadata, and the +`/admin` CSRF origin — comes from the single `ONEC_MCP_PUBLIC_URL` you set, so +spoofed forwarding headers can't shift its origin. (The DNS-rebinding allowlist +is separate: it auto-*includes* that public host on top of the bind address and +loopback aliases, and `ONEC_MCP_ALLOWED_HOSTS` overrides it — see requirement 2.) +To swap Caddy out, point your proxy at the `mcp` service (or run the bin +directly) on its HTTP port and drop the `caddy` service. + +Your proxy must satisfy four things: + +1. **Set `ONEC_MCP_PUBLIC_URL`** to the exact external HTTPS origin clients use + (e.g. `https://1c-mcp.example.com`) — this is the connector URL and the OAuth + issuer. +2. **Forward the original `Host`** — the DNS-rebinding guard auto-allows the + `ONEC_MCP_PUBLIC_URL` host, so a proxy that preserves `Host` needs no extra + config. If the proxy rewrites `Host` to an internal upstream name, set + `ONEC_MCP_ALLOWED_HOSTS` to the raw `Host` value it actually sends + (comma-separated for several). +3. **Don't buffer the response stream.** Streamable HTTP streams server→client + over SSE on `GET /mcp`; a proxy that buffers responses or applies a short read + timeout will stall or cut the stream. Disable response buffering and allow + long-lived connections. +4. **Give it the origin root, not a sub-path.** OAuth discovery + (`/.well-known/oauth-*`) and the app's routes (`/mcp`, `/api/auth/*`, + `/sign-in`, `/consent`, `/admin`, `/setup`) are all origin-rooted, so serve it + on its own hostname — mounting under a path prefix + (`https://host/1c-mcp/…`) breaks Dynamic Client Registration and discovery. + +Keep the **single-instance** rule (session state, the `$metadata` cache and the +health job are per-process) — the proxy fronts one upstream, not a pool. + +An nginx server block covering all four: + +```nginx +server { + listen 443 ssl; + server_name 1c-mcp.example.com; # == ONEC_MCP_PUBLIC_URL host + + ssl_certificate /etc/ssl/certs/1c-mcp.pem; + ssl_certificate_key /etc/ssl/private/1c-mcp.key; + + location / { + proxy_pass http://127.0.0.1:3000; # the mcp bin's HTTP port + proxy_set_header Host $http_host; # forward the client's raw Host verbatim (req. 2) + + # SSE stream on GET /mcp — never buffer it, allow long connections (req. 3): + proxy_buffering off; + proxy_http_version 1.1; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } +} +``` + +> `/admin` is served on the same origin (its CSRF check is bound to +> `ONEC_MCP_PUBLIC_URL`) and gated by the better-auth `admin` role. To narrow that +> surface, add an IP allow-list or extra auth in front of `location /admin` in +> your proxy. + +## Operations + +- **Update:** `git pull && docker compose up -d --build` (migrations re-run idempotently on boot). +- **Logs:** `docker compose logs -f mcp`. +- **Health-probe tuning (optional):** the dashboard probes each base's reachability on an interval — set `ONEC_MCP_HEALTH_INTERVAL_MS` / `ONEC_MCP_HEALTH_TIMEOUT_MS` in `.env` (defaults 60000 / 5000; the probe is a lightweight service-root GET, and the timeout is clamped below the interval). Both are already wired through `compose.yml` to the `mcp` service. +- **Session limits (optional):** a per-principal quota and an idle sweeper cap concurrent MCP sessions and reclaim abandoned ones — tune via `ONEC_MCP_MAX_SESSIONS` / `ONEC_MCP_MAX_SESSIONS_PER_SUB` / `ONEC_MCP_SESSION_IDLE_MS` / `ONEC_MCP_SESSION_SWEEP_MS` in `.env` (defaults 1024 / 32 / 1800000 / 60000). See the package README "Session limits" for the semantics; the defaults suit most multi-tenant deployments. +- **Data:** lives in the `db-data` volume; TLS certs in `caddy-data`. Back both up. **Also back up `ONEC_MCP_ENC_KEY`** — without it the stored 1С passwords are unrecoverable. +- **Stop:** `docker compose down` (keeps volumes) — add `-v` to also drop the database and certs. + +## Notes + +- Only Caddy publishes host ports; Postgres and the direct `mcp:3000` port stay on the internal compose network. **`/admin` _is_ reachable** through Caddy at `https://$MCP_PUBLIC_DOMAIN/admin` — it has to be, since its CSRF check is bound to the public origin — but it is gated by the better-auth `admin` role (login + same-origin). To narrow that surface, add an IP allow-list or extra auth in front of `/admin` in the `Caddyfile`. +- Caddy preserves the original `Host` header, which the server's DNS-rebinding guard auto-allows from `ONEC_MCP_PUBLIC_URL` — no `ONEC_MCP_ALLOWED_HOSTS` needed. If you front this with a *different* proxy that rewrites `Host`, set `ONEC_MCP_ALLOWED_HOSTS` on the `mcp` service. +- **Local trial without a public domain:** set `MCP_PUBLIC_DOMAIN=localhost` and `MCP_PUBLIC_URL=https://localhost` — Caddy serves a local self-signed cert (your client must trust Caddy's local CA). Real connector use needs a real domain. +- **TLS is automatic** — on the first HTTPS request Caddy obtains a certificate from its default ACME CA (**Let's Encrypt**) and renews it before expiry; no certbot, cron, or manual step. It just needs the domain to resolve to this host and ports **80 + 443** open (80 for the ACME challenge). Keep the `caddy-data` volume — it holds the cert + ACME account, so wiping it forces re-issuance and can run into the CA's issuance rate limits. Optionally set an ACME email for expiry notices (see the `Caddyfile`). For an **internal host** that can't expose 80/443, or to use a certificate you already hold, see [TLS certificate options](#tls-certificate-options). + +## Other deployment targets + +Docker Compose (above) is the reference, but nothing here is Compose-specific — the +same binary — the published npm package, or an image you build from the +`Dockerfile` — runs on **any Node host or container platform** with the SAME env +contract: `BETTER_AUTH_SECRET`, `ONEC_MCP_ENC_KEY`, `DATABASE_URL`, +`ONEC_MCP_PUBLIC_URL` (see the package [README](../README.md#running-the-cli) for +invocation and the auth modes). Whatever you pick, a deploy must satisfy: + +- **One instance** — session state, the `$metadata` cache and the health job are + per-process; do not run more than one replica. +- **A persistent Postgres** (`DATABASE_URL`); migrations run on boot. +- **TLS terminated by a proxy that forwards the original `Host`** — else set + `ONEC_MCP_ALLOWED_HOSTS` to the raw `Host` the proxy actually forwards (an + internal upstream name if it rewrites `Host`, not necessarily the public host). + See [Behind your own reverse proxy](#behind-your-own-reverse-proxy) for the full + proxy contract (Host, SSE buffering, origin root) and an nginx example. +- The three secrets kept out of the image and backed up (losing `ONEC_MCP_ENC_KEY` + makes stored 1С passwords unrecoverable). + +Concretely: + +- **PaaS (Fly.io / Render / Railway):** point the platform at the prebuilt + `ghcr.io/hacker-cb/1c-odata-mcp-server` image (or build the `Dockerfile`) and + attach the platform's managed Postgres; TLS + the domain come from the platform, + so you can drop the `caddy` service. Pin the app to a single machine/replica. +- **Bare VPS (systemd):** `npm i -g @1c-odata/mcp-server`, run `1c-odata-mcp-server + serve --host 0.0.0.0 --port 3000` under a `systemd` unit, front it with your own + TLS proxy (nginx/Caddy — see [Behind your own reverse proxy](#behind-your-own-reverse-proxy)), + and point `DATABASE_URL` at a system Postgres. +- **Kubernetes:** a 1-replica `Deployment` + a Postgres (managed or in-cluster) + + an Ingress with TLS. Single replica only until horizontal scaling lands. diff --git a/packages/mcp-server/deploy/ci/Caddyfile.ci b/packages/mcp-server/deploy/ci/Caddyfile.ci new file mode 100644 index 0000000..ce3efa2 --- /dev/null +++ b/packages/mcp-server/deploy/ci/Caddyfile.ci @@ -0,0 +1,16 @@ +# deploy/ci/Caddyfile.ci — CI-only Caddy config. +# +# Keeps the shipped deploy/Caddyfile BYTE-IDENTICAL: that file is mounted here at +# /etc/caddy/site.caddy and imported unchanged, so the ONLY prod-vs-CI delta is the +# cert ISSUER — the one thing that provably cannot run on a GitHub runner (real +# ACME needs public DNS + inbound 80/443). Everything else (auto-HTTPS wiring, the +# reverse_proxy, Host preservation) is exercised exactly as in prod. +{ + # Every site uses Caddy's internal CA instead of Let's Encrypt — so site.caddy + # needs no per-site edit. skip_install_trust: don't try to add the root to the + # container's system trust store (unprivileged, and we extract root.crt instead). + local_certs + skip_install_trust +} + +import /etc/caddy/site.caddy diff --git a/packages/mcp-server/deploy/ci/compose-smoke.sh b/packages/mcp-server/deploy/ci/compose-smoke.sh new file mode 100755 index 0000000..ca8a97f --- /dev/null +++ b/packages/mcp-server/deploy/ci/compose-smoke.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# deploy/ci/compose-smoke.sh — Tier 3 smoke: drive the FULL shipped compose stack +# (db + mcp + caddy) through Caddy over real TLS (internal CA), verifying the +# certificate chain — never `curl -k`. +# +# Run from deploy/ (the workflow sets working-directory). The stack is already up +# (`docker compose ... up -d --wait`). Every wait is a bounded poll. +set -euo pipefail + +DC="docker compose -f compose.yml -f ci/compose.ci.yml" + +# Caddy mints its internal-CA root lazily at first provisioning — poll for it, then +# extract it so curl/undici can verify the chain. +for _ in $(seq 1 30); do + $DC exec -T caddy cat /data/caddy/pki/authorities/local/root.crt >ci-root.crt 2>/dev/null && [ -s ci-root.crt ] && break + sleep 1 +done +[ -s ci-root.crt ] || { echo "::error::Caddy internal-CA root.crt never appeared"; exit 1; } + +# Reach the base 0.0.0.0:443/80 publish via loopback; verify with the extracted CA. +# --max-time bounds a stalled server so a hang fails fast, not to the job timeout. +C="curl -sS --connect-timeout 5 --max-time 15 --cacert ci-root.crt --resolve mcp.test:443:127.0.0.1 --resolve mcp.test:80:127.0.0.1" +code() { $C -o /dev/null -w '%{http_code}' "$@"; } + +# root.crt existing does NOT mean the per-site LEAF for mcp.test is issued — that +# happens on first handshake. Retry the first HTTPS hit. +ok="" +for _ in $(seq 1 30); do + [ "$(code https://mcp.test/healthz)" = 200 ] && { ok=1; break; } + sleep 1 +done +[ -n "$ok" ] || { echo "::error::https://mcp.test/healthz never reached 200 (leaf not issued?)"; exit 1; } + +[ "$(code http://mcp.test/healthz)" = 308 ] # Caddy auto HTTP→HTTPS redirect + +# The wizard, through Caddy (Host preserved). No concurrency race here — a single +# compose stack has no request-level parallelism to exercise (that lives in Tier 1 +# on the real pg.Pool) — so a fixed email is deterministic and correct. +# `head -1` is load-bearing: the pino FIRST-RUN line carries the token TWICE (the +# `setupUrl` field AND the message text), so grep -oE matches twice — without +# head -1 the substitution yields "TOKEN\nTOKEN" and the embedded newline makes the +# request URL malformed (curl error 3). `|| true` so an empty grep doesn't abort +# the substitution (pipefail) BEFORE the diagnostic below can run. +TOKEN=$($DC logs --no-color --no-log-prefix mcp | grep 'FIRST-RUN' | grep -oE 'token=[A-Za-z0-9_-]+' | head -1 | cut -d= -f2 || true) +[ -n "$TOKEN" ] || { echo "::error::setup token not found in mcp logs"; exit 1; } + +[ "$(code "https://mcp.test/setup?token=$TOKEN")" = 200 ] # wizard OPEN through the proxy (status, not just body) +[ "$(code -X POST https://mcp.test/setup -H 'Origin: https://mcp.test' \ + --data-urlencode "token=$TOKEN" --data-urlencode email=a@b.co \ + --data-urlencode 'password=Password123!' --data-urlencode 'confirm=Password123!')" = 302 ] +[ "$(code "https://mcp.test/setup?token=$TOKEN")" = 404 ] # single-use: burned + self-closed + +# The /mcp bearer gate is wired THROUGH Caddy: an unauthenticated call → 401 with +# the RFC 9728 resource_metadata pointer. This proves the gate + discovery survive +# the proxy hop without needing a token. +# +# The full AUTHENTICATED round-trip (bearer → Host guard → initialize 200) is +# covered by Tier 1 direct and is not repeated through Caddy here — driving the +# whole OAuth dance (sign-in → DCR → authorize → consent → token, with PKCE) from +# a shell script buys little over the TS e2e that already covers it. +# +# Note this is now purely a cost call, not a topology limit: verification reads +# the AS's keys IN-PROCESS, so the container never has to resolve or trust its own +# public origin (see #106). It used to fetch https://mcp.test/api/auth/... , which +# in the compose network the container can neither resolve (mcp.test lives only in +# the RUNNER's /etc/hosts) nor trust (Caddy's internal CA). +mcp_headers=$($C -D - -o /dev/null -X POST https://mcp.test/mcp \ + -H 'accept: application/json, text/event-stream' -H 'content-type: application/json' --data '{}') +grep -qi 'www-authenticate' <<<"$mcp_headers" + +# Break-glass at the container boundary (README §3; ENTRYPOINT is the bin). +setpw=$($DC run --rm mcp set-password --email a@b.co --password 'Rotated456!' 2>&1) +grep -q 'password updated' <<<"$setpw" + +echo "deploy-compose-smoke: OK" diff --git a/packages/mcp-server/deploy/ci/compose.ci.yml b/packages/mcp-server/deploy/ci/compose.ci.yml new file mode 100644 index 0000000..ee56c36 --- /dev/null +++ b/packages/mcp-server/deploy/ci/compose.ci.yml @@ -0,0 +1,29 @@ +# deploy/ci/compose.ci.yml — CI overlay for compose.yml (merged as a second -f). +# +# Compose resolves relative bind-mount paths against the PROJECT directory (the +# first -f file's parent = deploy/), NOT each override's own dir — so paths here +# are written relative to deploy/. +# +# Two deltas vs the shipped stack: +# 1. mcp runs the PREBUILT `onec-mcp:ci` image (built once via build-push-action +# with a GHA cache) instead of building inline — faster, cleaner cache. +# 2. caddy layers Caddyfile.ci on top so TLS uses the internal CA (see that file). +# +# NOT overridden on purpose: +# - No `ports:` on caddy — compose MERGES ports by concatenation, so a second +# mapping would collide with the base 0.0.0.0:80/443. The base publish is +# harmless on an ephemeral runner; the smoke reaches it via `curl --resolve`. +# - The mcp healthcheck lives in the base compose.yml, so `up --wait` is a real +# readiness signal here too. +services: + mcp: + image: onec-mcp:ci + pull_policy: never # the image is loaded locally by build-push-action; never reach a registry + build: !reset null # drop the base `build:` so `up` uses the prebuilt image, never rebuilds + + caddy: + volumes: + # The shipped Caddyfile becomes an imported fragment; Caddyfile.ci is the root + # config. Paths are relative to the project dir (deploy/). + - ./Caddyfile:/etc/caddy/site.caddy:ro + - ./ci/Caddyfile.ci:/etc/caddy/Caddyfile:ro diff --git a/packages/mcp-server/deploy/ci/mcp-flow.mjs b/packages/mcp-server/deploy/ci/mcp-flow.mjs new file mode 100644 index 0000000..7ad6f80 --- /dev/null +++ b/packages/mcp-server/deploy/ci/mcp-flow.mjs @@ -0,0 +1,223 @@ +#!/usr/bin/env node +// deploy/ci/mcp-flow.mjs +// +// A headless MCP connector: the full browser-less OAuth 2.1 dance a real Claude +// connector performs, then a POST /mcp `initialize`, driven twice to prove the +// server's DNS-rebinding Host guard is live END-TO-END through whatever fronts it. +// +// It exists so CI can assert the ONE seam no cheaper test reaches: on `/mcp` the +// bearer gate runs BEFORE the DNS-rebinding Host guard, so the guard is only +// reachable WITH a valid token — every Host assertion must be bearer-driven. An +// unauthenticated `curl /mcp` is a 401 and proves nothing. +// +// Deliberately dependency-free (global fetch only) so it runs against the shipped +// `--prod` deploy tree, which has no dev deps. It mirrors the OAuth flow in +// test/e2e/_harness.ts::runFlow; that suite is the authoritative version — keep +// the two in step. In particular the initialize POST MUST send +// Accept: application/json, text/event-stream +// or the Streamable-HTTP transport answers 406, not 200 (a positive-case break +// that is easy to misread as a Host-guard bug). +// +// Usage: +// node mcp-flow.mjs --host-ok [--host-evil ] +// Asserts: +// --host-ok → initialize returns 200 + an Mcp-Session-Id header +// --host-evil → the SAME request with a spoofed Host returns exactly 403 (OPTIONAL: +// meaningful only DIRECT to the app — through a proxy it tests the +// proxy's own host routing, not this server's guard, so Tier 3 omits it) +// A NODE_EXTRA_CA_CERTS env var (set by the caller) covers the TLS case behind a +// proxy terminating with a private CA; node:https honors it by default. +// +// The initialize POST uses node:http/https, NOT fetch: `Host` is a forbidden +// header name for fetch/undici (silently dropped), so a spoofed-Host request over +// fetch would carry the REAL host and the negative assertion would be inert. Raw +// node:http sends exactly the Host we set; SNI stays the real hostname for TLS. + +import { createHash, randomBytes } from 'node:crypto' +import http from 'node:http' +import https from 'node:https' + +const b64url = (buf) => Buffer.from(buf).toString('base64url') + +function arg(flag) { + const i = process.argv.indexOf(flag) + return i >= 0 ? process.argv[i + 1] : undefined +} + +const [publicUrl, email, password] = process.argv.slice(2) +const hostOk = arg('--host-ok') +const hostEvil = arg('--host-evil') // optional — see the negative-case note above +if (!publicUrl || !email || !password || !hostOk) { + console.error('usage: mcp-flow.mjs --host-ok [--host-evil ]') + process.exit(2) +} + +const base = `${publicUrl}/api/auth` // better-auth mount +const mcpUrl = `${publicUrl}/mcp` +const resource = mcpUrl // RFC 8707 resource → JWT aud +const redirectUri = 'http://127.0.0.1:9999/callback' // never dereferenced (headless) +const scope = 'openid mcp:read offline_access' + +const fail = (msg) => { + console.error(`mcp-flow: ${msg}`) + process.exit(1) +} + +// better-auth signals a redirect either as a 3xx Location or a 200 JSON {redirect,url}. +async function redirectTarget(res) { + if (res.status >= 300 && res.status < 400) return res.headers.get('location') + if (res.headers.get('content-type')?.includes('application/json')) { + const j = await res + .clone() + .json() + .catch(() => null) + if (j?.redirect === true && typeof j.url === 'string') return j.url + if (typeof j?.redirect_uri === 'string') return j.redirect_uri + if (typeof j?.redirectURI === 'string') return j.redirectURI + } + return null +} + +async function mintToken() { + const codeVerifier = b64url(randomBytes(32)) + const codeChallenge = b64url(createHash('sha256').update(codeVerifier).digest()) + + // Sign in → session cookie. + const signIn = await fetch(`${base}/sign-in/email`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: publicUrl }, + body: JSON.stringify({ email, password }), + }) + if (signIn.status !== 200) fail(`sign-in failed: ${signIn.status} ${await signIn.text()}`) + const cookie = signIn.headers + .getSetCookie() + .map((c) => c.split(';', 1)[0]) + .join('; ') + + // Dynamic Client Registration (what an MCP connector does). + const reg = await fetch(`${base}/oauth2/register`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: publicUrl }, + body: JSON.stringify({ + client_name: 'ci-mcp-flow', + redirect_uris: [redirectUri], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + scope, + }), + }) + if (reg.status !== 200) fail(`DCR failed: ${reg.status} ${await reg.text()}`) + const clientId = (await reg.json()).client_id + + const authorizeUrl = new URL(`${base}/oauth2/authorize`) + authorizeUrl.search = new URLSearchParams({ + response_type: 'code', + client_id: clientId, + redirect_uri: redirectUri, + scope, + state: b64url(randomBytes(8)), + code_challenge: codeChallenge, + code_challenge_method: 'S256', + resource, + }).toString() + + // authorize → /consent?; the consent POST completes the grant. + const authorized = await fetch(authorizeUrl, { headers: { cookie }, redirect: 'manual' }) + let target = await redirectTarget(authorized) + if (target?.includes('/consent')) { + const oauthQuery = target.split('?', 2)[1] ?? '' + const consent = await fetch(`${base}/oauth2/consent`, { + method: 'POST', + headers: { cookie, 'content-type': 'application/json', origin: publicUrl }, + redirect: 'manual', + body: JSON.stringify({ accept: true, oauth_query: oauthQuery }), + }) + target = await redirectTarget(consent) + if (target === null) fail(`consent did not redirect: ${consent.status} ${await consent.text()}`) + } + if (target === null) fail(`authorize did not redirect: ${authorized.status}`) + const code = new URL(target, base).searchParams.get('code') + if (code === null) fail(`no code in redirect: ${target}`) + + const tokenRes = await fetch(`${base}/oauth2/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', origin: publicUrl }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: clientId, + code_verifier: codeVerifier, + resource, + }).toString(), + }) + if (tokenRes.status !== 200) fail(`token exchange failed: ${tokenRes.status} ${await tokenRes.text()}`) + const accessToken = (await tokenRes.json()).access_token + if (!accessToken) fail('no access_token in token response') + return accessToken +} + +// POST /mcp initialize with an explicit Host header, over node:http(s) so the Host +// we set is the Host actually sent. Resolves { status, sessionId }. +function initialize(token, hostHeader) { + const u = new URL(mcpUrl) + const isTls = u.protocol === 'https:' + const mod = isTls ? https : http + const body = JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'ci-mcp-flow', version: '0.0.0' }, + }, + }) + const opts = { + method: 'POST', + hostname: u.hostname, + port: u.port || (isTls ? 443 : 80), + path: u.pathname, + headers: { + authorization: `Bearer ${token}`, + // Dual Accept is MANDATORY — the Streamable-HTTP transport 406s without it. + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(body), + host: hostHeader, // node:http sends this verbatim (fetch would forbid it) + }, + // Keep TLS SNI + cert validation pinned to the REAL hostname even when the Host + // header is spoofed, so only the app-level guard — not TLS — rejects it. + ...(isTls ? { servername: u.hostname } : {}), + } + return new Promise((resolve, reject) => { + const req = mod.request(opts, (res) => { + res.resume() // drain; we only need status + headers + resolve({ status: res.statusCode, sessionId: res.headers['mcp-session-id'] }) + }) + // node:http has NO default timeout — bound a server that accepts the socket but + // never responds, so this fails fast instead of hanging the CI job. + req.setTimeout(15000, () => req.destroy(new Error('initialize timed out'))) + req.on('error', reject) + req.end(body) + }) +} + +const token = await mintToken() + +// Good Host → 200 + a session id (proves the guard ADMITS the canonical host and +// the whole authed path works end-to-end, incl. through a Host-preserving proxy). +const good = await initialize(token, hostOk) +if (good.status !== 200) fail(`--host-ok expected 200, got ${good.status}`) +if (!good.sessionId) fail('--host-ok: no Mcp-Session-Id header on the 200') + +// Spoofed Host → exactly 403 (only asserted DIRECT to the app; a downgrade to a +// generic 400/401 would be distinguishable and is failed). +if (hostEvil !== undefined) { + const evil = await initialize(token, hostEvil) + if (evil.status !== 403) fail(`--host-evil expected 403, got ${evil.status}`) + console.log(`mcp-flow OK: initialize 200 for Host=${hostOk} (session issued), 403 for Host=${hostEvil}`) +} else { + console.log(`mcp-flow OK: initialize 200 for Host=${hostOk} (session issued)`) +} diff --git a/packages/mcp-server/deploy/ci/package-smoke.sh b/packages/mcp-server/deploy/ci/package-smoke.sh new file mode 100755 index 0000000..6aa2818 --- /dev/null +++ b/packages/mcp-server/deploy/ci/package-smoke.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# deploy/ci/package-smoke.sh — Tier 2 published-package smoke. +# +# `package:lint` (publint/attw) checks the type/export surface but never installs +# the tarball, boots the bin, or resolves `drizzle/` from a real node_modules +# layout. This tier owns exactly that: pack the four @1c-odata/* tarballs, install +# them into a scratch project (pinned to the local tarballs so npm never reaches +# the registry for the unpublished workspace siblings), then drive the AUTH path +# so runAuthMigrations actually walks migrationsFolder() from the installed tree. +# +# Caller provides REPO (repo root) and a writable TMP. Bounded polling only. +set -euo pipefail + +REPO="${REPO:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)}" +TMP="${TMP:-$(mktemp -d)}" +TARS="$TMP/tars" +SCRATCH="$TMP/scratch" +mkdir -p "$TARS" "$SCRATCH" + +# Pack the mcp-server and its three workspace siblings (client, metadata, mcp). +# pnpm rewrites each `workspace:*` to the exact local version in the tarball. +( cd "$REPO" && pnpm --filter '@1c-odata/mcp-server' --filter '@1c-odata/mcp' \ + --filter '@1c-odata/metadata' --filter '@1c-odata/client' pack --pack-destination "$TARS" ) + +# Resolve exact tarball paths. The `mcp` glob must exclude `mcp-server`, so match a +# digit right after `mcp-` (versions start with a digit). `head -1` guards against a +# stray second tarball (TARS is a fresh dir, but be defensive so a multi-line value +# can't slip into the file: specs below). +S=$(ls "$TARS"/1c-odata-mcp-server-*.tgz | head -1) +M=$(ls "$TARS"/1c-odata-mcp-[0-9]*.tgz | head -1) +C=$(ls "$TARS"/1c-odata-client-*.tgz | head -1) +D=$(ls "$TARS"/1c-odata-metadata-*.tgz | head -1) + +# Pin EVERY @1c-odata/* to its local tarball via `overrides` so npm resolves the +# unpublished siblings from disk, never the registry (which would 404). +cd "$SCRATCH" +node -e " +const fs=require('fs'); +fs.writeFileSync('package.json', JSON.stringify({ + name:'scratch', private:true, version:'0.0.0', + dependencies:{'@1c-odata/mcp-server':'file:$S'}, + overrides:{ + '@1c-odata/mcp-server':'file:$S','@1c-odata/mcp':'file:$M', + '@1c-odata/client':'file:$C','@1c-odata/metadata':'file:$D' + } +}, null, 2)); +" +# --install-links materializes the file: deps as real trees (not symlinks), so the +# result mirrors a registry install. +npm install --install-links --no-audit --no-fund + +# The migrations SQL must actually ship in the published `files` set — a direct +# guard that cannot silently pass (a dropped `drizzle/` ENOENTs the migrator). The +# resolved path is also the bin's own dist neighbour we invoke serve through below. +ls node_modules/@1c-odata/mcp-server/drizzle/*.sql >/dev/null +BIN=node_modules/@1c-odata/mcp-server/dist/cli.js +# Materialize-then-match (not `… | grep -q`): under pipefail a late writer hitting +# the pipe grep -q already closed would SIGPIPE and trip set -e. +help=$(npx 1c-odata-mcp-server --help); grep -q admin-create <<<"$help" # bin mapping + commander resolve + +# AUTH path from the installed tree: admin-create runs runAuthMigrations, which +# walks migrationsFolder() dist→package.json→drizzle/ in the node_modules layout. +# A dropped drizzle/ now fails HERE instead of passing green. +export BETTER_AUTH_SECRET +BETTER_AUTH_SECRET=$(openssl rand -base64 32) +seed=$(npx 1c-odata-mcp-server admin-create \ + --auth-data-dir "$TMP/store" --public-url http://127.0.0.1:3010 \ + --email a@b.co --password 'Password123!' 2>&1) +grep -qi 'admin user created' <<<"$seed" + +# And boot the auth serve against that same persistent pglite store. Invoke the bin +# via `node` directly (not `npx`) so $! is the server PID the trap must kill — an +# npx shim can outlive a SIGTERM that only reached the wrapper. +ONEC_MCP_PUBLIC_URL=http://127.0.0.1:3010 \ + node "$BIN" serve --auth-data-dir "$TMP/store" --host 127.0.0.1 --port 3010 >"$TMP/pk.log" 2>&1 & +PK=$! +trap 'kill -TERM "$PK" 2>/dev/null || true' EXIT +health=$(curl -fsS --retry 30 --retry-delay 1 --retry-connrefused --max-time 2 http://127.0.0.1:3010/healthz) +grep -q '"ok"' <<<"$health" + +echo "deploy-package-smoke: OK" diff --git a/packages/mcp-server/deploy/ci/smoke.sh b/packages/mcp-server/deploy/ci/smoke.sh new file mode 100755 index 0000000..14a29db --- /dev/null +++ b/packages/mcp-server/deploy/ci/smoke.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# deploy/ci/smoke.sh — Tier 1 deploy smoke: boot the shipped `pnpm deploy --prod` +# tree against a REAL Postgres and walk the security-critical deploy paths that +# the pglite-backed unit/e2e suite structurally cannot exercise: +# - the node-postgres migrator vs a real server (dev/tests use pglite); +# - the setup token's single-use guarantee under TRUE concurrency (pglite +# serializes queries; a prod pg.Pool does not); +# - the DNS-rebinding Host guard, bearer-driven, end-to-end; +# - SIGTERM drain + idempotent re-migration on restart. +# +# Caller (ci.yml) provides: APP_DIR (the deploy tree), DATABASE_URL (a real pg), +# ONEC_MCP_PUBLIC_URL. Every wait is a bounded poll — never a fixed sleep. +set -euo pipefail + +BASE=$ONEC_MCP_PUBLIC_URL +# Generated ONCE and reused across BOTH boots: the jwks signing key is stored +# encrypted with BETTER_AUTH_SECRET, so a rotated secret would fail to decrypt it +# on restart (a 500 at the token endpoint). Prod keeps these stable in .env too. +export BETTER_AUTH_SECRET="${BETTER_AUTH_SECRET:-$(openssl rand -base64 32)}" +export ONEC_MCP_ENC_KEY="${ONEC_MCP_ENC_KEY:-$(openssl rand -base64 32)}" # 32 bytes → enables tenancy (/setup, /admin) + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +serve() { node "$APP_DIR/dist/cli.js" serve --host 127.0.0.1 --port 3000 >"$1" 2>&1 & echo $!; } +# Capture then match (not `curl | grep -q`): keeps `-fsS`'s fail-fast on an HTTP +# error while avoiding a SIGPIPE if grep -q closes the pipe before curl finishes. +wait_health() { + local body + body=$(curl -fsS --retry 40 --retry-delay 1 --retry-connrefused --max-time 2 "$BASE/healthz") || return 1 + grep -q '"ok"' <<<"$body" +} +# --max-time bounds a stalled server (accepts the socket, never responds) so a hang +# fails fast instead of blocking the job to GitHub's outer timeout. +code() { curl -s --connect-timeout 5 --max-time 15 -o /dev/null -w '%{http_code}' "$@"; } + +# ── Boot #1: node-postgres migrator vs REAL pg, + the first-run wizard ────────── +MCP=$(serve /tmp/mcp1.log) +trap 'kill -TERM "$MCP" 2>/dev/null || true' EXIT +wait_health + +# The token is minted inside createHttpServer BEFORE listen(), so it is in the log +# by the time /healthz answers. Bounded regex-poll; never `logs -f` (blocks). +TOKEN="" +for _ in $(seq 1 15); do + TOKEN=$(grep -oE 'setup\?token=[A-Za-z0-9_-]+' /tmp/mcp1.log | head -1 | cut -d= -f2 || true) + [ -n "$TOKEN" ] && break + sleep 1 +done +[ -n "$TOKEN" ] || { echo "::error::setup token never appeared in the boot log"; exit 1; } + +[ "$(code "$BASE/setup?token=$TOKEN")" = 200 ] # wizard OPEN with the valid token +[ "$(code "$BASE/setup?token=nope")" = 404 ] # wrong token → uniform 404 (no oracle) +[ "$(code "$BASE/.well-known/oauth-protected-resource/mcp")" = 200 ] # PRM discovery wired in the dist +[ "$(code "$BASE/.well-known/oauth-authorization-server")" = 200 ] # AS metadata wired + +# NEGATIVE CSRF: a cross-origin POST must be EXACTLY 403 (assert the code, not +# `!= 302` — a weak check would also pass on a stray 404/500). +[ "$(code -X POST "$BASE/setup" -H 'Origin: https://evil.test' \ + --data-urlencode "token=$TOKEN" --data-urlencode email=x@y.co \ + --data-urlencode 'password=Password123!' --data-urlencode 'confirm=Password123!')" = 403 ] + +# ── SECURITY: the single-use token under TRUE concurrency ─────────────────────── +# 20 parallel POSTs, one token, DISTINCT emails (so the email-unique constraint is +# not the gate) → EXACTLY one 302. The winner is NONDETERMINISTIC, so read its +# email back from the winning line instead of hardcoding it. +race_one() { + echo "$(code -X POST "$BASE/setup" -H "Origin: $BASE" \ + --data-urlencode "token=$TOKEN" --data-urlencode "email=admin$1@ci.local" \ + --data-urlencode 'password=Password123!' --data-urlencode 'confirm=Password123!') admin$1@ci.local" +} +export -f race_one code +export BASE TOKEN +seq 20 | xargs -P20 -I@ bash -c 'race_one @' >/tmp/race.txt +N302=$(awk '$1==302' /tmp/race.txt | wc -l | tr -d ' ') +[ "$N302" = 1 ] || { echo "::error::token race seeded $N302 admins (want exactly 1)"; sort /tmp/race.txt; exit 1; } +ADMIN=$(awk '$1==302{print $2}' /tmp/race.txt) +[ -n "$ADMIN" ] +[ "$(code "$BASE/setup?token=$TOKEN")" = 404 ] # single-use: token burned, wizard self-closed + +# ── Host guard ENFORCEMENT (bearer-driven; the gate precedes the guard on /mcp) ─ +# Materialize then match (not `curl | grep -q`): grep -q closes the pipe on match, +# and under pipefail a late writer hitting SIGPIPE would trip set -e. +headers=$(curl -s --connect-timeout 5 --max-time 15 -D - -o /dev/null -X POST "$BASE/mcp" \ + -H 'accept: application/json, text/event-stream' -H 'content-type: application/json' --data '{}') +grep -qi 'www-authenticate' <<<"$headers" # unauth → 401 + RFC 9728 pointer +# `timeout` bounds a stalled server (a hung /authorize or /mcp) to fail fast rather +# than block the job to GitHub's outer timeout — the bounded-poll contract. +timeout 60 node "$here/mcp-flow.mjs" "$BASE" "$ADMIN" 'Password123!' \ + --host-ok 127.0.0.1:3000 --host-evil evil.test # 200 canonical Host, 403 spoofed + +# ── Break-glass on the real store ────────────────────────────────────────────── +setpw=$(node "$APP_DIR/dist/cli.js" set-password --email "$ADMIN" --password 'Rotated456!' 2>&1) +grep -q 'password updated' <<<"$setpw" + +# ── SIGTERM drain within compose's 10s window, then IDEMPOTENT re-boot ────────── +kill -TERM "$MCP" +timeout 12 bash -c "while kill -0 $MCP 2>/dev/null; do sleep 0.3; done" \ + || { echo "::error::mcp did not exit within 12s of SIGTERM"; kill -9 "$MCP"; exit 1; } + +MCP=$(serve /tmp/mcp2.log) +wait_health # migrations idempotent on the 2nd boot +grep -q 'FIRST-RUN SETUP' /tmp/mcp2.log && { echo "::error::setup token re-minted after an admin exists"; exit 1; } || true +[ "$(code "$BASE/setup?token=$TOKEN")" = 404 ] # admin persisted across the restart +kill -TERM "$MCP" 2>/dev/null || true + +echo "deploy-pg-smoke: OK" diff --git a/packages/mcp-server/deploy/compose.prod.yml b/packages/mcp-server/deploy/compose.prod.yml new file mode 100644 index 0000000..beff318 --- /dev/null +++ b/packages/mcp-server/deploy/compose.prod.yml @@ -0,0 +1,19 @@ +# compose.prod.yml — run the stack from the PUBLISHED image instead of building +# from source. Overlay on top of compose.yml: +# +# export MCP_IMAGE_TAG=0.7.0 # pin an exact version in production +# docker compose -f compose.yml -f compose.prod.yml pull +# docker compose -f compose.yml -f compose.prod.yml up -d +# +# This needs only compose.yml + compose.prod.yml + Caddyfile + .env on the host — +# no monorepo checkout and no build toolchain (the base compose.yml's `build:` +# context is `../../..`, which only exists in a full source tree). +services: + mcp: + # Drop the base `build:` block entirely (Compose `!reset` merge tag) so compose + # never tries to build from the (absent) repo root — it only pulls the image. + build: !reset null + image: ghcr.io/hacker-cb/1c-odata-mcp-server:${MCP_IMAGE_TAG:-latest} + # `latest` is mutable, so always re-check the registry digest on `up`. For a + # pinned immutable tag this is just a cheap HEAD; the layers are cached. + pull_policy: always diff --git a/packages/mcp-server/deploy/compose.yml b/packages/mcp-server/deploy/compose.yml new file mode 100644 index 0000000..8080424 --- /dev/null +++ b/packages/mcp-server/deploy/compose.yml @@ -0,0 +1,94 @@ +name: onec-mcp + +# Single-instance production stack: the MCP server + its Postgres + a Caddy +# reverse proxy that terminates TLS (auto-HTTPS) and forwards to the server. +# Copy .env.example → .env and fill it in first. See README.md. + +services: + db: + image: postgres:17-bookworm + restart: unless-stopped + environment: + POSTGRES_USER: mcp + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} + POSTGRES_DB: mcp + volumes: + - db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mcp -d mcp"] + interval: 5s + timeout: 5s + retries: 10 + + mcp: + build: + # Repo root — the workspace build needs @1c-odata/client + @1c-odata/mcp. + context: ../../.. + dockerfile: packages/mcp-server/deploy/Dockerfile + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + # OAuth issuer + MCP resource id; must match the URL you add to Claude. + ONEC_MCP_PUBLIC_URL: ${MCP_PUBLIC_URL:?set MCP_PUBLIC_URL in .env} + # Signs sessions/JWTs; keep stable across restarts. + BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?set BETTER_AUTH_SECRET in .env} + # AES-256 key encrypting 1С passwords at rest — turns on multi-tenancy. + ONEC_MCP_ENC_KEY: ${ONEC_MCP_ENC_KEY:?set ONEC_MCP_ENC_KEY in .env} + # Key rotation (optional): the id of the current key, plus retired keys kept + # for decryption only. Empty → id 1 and no retired keys. See .env.example. + ONEC_MCP_ENC_KEY_ID: ${ONEC_MCP_ENC_KEY_ID:-} + ONEC_MCP_ENC_KEYS_PREVIOUS: ${ONEC_MCP_ENC_KEYS_PREVIOUS:-} + # Auth store; migrations run automatically on boot. POSTGRES_PASSWORD is + # interpolated raw, so it must be URL-safe — generate it with `openssl rand -hex`. + DATABASE_URL: postgres://mcp:${POSTGRES_PASSWORD}@db:5432/mcp + # Optional connection-health tuning. Empty → defaults (60s interval, 5s probe + # timeout); the timeout is clamped below the interval. See .env.example. + ONEC_MCP_HEALTH_INTERVAL_MS: ${ONEC_MCP_HEALTH_INTERVAL_MS:-} + ONEC_MCP_HEALTH_TIMEOUT_MS: ${ONEC_MCP_HEALTH_TIMEOUT_MS:-} + # Optional session limits. Empty → defaults (1024 global / 32 per-principal / + # 30m idle TTL / 60s sweep). See .env.example and the package README. + ONEC_MCP_MAX_SESSIONS: ${ONEC_MCP_MAX_SESSIONS:-} + ONEC_MCP_MAX_SESSIONS_PER_SUB: ${ONEC_MCP_MAX_SESSIONS_PER_SUB:-} + ONEC_MCP_SESSION_IDLE_MS: ${ONEC_MCP_SESSION_IDLE_MS:-} + ONEC_MCP_SESSION_SWEEP_MS: ${ONEC_MCP_SESSION_SWEEP_MS:-} + # No published ports — only Caddy reaches it on the internal compose network. + expose: + - "3000" + # Readiness = migrations done AND listening. The slim image has no curl, so + # probe with node. Lets Caddy wait for a READY app (not just a started one), + # so the first requests don't 502, and surfaces a boot crash-loop in `ps`. + healthcheck: + test: + - "CMD" + - "node" + - "-e" + - "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + interval: 5s + timeout: 5s + retries: 20 + start_period: 10s + + caddy: + image: caddy:2 + restart: unless-stopped + depends_on: + mcp: + condition: service_healthy + ports: + - "80:80" + - "443:443" + environment: + # Domain Caddy serves + provisions a TLS cert for (must resolve to this host). + MCP_PUBLIC_DOMAIN: ${MCP_PUBLIC_DOMAIN:?set MCP_PUBLIC_DOMAIN in .env} + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + # Persist ACME certs + state so restarts don't re-issue (rate limits!). + - caddy-data:/data + - caddy-config:/config + +volumes: + db-data: + caddy-data: + caddy-config: diff --git a/packages/mcp-server/drizzle.config.ts b/packages/mcp-server/drizzle.config.ts new file mode 100644 index 0000000..598ab8e --- /dev/null +++ b/packages/mcp-server/drizzle.config.ts @@ -0,0 +1,20 @@ +// drizzle.config.ts +import { defineConfig } from 'drizzle-kit' + +/** + * drizzle-kit config for generating the committed prod SQL. Points at the combined + * barrel (better-auth's generated `auth-schema.ts` + the hand-written + * `tenancy-schema.ts`) so `generate` emits DDL for BOTH the auth tables and the + * tenancy tables (bases / base_secrets / grants / health) into ./drizzle. drizzle-kit + * resolves the `export *` chain, so pointing at the barrel is enough. + * + * `generate` (SQL into ./drizzle) produces the single source of truth: BOTH prod + * (pg) and dev/tests (pglite) apply that same committed SQL via a drizzle-orm + * migrator — see src/store/migrate.ts. The url is only needed for `push`/`migrate` + * against a live DB, not for `generate`. + */ +export default defineConfig({ + schema: './src/store/schema.ts', + out: './drizzle', + dialect: 'postgresql', +}) diff --git a/packages/mcp-server/drizzle/0000_goofy_rhodey.sql b/packages/mcp-server/drizzle/0000_goofy_rhodey.sql new file mode 100644 index 0000000..94e7310 --- /dev/null +++ b/packages/mcp-server/drizzle/0000_goofy_rhodey.sql @@ -0,0 +1,159 @@ +CREATE TABLE "account" ( + "id" text PRIMARY KEY NOT NULL, + "account_id" text NOT NULL, + "provider_id" text NOT NULL, + "user_id" text NOT NULL, + "access_token" text, + "refresh_token" text, + "id_token" text, + "access_token_expires_at" timestamp, + "refresh_token_expires_at" timestamp, + "scope" text, + "password" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE TABLE "jwks" ( + "id" text PRIMARY KEY NOT NULL, + "public_key" text NOT NULL, + "private_key" text NOT NULL, + "created_at" timestamp NOT NULL, + "expires_at" timestamp +); +--> statement-breakpoint +CREATE TABLE "oauth_access_token" ( + "id" text PRIMARY KEY NOT NULL, + "token" text, + "client_id" text NOT NULL, + "session_id" text, + "user_id" text, + "reference_id" text, + "refresh_id" text, + "expires_at" timestamp, + "created_at" timestamp, + "scopes" text[] NOT NULL, + CONSTRAINT "oauth_access_token_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "oauth_client" ( + "id" text PRIMARY KEY NOT NULL, + "client_id" text NOT NULL, + "client_secret" text, + "disabled" boolean DEFAULT false, + "skip_consent" boolean, + "enable_end_session" boolean, + "subject_type" text, + "scopes" text[], + "user_id" text, + "created_at" timestamp, + "updated_at" timestamp, + "name" text, + "uri" text, + "icon" text, + "contacts" text[], + "tos" text, + "policy" text, + "software_id" text, + "software_version" text, + "software_statement" text, + "redirect_uris" text[] NOT NULL, + "post_logout_redirect_uris" text[], + "token_endpoint_auth_method" text, + "grant_types" text[], + "response_types" text[], + "public" boolean, + "type" text, + "require_pkce" boolean, + "reference_id" text, + "metadata" jsonb, + CONSTRAINT "oauth_client_client_id_unique" UNIQUE("client_id") +); +--> statement-breakpoint +CREATE TABLE "oauth_consent" ( + "id" text PRIMARY KEY NOT NULL, + "client_id" text NOT NULL, + "user_id" text, + "reference_id" text, + "scopes" text[] NOT NULL, + "created_at" timestamp, + "updated_at" timestamp +); +--> statement-breakpoint +CREATE TABLE "oauth_refresh_token" ( + "id" text PRIMARY KEY NOT NULL, + "token" text NOT NULL, + "client_id" text NOT NULL, + "session_id" text, + "user_id" text NOT NULL, + "reference_id" text, + "expires_at" timestamp, + "created_at" timestamp, + "revoked" timestamp, + "auth_time" timestamp, + "scopes" text[] NOT NULL, + CONSTRAINT "oauth_refresh_token_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "session" ( + "id" text PRIMARY KEY NOT NULL, + "expires_at" timestamp NOT NULL, + "token" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp NOT NULL, + "ip_address" text, + "user_agent" text, + "user_id" text NOT NULL, + "impersonated_by" text, + CONSTRAINT "session_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "user" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "email_verified" boolean DEFAULT false NOT NULL, + "image" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "role" text, + "banned" boolean DEFAULT false, + "ban_reason" text, + "ban_expires" timestamp, + CONSTRAINT "user_email_unique" UNIQUE("email") +); +--> statement-breakpoint +CREATE TABLE "verification" ( + "id" text PRIMARY KEY NOT NULL, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expires_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_access_token" ADD CONSTRAINT "oauth_access_token_client_id_oauth_client_client_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."oauth_client"("client_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_access_token" ADD CONSTRAINT "oauth_access_token_session_id_session_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."session"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_access_token" ADD CONSTRAINT "oauth_access_token_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_access_token" ADD CONSTRAINT "oauth_access_token_refresh_id_oauth_refresh_token_id_fk" FOREIGN KEY ("refresh_id") REFERENCES "public"."oauth_refresh_token"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_client" ADD CONSTRAINT "oauth_client_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_consent" ADD CONSTRAINT "oauth_consent_client_id_oauth_client_client_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."oauth_client"("client_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_consent" ADD CONSTRAINT "oauth_consent_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_refresh_token" ADD CONSTRAINT "oauth_refresh_token_client_id_oauth_client_client_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."oauth_client"("client_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_refresh_token" ADD CONSTRAINT "oauth_refresh_token_session_id_session_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."session"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_refresh_token" ADD CONSTRAINT "oauth_refresh_token_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "oauthAccessToken_clientId_idx" ON "oauth_access_token" USING btree ("client_id");--> statement-breakpoint +CREATE INDEX "oauthAccessToken_sessionId_idx" ON "oauth_access_token" USING btree ("session_id");--> statement-breakpoint +CREATE INDEX "oauthAccessToken_userId_idx" ON "oauth_access_token" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "oauthAccessToken_refreshId_idx" ON "oauth_access_token" USING btree ("refresh_id");--> statement-breakpoint +CREATE INDEX "oauthClient_userId_idx" ON "oauth_client" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "oauthConsent_clientId_idx" ON "oauth_consent" USING btree ("client_id");--> statement-breakpoint +CREATE INDEX "oauthConsent_userId_idx" ON "oauth_consent" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "oauthRefreshToken_clientId_idx" ON "oauth_refresh_token" USING btree ("client_id");--> statement-breakpoint +CREATE INDEX "oauthRefreshToken_sessionId_idx" ON "oauth_refresh_token" USING btree ("session_id");--> statement-breakpoint +CREATE INDEX "oauthRefreshToken_userId_idx" ON "oauth_refresh_token" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier"); \ No newline at end of file diff --git a/packages/mcp-server/drizzle/0001_right_captain_marvel.sql b/packages/mcp-server/drizzle/0001_right_captain_marvel.sql new file mode 100644 index 0000000..81f2f2d --- /dev/null +++ b/packages/mcp-server/drizzle/0001_right_captain_marvel.sql @@ -0,0 +1,41 @@ +CREATE TABLE "base_secrets" ( + "base_name" text PRIMARY KEY NOT NULL, + "key_id" smallint NOT NULL, + "nonce" "bytea" NOT NULL, + "ciphertext" "bytea" NOT NULL, + "tag" "bytea" NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "bases" ( + "name" text PRIMARY KEY NOT NULL, + "base_url" text NOT NULL, + "login" text NOT NULL, + "server_timezone" text NOT NULL, + "label" text, + "shape" jsonb, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "grants" ( + "sub" text NOT NULL, + "base_name" text NOT NULL, + "scope" text DEFAULT 'read' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "grants_sub_base_name_pk" PRIMARY KEY("sub","base_name") +); +--> statement-breakpoint +CREATE TABLE "health" ( + "base_name" text PRIMARY KEY NOT NULL, + "status" text NOT NULL, + "last_check" timestamp DEFAULT now() NOT NULL, + "error" text +); +--> statement-breakpoint +ALTER TABLE "base_secrets" ADD CONSTRAINT "base_secrets_base_name_bases_name_fk" FOREIGN KEY ("base_name") REFERENCES "public"."bases"("name") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "grants" ADD CONSTRAINT "grants_sub_user_id_fk" FOREIGN KEY ("sub") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "grants" ADD CONSTRAINT "grants_base_name_bases_name_fk" FOREIGN KEY ("base_name") REFERENCES "public"."bases"("name") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "health" ADD CONSTRAINT "health_base_name_bases_name_fk" FOREIGN KEY ("base_name") REFERENCES "public"."bases"("name") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "grants_sub_idx" ON "grants" USING btree ("sub");--> statement-breakpoint +CREATE INDEX "grants_base_name_idx" ON "grants" USING btree ("base_name"); \ No newline at end of file diff --git a/packages/mcp-server/drizzle/0002_huge_supreme_intelligence.sql b/packages/mcp-server/drizzle/0002_huge_supreme_intelligence.sql new file mode 100644 index 0000000..3b1d403 --- /dev/null +++ b/packages/mcp-server/drizzle/0002_huge_supreme_intelligence.sql @@ -0,0 +1,5 @@ +CREATE TABLE "setup_token" ( + "id" text PRIMARY KEY NOT NULL, + "token" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); diff --git a/packages/mcp-server/drizzle/meta/0000_snapshot.json b/packages/mcp-server/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..5a15da5 --- /dev/null +++ b/packages/mcp-server/drizzle/meta/0000_snapshot.json @@ -0,0 +1,1103 @@ +{ + "id": "c9a33039-545a-4e18-a29b-25352e66622e", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthAccessToken_clientId_idx": { + "name": "oauthAccessToken_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_sessionId_idx": { + "name": "oauthAccessToken_sessionId_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_userId_idx": { + "name": "oauthAccessToken_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_refreshId_idx": { + "name": "oauthAccessToken_refreshId_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": ["refresh_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauthClient_userId_idx": { + "name": "oauthClient_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauthConsent_clientId_idx": { + "name": "oauthConsent_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthConsent_userId_idx": { + "name": "oauthConsent_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthRefreshToken_clientId_idx": { + "name": "oauthRefreshToken_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshToken_sessionId_idx": { + "name": "oauthRefreshToken_sessionId_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshToken_userId_idx": { + "name": "oauthRefreshToken_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/mcp-server/drizzle/meta/0001_snapshot.json b/packages/mcp-server/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..a9a630f --- /dev/null +++ b/packages/mcp-server/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1368 @@ +{ + "id": "8c2d479a-0c56-45f7-9da9-6c945ee3e140", + "prevId": "c9a33039-545a-4e18-a29b-25352e66622e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthAccessToken_clientId_idx": { + "name": "oauthAccessToken_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_sessionId_idx": { + "name": "oauthAccessToken_sessionId_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_userId_idx": { + "name": "oauthAccessToken_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_refreshId_idx": { + "name": "oauthAccessToken_refreshId_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": ["refresh_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauthClient_userId_idx": { + "name": "oauthClient_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauthConsent_clientId_idx": { + "name": "oauthConsent_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthConsent_userId_idx": { + "name": "oauthConsent_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthRefreshToken_clientId_idx": { + "name": "oauthRefreshToken_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshToken_sessionId_idx": { + "name": "oauthRefreshToken_sessionId_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshToken_userId_idx": { + "name": "oauthRefreshToken_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.base_secrets": { + "name": "base_secrets", + "schema": "", + "columns": { + "base_name": { + "name": "base_name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "nonce": { + "name": "nonce", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "base_secrets_base_name_bases_name_fk": { + "name": "base_secrets_base_name_bases_name_fk", + "tableFrom": "base_secrets", + "tableTo": "bases", + "columnsFrom": ["base_name"], + "columnsTo": ["name"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bases": { + "name": "bases", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_timezone": { + "name": "server_timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shape": { + "name": "shape", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grants": { + "name": "grants", + "schema": "", + "columns": { + "sub": { + "name": "sub", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_name": { + "name": "base_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'read'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "grants_sub_idx": { + "name": "grants_sub_idx", + "columns": [ + { + "expression": "sub", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "grants_base_name_idx": { + "name": "grants_base_name_idx", + "columns": [ + { + "expression": "base_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "grants_sub_user_id_fk": { + "name": "grants_sub_user_id_fk", + "tableFrom": "grants", + "tableTo": "user", + "columnsFrom": ["sub"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grants_base_name_bases_name_fk": { + "name": "grants_base_name_bases_name_fk", + "tableFrom": "grants", + "tableTo": "bases", + "columnsFrom": ["base_name"], + "columnsTo": ["name"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "grants_sub_base_name_pk": { + "name": "grants_sub_base_name_pk", + "columns": ["sub", "base_name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.health": { + "name": "health", + "schema": "", + "columns": { + "base_name": { + "name": "base_name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_check": { + "name": "last_check", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "health_base_name_bases_name_fk": { + "name": "health_base_name_bases_name_fk", + "tableFrom": "health", + "tableTo": "bases", + "columnsFrom": ["base_name"], + "columnsTo": ["name"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/mcp-server/drizzle/meta/0002_snapshot.json b/packages/mcp-server/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..52e1ee9 --- /dev/null +++ b/packages/mcp-server/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1400 @@ +{ + "id": "21c4ff8b-ad60-4208-b1fc-6b7c7bcc665a", + "prevId": "8c2d479a-0c56-45f7-9da9-6c945ee3e140", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthAccessToken_clientId_idx": { + "name": "oauthAccessToken_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_sessionId_idx": { + "name": "oauthAccessToken_sessionId_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_userId_idx": { + "name": "oauthAccessToken_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_refreshId_idx": { + "name": "oauthAccessToken_refreshId_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": ["refresh_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauthClient_userId_idx": { + "name": "oauthClient_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauthConsent_clientId_idx": { + "name": "oauthConsent_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthConsent_userId_idx": { + "name": "oauthConsent_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthRefreshToken_clientId_idx": { + "name": "oauthRefreshToken_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshToken_sessionId_idx": { + "name": "oauthRefreshToken_sessionId_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshToken_userId_idx": { + "name": "oauthRefreshToken_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.base_secrets": { + "name": "base_secrets", + "schema": "", + "columns": { + "base_name": { + "name": "base_name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "nonce": { + "name": "nonce", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "base_secrets_base_name_bases_name_fk": { + "name": "base_secrets_base_name_bases_name_fk", + "tableFrom": "base_secrets", + "tableTo": "bases", + "columnsFrom": ["base_name"], + "columnsTo": ["name"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bases": { + "name": "bases", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_timezone": { + "name": "server_timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shape": { + "name": "shape", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grants": { + "name": "grants", + "schema": "", + "columns": { + "sub": { + "name": "sub", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_name": { + "name": "base_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'read'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "grants_sub_idx": { + "name": "grants_sub_idx", + "columns": [ + { + "expression": "sub", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "grants_base_name_idx": { + "name": "grants_base_name_idx", + "columns": [ + { + "expression": "base_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "grants_sub_user_id_fk": { + "name": "grants_sub_user_id_fk", + "tableFrom": "grants", + "tableTo": "user", + "columnsFrom": ["sub"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grants_base_name_bases_name_fk": { + "name": "grants_base_name_bases_name_fk", + "tableFrom": "grants", + "tableTo": "bases", + "columnsFrom": ["base_name"], + "columnsTo": ["name"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "grants_sub_base_name_pk": { + "name": "grants_sub_base_name_pk", + "columns": ["sub", "base_name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.health": { + "name": "health", + "schema": "", + "columns": { + "base_name": { + "name": "base_name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_check": { + "name": "last_check", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "health_base_name_bases_name_fk": { + "name": "health_base_name_bases_name_fk", + "tableFrom": "health", + "tableTo": "bases", + "columnsFrom": ["base_name"], + "columnsTo": ["name"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.setup_token": { + "name": "setup_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/mcp-server/drizzle/meta/_journal.json b/packages/mcp-server/drizzle/meta/_journal.json new file mode 100644 index 0000000..68d0fc5 --- /dev/null +++ b/packages/mcp-server/drizzle/meta/_journal.json @@ -0,0 +1,27 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1783101603366, + "tag": "0000_goofy_rhodey", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1783107396105, + "tag": "0001_right_captain_marvel", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1783245312695, + "tag": "0002_huge_supreme_intelligence", + "breakpoints": true + } + ] +} diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json new file mode 100644 index 0000000..2c0dbfa --- /dev/null +++ b/packages/mcp-server/package.json @@ -0,0 +1,88 @@ +{ + "name": "@1c-odata/mcp-server", + "version": "0.6.0", + "description": "Streamable HTTP MCP server for @1c-odata: read-only schema introspection and data queries against 1С:Enterprise OData V3 bases.", + "keywords": [ + "1c", + "1c-enterprise", + "odata", + "odata-v3", + "mcp", + "model-context-protocol", + "claude", + "http" + ], + "author": "Pavel Sokolov", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/hacker-cb/1c-odata.git", + "directory": "packages/mcp-server" + }, + "homepage": "https://github.com/hacker-cb/1c-odata#readme", + "bugs": { + "url": "https://github.com/hacker-cb/1c-odata/issues" + }, + "type": "module", + "bin": { + "1c-odata-mcp-server": "./dist/cli.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "engines": { + "node": ">=24.18.0" + }, + "files": [ + "dist", + "drizzle", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsdown", + "dev": "tsx src/cli.ts", + "start": "node dist/cli.js", + "prepare": "pnpm build", + "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json", + "test:unit": "vitest run --exclude \"test/integration/**\" --exclude \"test/e2e/**\"", + "test:coverage": "vitest run --exclude \"test/integration/**\" --exclude \"test/e2e/**\" --coverage", + "test:integration:offline": "echo no offline integration in mcp-server && exit 0", + "test:integration:live": "echo no live integration in mcp-server && exit 0", + "test:integration:write": "echo no write integration in mcp-server && exit 0", + "test:e2e": "vitest run test/e2e", + "package:lint": "publint && attw --pack . --ignore-rules no-resolution cjs-resolves-to-esm", + "auth:schema": "better-auth generate --config ./auth.config.ts --output ./auth-schema.ts --yes && biome check --write ./auth-schema.ts && drizzle-kit generate" + }, + "dependencies": { + "@1c-odata/mcp": "workspace:*", + "@better-auth/drizzle-adapter": "^1.6.23", + "@better-auth/oauth-provider": "^1.6.23", + "@electric-sql/pglite": "^0.5.4", + "@modelcontextprotocol/sdk": "^1.29.0", + "better-auth": "^1.6.23", + "commander": "^15.0.0", + "drizzle-orm": "^0.45.2", + "eta": "^4.6.0", + "express": "^5.2.1", + "jose": "^6.2.3", + "pg": "^8.22.0", + "pino": "^10.3.1" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "devDependencies": { + "@better-auth/cli": "^1.4.21", + "@types/express": "^5.0.6", + "@types/pg": "^8.20.0", + "drizzle-kit": "^0.31.10", + "msw": "^2.15.0" + } +} diff --git a/packages/mcp-server/src/auth/better-auth.ts b/packages/mcp-server/src/auth/better-auth.ts new file mode 100644 index 0000000..a18a0a6 --- /dev/null +++ b/packages/mcp-server/src/auth/better-auth.ts @@ -0,0 +1,143 @@ +// src/auth/better-auth.ts + +import { drizzleAdapter } from '@better-auth/drizzle-adapter' +import { oauthProvider } from '@better-auth/oauth-provider' +import { type Auth as BetterAuthInstance, betterAuth } from 'better-auth' +import { admin, jwt } from 'better-auth/plugins' +import type { JSONWebKeySet } from 'jose' +import type { AuthDb } from '../store/db.js' +import type { CanonicalUrls } from './config.js' + +export interface BuildAuthOptions { + urls: CanonicalUrls + db: AuthDb + /** BETTER_AUTH_SECRET. REQUIRED in prod — better-auth throws in production if unset. */ + secret: string + /** + * Trusted origins the CSRF / cookie machinery accepts. The public origin plus + * any client origins (Claude connector). Defaults to [publicUrl]. + */ + trustedOrigins?: string[] + /** + * Additional RFC 8707 resource ids the token endpoint may mint `aud` for, on + * top of the MCP resource url. Production leaves this empty; tests use it to + * mint a token for an alternate, AS-permitted resource and exercise the RS-side + * `aud` pin in isolation. + */ + extraAudiences?: string[] +} + +/** + * The better-auth instance type — reused by verifier wiring, discovery, and tests. + * + * We cannot use `ReturnType`: the fully-inferred `betterAuth()` + * return references zod's internal `$strip` symbol, which TypeScript refuses to + * serialize into our emitted `.d.ts` (TS2883/TS7056). So we widen to better-auth's + * exported base `Auth` and intersect it with just the plugin `api` members our + * consumers structurally need — `getOAuthServerConfig` (required by + * `oauthProviderAuthServerMetadata`). `handler` / `api` come from the base type; + * `toNodeHandler` and the middleware need nothing more. Plugin-specific `api.*` + * endpoints (sign-up, consent, …) are driven over HTTP in tests, so no caller + * depends on the discarded inferred narrowing. + */ +/** + * A minimal user shape the admin panel renders — a structural subset of the + * admin() plugin's `UserWithRole`. Kept local so the admin `api.*` intersection + * below stays serializable into our emitted `.d.ts` (the fully-inferred plugin + * types drag in zod's `$strip` symbol — see the note above). + */ +export interface AdminUser { + id: string + email: string + name: string + role?: string | null + banned?: boolean | null + createdAt?: Date | string +} + +/** + * The admin() plugin's `api` endpoints the admin panel drives. Widened to + * `(...args: any[]) => …` for the same reason `getOAuthServerConfig` is: the + * inferred better-call endpoint types are not `.d.ts`-portable, and our call + * sites only need the argument bag (headers + body/query) and the result shape. + */ +export interface AdminApi { + // biome-ignore lint/suspicious/noExplicitAny: inferred endpoint types are not .d.ts-portable; only the result shape is load-bearing. + listUsers: (...args: any[]) => Promise<{ users: AdminUser[] }> + // biome-ignore lint/suspicious/noExplicitAny: see listUsers. + createUser: (...args: any[]) => Promise<{ user: AdminUser }> + // biome-ignore lint/suspicious/noExplicitAny: see listUsers. Updates email/name of any user (POST /admin/update-user). + adminUpdateUser: (...args: any[]) => Promise + // biome-ignore lint/suspicious/noExplicitAny: see listUsers. + setRole: (...args: any[]) => Promise<{ user: AdminUser }> + // biome-ignore lint/suspicious/noExplicitAny: see listUsers. + setUserPassword: (...args: any[]) => Promise + // biome-ignore lint/suspicious/noExplicitAny: see listUsers. + removeUser: (...args: any[]) => Promise + // biome-ignore lint/suspicious/noExplicitAny: see listUsers. + banUser: (...args: any[]) => Promise<{ user: AdminUser }> + // biome-ignore lint/suspicious/noExplicitAny: see listUsers. + unbanUser: (...args: any[]) => Promise<{ user: AdminUser }> + // biome-ignore lint/suspicious/noExplicitAny: see listUsers. + revokeUserSessions: (...args: any[]) => Promise +} + +export type Auth = BetterAuthInstance & { + api: BetterAuthInstance['api'] & + AdminApi & { + // biome-ignore lint/suspicious/noExplicitAny: mirrors the plugin's own (...args: any) => any shape; only presence matters here. + getOAuthServerConfig: (...args: any[]) => any + /** + * The jwt() plugin's JWKS endpoint, called IN-PROCESS (it reads the `jwks` + * table directly). This is what lets the resource server verify tokens + * without fetching its own public origin — see `createLocalJwks`. + */ + // biome-ignore lint/suspicious/noExplicitAny: see AdminApi — the inferred endpoint types are not .d.ts-portable. + getJwks: (...args: any[]) => Promise + } +} + +/** + * Build the authorization server. Plugin order matters: `jwt()` must be present + * (and BEFORE oauthProvider is fine) so oauthProvider can sign asymmetric JWT + * access tokens. There is NO positive "enable JWT" flag — signing is automatic + * when jwt() is loaded and a `resource` is requested; `disableJwtPlugin` (default + * false) is the only opt-out and we deliberately leave it off. + * + * `validAudiences: [mcpResourceUrl]` is load-bearing: without it the token + * endpoint rejects our resource (or defaults `aud` to baseURL), and every /mcp + * call 401s despite a successful login. + */ +export function buildAuth(opts: BuildAuthOptions): Auth { + const { urls, db, secret } = opts + // Cast through unknown: the inferred betterAuth() type is structurally a + // superset of `Auth` but its zod-laden shape can't be checked against the + // widened alias directly (nor serialized — see the `Auth` note above). + return betterAuth({ + baseURL: urls.publicUrl, // → mount at /api/auth; `iss` defaults to this base + secret, + trustedOrigins: opts.trustedOrigins ?? [urls.publicUrl], + // Sign-IN stays enabled (the OAuth login page + admin login use it), but + // public self-service sign-UP is OFF: users are provisioned by an admin + // (the `admin-create` CLI seed and the admin panel's createUser go through + // the admin() plugin's trusted server path, which bypasses this flag). This + // closes the open-registration bypass — otherwise, on the auth-WITHOUT-keyring + // path (unscoped file pool), anyone could self-register and mint an mcp:read + // token that reads every configured base. + emailAndPassword: { enabled: true, disableSignUp: true }, + database: drizzleAdapter(db, { provider: 'pg' }), // "pg" for BOTH pglite and node-postgres + plugins: [ + jwt(), // asymmetric signing + JWKS at /api/auth/jwks (default alg EdDSA/Ed25519) + admin(), // role scaffolding for later tenancy/admin flows (Slice 3+) + oauthProvider({ + loginPage: '/sign-in', // required — plain BA login; plugin resumes the flow + consentPage: '/consent', // required — receives ?client_id&scope&code, POSTs /oauth2/consent + validAudiences: [urls.mcpResourceUrl, ...(opts.extraAudiences ?? [])], // ← RFC 8707 allowlist == our JWT `aud` + allowDynamicClientRegistration: true, // MCP connectors self-register (DCR / RFC 7591) + allowUnauthenticatedClientRegistration: true, // Claude registers before it has a token + scopes: ['openid', 'profile', 'email', 'offline_access', 'mcp:read'], + // disableJwtPlugin: false — leave OFF to keep signed-JWT access tokens + }), + ], + }) as unknown as Auth +} diff --git a/packages/mcp-server/src/auth/config.ts b/packages/mcp-server/src/auth/config.ts new file mode 100644 index 0000000..f6841b9 --- /dev/null +++ b/packages/mcp-server/src/auth/config.ts @@ -0,0 +1,73 @@ +// src/auth/config.ts +/** + * Canonical-URL module. Every URL the auth layer emits or validates derives from + * ONE public origin so `iss`, `aud`, PRM, and DNS-rebinding stay consistent. + * + * The public URL is the externally reachable origin of THIS server (behind a + * reverse proxy it differs from the bound address). better-auth uses it as + * `baseURL` (→ default `iss`), and the MCP resource id (`aud`, RFC 8707) is + * `${publicUrl}/mcp`. `validAudiences: [mcpResourceUrl]` gates the token endpoint. + */ +export interface CanonicalUrls { + /** Externally reachable origin, no trailing slash (e.g. https://mcp.example.com). */ + readonly publicUrl: string + /** better-auth mount base — `${publicUrl}/api/auth`. This is the OAuth issuer origin's mount. */ + readonly authBaseUrl: string + /** The OAuth 2.0 issuer identifier the RS pins (`iss`). Equals authBaseUrl (better-auth default). */ + readonly issuer: string + /** The MCP resource id (RFC 8707 `resource`, JWT `aud`) — `${publicUrl}/mcp`. */ + readonly mcpResourceUrl: string +} + +/** Strip a single trailing slash so `${base}/x` never double-slashes. */ +function trimTrailingSlash(u: string): string { + return u.replace(/\/+$/, '') +} + +/** + * Derive the canonical URL set from the public origin. `publicUrl` is REQUIRED — + * a wrong/absent value silently mis-scopes `aud` (every /mcp call then 401s), so + * there is no default; the caller (cli.ts) resolves it from --public-url / env. + */ +export function resolveCanonicalUrls(publicUrl: string): CanonicalUrls { + const base = trimTrailingSlash(publicUrl) + if (base === '' || !/^https?:\/\//.test(base)) { + throw new Error( + `Invalid public URL ${JSON.stringify(publicUrl)}: expected an absolute http(s) origin ` + + `(e.g. https://mcp.example.com). Set --public-url or ONEC_MCP_PUBLIC_URL.`, + ) + } + // Must be a BARE origin: every derived URL is origin-rooted (`/api/auth`, `/mcp`, + // `/.well-known/…`) and the Express routes mount at the root. A path/query/hash + // (e.g. https://host/mcp) would silently mangle `iss`/`aud` into https://host/mcp/api/auth. + let parsed: URL + try { + parsed = new URL(base) + } catch { + throw new Error(`Invalid public URL ${JSON.stringify(publicUrl)}: not a valid URL.`) + } + if ( + (parsed.pathname !== '' && parsed.pathname !== '/') || + parsed.search !== '' || + parsed.hash !== '' || + parsed.username !== '' || + parsed.password !== '' + ) { + throw new Error( + `Invalid public URL ${JSON.stringify(publicUrl)}: expected a bare origin without a path/query/fragment/userinfo ` + + `(e.g. https://mcp.example.com) — the server mounts /mcp and /api/auth at the origin root.`, + ) + } + // Derive from parsed.origin, NOT the raw `base`: `new URL` normalizes a bare `?` + // or `#` away from search/hash (so the guard above passes them), yet they survive + // in the raw string and would mangle `${base}/api/auth` into `…/?/api/auth`. + // The origin is the canonical scheme://host[:port] with any such tail dropped. + const origin = parsed.origin + const authBaseUrl = `${origin}/api/auth` + return { + publicUrl: origin, + authBaseUrl, + issuer: authBaseUrl, + mcpResourceUrl: `${origin}/mcp`, + } +} diff --git a/packages/mcp-server/src/auth/pages/consent.ts b/packages/mcp-server/src/auth/pages/consent.ts new file mode 100644 index 0000000..9779c91 --- /dev/null +++ b/packages/mcp-server/src/auth/pages/consent.ts @@ -0,0 +1,60 @@ +// src/auth/pages/consent.ts +import type { Request, Response } from 'express' +import { authShell, esc } from '../../ui/shell.js' + +/** + * Minimal /consent page fulfilling the oauthProvider `consentPage` contract. The + * plugin redirects here with the FULL signed authorize query appended (client_id, + * scope, code_challenge, exp, sig, …). On accept we POST to + * /api/auth/oauth2/consent with { accept: true, oauth_query } — where oauth_query + * is that verbatim query string. The plugin's `before` hook re-verifies the sig + * and repopulates the pending request from it (without oauth_query the endpoint + * 400s "missing oauth query"), then completes the authorization ITSELF and + * returns the redirect back to the client (with the code) — this page does not + * re-authorize. + * + * `accept: false` denies WITHOUT removing any prior consent (to fully revoke, + * delete the user's oauthConsent via /oauth2/delete-consent — out of scope here). + */ +export function consentPage(req: Request, res: Response): void { + const clientId = typeof req.query.client_id === 'string' ? req.query.client_id : '' + const scope = typeof req.query.scope === 'string' ? req.query.scope : '' + const scopes = scope + .split(' ') + .filter(Boolean) + .map((s) => `
  • ${esc(s)}
  • `) + .join('') + const body = `

    Authorize access

    +

    ${esc(clientId)} is requesting access to:

    +
      ${scopes}
    +
    + + +
    +

    ` + res + .status(200) + .type('html') + .send(authShell({ title: 'Authorize', body, scripts: CONSENT_SCRIPT })) +} + +// Client-side accept/deny. Reads the verbatim signed query from window.location at +// runtime (never interpolated into markup), so a crafted query cannot inject. +const CONSENT_SCRIPT = ` +async function decide(accept) { + const oauthQuery = window.location.search.replace(/^\\?/, ''); + const r = await fetch('/api/auth/oauth2/consent', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ accept, oauth_query: oauthQuery }), + }); + if (r.redirected) { window.location.href = r.url; return; } + const data = await r.json().catch(() => ({})); + const url = data.url || data.redirect_uri || data.redirectURI; + if (typeof url === 'string') { window.location.href = url; return; } + if (!accept) { window.location.href = '/'; return; } + document.getElementById('err').textContent = 'Consent failed'; +} +document.getElementById('allow').addEventListener('click', () => decide(true)); +document.getElementById('deny').addEventListener('click', () => decide(false));` diff --git a/packages/mcp-server/src/auth/pages/sign-in.ts b/packages/mcp-server/src/auth/pages/sign-in.ts new file mode 100644 index 0000000..8865fc4 --- /dev/null +++ b/packages/mcp-server/src/auth/pages/sign-in.ts @@ -0,0 +1,122 @@ +// src/auth/pages/sign-in.ts +import type { Request, Response } from 'express' +import { authShell } from '../../ui/shell.js' + +/** + * Optional first-run probe. When present and it resolves true (no admin exists + * yet), the page shows a small static hint that setup is pending — WITHOUT the + * setup token (the token lives only in the server log; leaking it here would + * defeat the token gate). Absent (or resolving false) → the plain sign-in page. + */ +export type FirstRunCheck = () => Promise + +/** + * Build the /sign-in handler, fulfilling the oauthProvider `loginPage` contract. + * The plugin redirects the unauthenticated user here with the ENTIRE + * /oauth2/authorize query string appended verbatim. Per the plugin docs we do NOT + * handle anything OAuth-specific: once a Better Auth session exists, the plugin + * resumes the authorize flow. We only (a) perform a normal email/password sign-in + * against /api/auth/sign-in/email, then (b) send the browser back to + * /api/auth/oauth2/authorize? to re-enter the flow. + * + * The resume URL is rebuilt CLIENT-SIDE from `window.location.search` — the + * request query is never interpolated into the server-rendered markup, so a + * ``-bearing query cannot break out of the inline script (reflected XSS). + * The page markup is therefore fully static (the only server-side branch is the + * first-run hint, which is likewise static and token-free). + * + * Three arrival paths: + * - OAuth login: the plugin appends the authorize query (carrying `client_id`) + * → after sign-in we resume `/api/auth/oauth2/authorize?`. + * - Admin gate: an anonymous `/admin` visit redirects to `/sign-in?next=/admin` + * → after sign-in we go to that `next`, but ONLY when it resolves to a + * same-origin absolute path (see `RESUME_TARGET_FN` — the guard resolves it + * with the URL parser and re-checks the origin, so `//host`, its backslash + * form, and scheme URIs are all rejected). This blocks an open-redirect via a + * crafted `next`. + * - Direct visit (no `next`, no authorize query): we land on `/admin`, the human + * home. We must NOT fall back to `/api/auth/oauth2/authorize` with no params — + * that endpoint then dumps a raw "client_id required" validation error at the user. + */ +export function makeSignInPage(firstRunCheck?: FirstRunCheck) { + return async (_req: Request, res: Response): Promise => { + let pending = false + if (firstRunCheck !== undefined) { + // A probe failure must never break the sign-in page — fall back to no hint. + pending = await firstRunCheck().catch(() => false) + } + res + .status(200) + .type('html') + .send(pending ? SIGN_IN_HTML_FIRST_RUN : SIGN_IN_HTML) + } +} + +// The first-run hint is STATIC markup — it names no token and interpolates no +// request data, so it carries no injection risk. It points the operator at the +// server log, which is where the `/setup?token=…` URL was printed at boot. +const FIRST_RUN_HINT = `

    First-run setup pending. No administrator exists yet. Open the +one-time setup URL printed in the server logs (…/setup?token=…) to create the first admin.

    ` + +/** + * The resume-target resolver, kept as a source STRING so the EXACT same code is + * both embedded in the inline sign-in script AND compiled + behavior-tested in + * Node (test/unit/sign-in-page.test.ts) — one source of truth, no drift. Pure: + * takes the URL query string (incl. any leading `?`), returns a same-origin + * relative path. `URLSearchParams` is a global in both the browser and Node. + */ +export const RESUME_TARGET_FN = `function resumeTarget(search, origin) { + const q = new URLSearchParams(search); + const next = q.get('next'); + // Honor next only if it resolves to a SAME-ORIGIN absolute path. The leading '/' + // rejects scheme URIs (javascript:, https://other-host); resolving against the + // origin and re-checking the result origin rejects protocol-relative '//', its + // backslash variant, and tab/newline tricks that browsers normalize on + // navigation. A character test alone is bypassable; this is not. Blocks open + // redirects via a crafted next. + if (next && next[0] === '/') { + try { + const u = new URL(next, origin); + if (u.origin === origin) return u.pathname + u.search + u.hash; + } catch (e) { /* malformed next — fall through to the default */ } + } + // Resume the OAuth authorize flow only when one is actually in progress + // (client_id present); a bare /sign-in visit would otherwise hit + // /api/auth/oauth2/authorize with no params and get a raw validation error. + if (q.get('client_id')) return '/api/auth/oauth2/authorize' + search; + // Direct visit: the admin panel is the human home. + return '/admin'; +}` + +// Client-side submit handler. No request data is interpolated here — the resume +// target is derived from window.location at runtime, so a crafted query cannot inject. +const SIGN_IN_SCRIPT = ` +${RESUME_TARGET_FN} +document.getElementById('f').addEventListener('submit', async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + const r = await fetch('/api/auth/sign-in/email', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ email: fd.get('email'), password: fd.get('password') }), + }); + if (r.ok) { window.location.href = resumeTarget(window.location.search, window.location.origin); } + else { document.getElementById('err').textContent = 'Sign-in failed'; } +});` + +function signInHtml(hint: string): string { + const body = `

    Sign in

    +

    Sign in to the admin panel and the 1С bases you've been granted.

    +${hint} +
    + + + +

    +
    ` + return authShell({ title: 'Sign in', body, scripts: SIGN_IN_SCRIPT }) +} + +const SIGN_IN_HTML = signInHtml('') +const SIGN_IN_HTML_FIRST_RUN = signInHtml(FIRST_RUN_HINT) diff --git a/packages/mcp-server/src/auth/resource-metadata.ts b/packages/mcp-server/src/auth/resource-metadata.ts new file mode 100644 index 0000000..fc6d2f0 --- /dev/null +++ b/packages/mcp-server/src/auth/resource-metadata.ts @@ -0,0 +1,51 @@ +// src/auth/resource-metadata.ts +import type { CanonicalUrls } from './config.js' + +/** + * The scope set required on `/mcp` when the operator configures none. SINGLE source + * of truth for the default: the bearer gate (auth-mount.ts) enforces it and the PRM + * below advertises it, so the two can never drift — a client reading discovery asks + * for exactly the scope the gate will check. + */ +export const DEFAULT_REQUIRED_SCOPES: readonly string[] = Object.freeze(['mcp:read']) + +/** + * RFC 9728 Protected Resource Metadata document for our MCP resource. Hand-built + * (not via the SDK's mcpAuthMetadataRouter, which would couple us to a full + * AS-metadata object) — it only needs to point clients at the AS. The `resource` + * field is our MCP resource id; `authorization_servers` lists our AS issuer. + */ +export interface ProtectedResourceMetadata { + resource: string + authorization_servers: string[] + scopes_supported: string[] + bearer_methods_supported: string[] + resource_name: string +} + +/** + * `requiredScopes` MUST be the same set the bearer gate enforces — advertising a + * scope the gate does not check (or omitting one it does) would make a + * spec-compliant client request the wrong scope and then fail authorization. + */ +export function buildResourceMetadata( + urls: CanonicalUrls, + requiredScopes: readonly string[] = DEFAULT_REQUIRED_SCOPES, +): ProtectedResourceMetadata { + return { + resource: urls.mcpResourceUrl, + authorization_servers: [urls.issuer], + scopes_supported: [...requiredScopes], + bearer_methods_supported: ['header'], + resource_name: '1C OData MCP server', + } +} + +/** + * The canonical PRM URL for our resource: RFC 9728 path-suffix form + * `/.well-known/oauth-protected-resource/mcp`. Matches the MCP SDK's + * `getOAuthProtectedResourceMetadataUrl(new URL(`${publicUrl}/mcp`))`. + */ +export function resourceMetadataUrl(urls: CanonicalUrls): string { + return `${urls.publicUrl}/.well-known/oauth-protected-resource/mcp` +} diff --git a/packages/mcp-server/src/auth/verifier.ts b/packages/mcp-server/src/auth/verifier.ts new file mode 100644 index 0000000..30a92eb --- /dev/null +++ b/packages/mcp-server/src/auth/verifier.ts @@ -0,0 +1,267 @@ +// src/auth/verifier.ts +import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js' +import type { OAuthTokenVerifier } from '@modelcontextprotocol/sdk/server/auth/provider.js' +import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js' +import { createLocalJWKSet, type JSONWebKeySet, type JWTPayload, errors as joseErrors, jwtVerify } from 'jose' + +/** + * Resolves the AS's public signing key for a given JWS header. This is exactly + * what jose's key-set helpers return, so a remote set is assignable too. + */ +export type KeyResolver = ReturnType + +export interface JwtVerifierOptions { + /** AS issuer (`iss`) to pin, e.g. https://mcp.example.com/api/auth. */ + issuer: string + /** Expected `aud` — our MCP resource id (RFC 8707), e.g. https://mcp.example.com/mcp. */ + audience: string + /** Public signing keys of the AS. See {@link createLocalJwks} for the in-process source. */ + keys: () => Promise + /** Restrict accepted signing algorithms. Default: the AS's EdDSA plus common asym algs. */ + algorithms?: string[] +} + +/** Reads the JWKS straight out of the AS — no HTTP. See {@link createLocalJwks}. */ +export type JwksReader = () => Promise + +/** Minimum spacing between JWKS reloads triggered by an unknown `kid`. */ +const JWKS_REFRESH_COOLDOWN_MS = 30_000 + +/** + * How long a loaded key set is used before it is refreshed. This is what bounds + * how long a key the operator REMOVED from the AS keeps verifying tokens (an + * unknown-`kid` reload only ever picks keys UP). Matches jose's remote-set default. + */ +const JWKS_MAX_AGE_MS = 600_000 + +/** How long one JWKS read may take before it is abandoned. */ +const JWKS_READ_TIMEOUT_MS = 5_000 + +export interface LocalJwksOptions { + /** + * Minimum spacing between reloads forced by an unknown `kid`, and between + * retries of a FAILING stale refresh. Default 30s. + */ + cooldownMs?: number + /** How long a loaded set is used before it is refreshed. Default 10 min. */ + maxAgeMs?: number + /** How long one read of the key set may take before it is abandoned. Default 5s. */ + timeoutMs?: number +} + +/** + * Build the verifier's key source from an AS that lives in THIS process. + * + * The server is both the Authorization Server (better-auth) and the Resource + * Server, so its signing keys are already local — in the `jwks` table the AS + * reads through `auth.api.getJwks()`. Fetching them back over the network from + * our own public origin would make token verification depend on hairpin-NAT / + * split-horizon DNS, which a single-host deploy behind a reverse proxy often + * lacks; there, OAuth would break entirely. Reading in-process removes that + * dependency — and with it the SSRF surface a URL-driven fetch carries. + * + * Public discovery is unaffected: `/.well-known/*` still advertises the PUBLIC + * `jwks_uri`, because external clients do need to reach it over the network. + * + * The set is cached, so a bearer check does not hit the database. Staleness is + * handled the way jose's `RemoteJWKSet` handles it: a set older than `maxAgeMs` + * is refreshed before use, and an unknown `kid` — the AS rotated a key in — + * forces one extra reload, rate-limited by `cooldownMs` so a stream of bogus + * `kid`s cannot turn into a read per request. Reloads are DEDUPED: concurrent + * misses share one read and all retry against the set it installs, so the + * requests in flight when a rotation lands are not spuriously rejected. + * + * Unlike jose we degrade gracefully on a stale refresh: this read is a local + * database query, and failing auth over a blip when a perfectly good (merely + * aging) key set is already in hand would trade the availability this whole + * change is about. A failed refresh keeps the last good set; only the very first + * load has no fallback and propagates. + * + * Every read is bounded by `timeoutMs`, because a read that never settles is not + * a failure any of the above recovers from — see {@link readWithin}. + */ +export function createLocalJwks(readJwks: JwksReader, opts: LocalJwksOptions = {}): () => Promise { + const { cooldownMs = JWKS_REFRESH_COOLDOWN_MS, maxAgeMs = JWKS_MAX_AGE_MS, timeoutMs = JWKS_READ_TIMEOUT_MS } = opts + + let keys: KeyResolver | undefined + let pending: Promise | undefined + let loadedAt = Number.NEGATIVE_INFINITY + let staleRefreshAt = Number.NEGATIVE_INFINITY + + /** + * Bound one read. `reload()` shares a SINGLE promise across every concurrent + * bearer check, so a read that hangs rather than fails would hold token + * verification for the life of the process — and none of the retry paths ever + * run, because they are all downstream of that promise settling. A hang is + * reachable: the pg pool has no checkout deadline by default, so a saturated + * pool waits indefinitely, and a lock can stall the query after checkout. + * + * Rejecting frees the waiters and clears `pending`, so the next request retries. + * The abandoned read is not cancelled — there is nothing to cancel it with — it + * is simply no longer awaited. + * + * The deadline is PER READ, and one request can make two: an aged set whose + * refresh times out (absorbed, so the request continues on the old set) followed + * by a miss on an unknown `kid`, which reads again. So the worst case for a + * single bearer check is 2 x `timeoutMs`. That is deliberate — collapsing it + * would mean either skipping the rotation retry, which 401s a valid token, or + * reporting the refresh failure as a bad token, which misclassifies infra as + * client error. Two bounded reads beat one unbounded one. + */ + const readWithin = async (): Promise => { + let timer: ReturnType | undefined + try { + return await Promise.race([ + readJwks(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Timed out reading the JWKS after ${timeoutMs}ms`)), timeoutMs) + }), + ]) + } finally { + clearTimeout(timer) // never leave the timer holding the event loop open + } + } + + // One shared read per reload (jose's `#pendingFetch`). `loadedAt` is stamped on + // COMPLETION, not on entry: stamping it up front would close the cooldown window + // on every concurrent miss the moment one of them started a reload, so all the + // others would 401 on a valid, freshly-rotated token instead of retrying. + const reload = (): Promise => { + pending ??= readWithin() + .then((jwks) => { + keys = createLocalJWKSet(jwks) + loadedAt = Date.now() + pending = undefined + }) + .catch((err: unknown) => { + pending = undefined // don't poison the cache — a transient failure can be retried + throw err + }) + return pending + } + + const loadedWithin = (windowMs: number): boolean => Date.now() - loadedAt < windowMs + + /** The set to verify against: loaded if absent, refreshed if past `maxAgeMs`. */ + const currentKeys = async (): Promise => { + if (keys === undefined) { + await reload() // nothing to fall back on — a failure here must surface + } else if (!loadedWithin(maxAgeMs) && Date.now() - staleRefreshAt >= cooldownMs) { + // Best-effort refresh of an aging set, but throttled by the failure case: a + // successful reload advances `loadedAt` and closes this branch until the next + // maxAge, whereas a FAILED one leaves the set stale, so without this every + // subsequent verification would re-read — hammering the DB through an outage + // while a usable set is already in hand. Stamp the ATTEMPT so a failing + // refresh is retried at most once per `cooldownMs`. + staleRefreshAt = Date.now() + await reload().catch(() => {}) // keep the aging set if the read fails + } + const current = keys + if (current === undefined) throw new Error('JWKS unavailable') // unreachable once reload resolves + return current + } + + return async () => async (protectedHeader, token) => { + const current = await currentKeys() + try { + return await current(protectedHeader, token) + } catch (err) { + if (!(err instanceof joseErrors.JWKSNoMatchingKey) || loadedWithin(cooldownMs)) throw err + await reload() + const refreshed = keys + if (refreshed === undefined) throw err + return await refreshed(protectedHeader, token) + } + } +} + +/** Pull scopes from the standard OAuth JWT claims: `scope` (space-delimited) or `scp` (array/string). */ +function extractScopes(payload: JWTPayload): string[] { + const scope = payload.scope + if (typeof scope === 'string') return scope.split(' ').filter(Boolean) + const scp = (payload as { scp?: unknown }).scp + if (Array.isArray(scp)) return scp.filter((s): s is string => typeof s === 'string') + if (typeof scp === 'string') return scp.split(' ').filter(Boolean) + return [] +} + +/** The confidential client id: `client_id`, else `azp`, else fall back to `sub`. */ +function extractClientId(payload: JWTPayload, sub: string): string { + const clientId = (payload as { client_id?: unknown }).client_id + if (typeof clientId === 'string') return clientId + const azp = (payload as { azp?: unknown }).azp + if (typeof azp === 'string') return azp + return sub +} + +/** + * jose-6-backed OAuthTokenVerifier for MCP's requireBearerAuth. Verifies the + * signature against the AS's signing keys and pins BOTH `iss` and `aud`. JWT-only: + * an opaque (resource-less) token has no valid signature here and fails loudly — + * exactly the guard against the silent-downgrade trap. + * + * Note: the AS mints `aud` as an ARRAY (the `openid` scope adds the userinfo + * endpoint as a second audience). jose's `audience` option accepts array `aud` + * as long as our resource id is a member — so array `aud` verifies correctly. + */ +export function createJwtVerifier(opts: JwtVerifierOptions): OAuthTokenVerifier { + const algorithms = opts.algorithms ?? ['EdDSA', 'RS256', 'ES256'] + + return { + async verifyAccessToken(token: string): Promise { + let payload: JWTPayload + try { + const keys = await opts.keys() + const result = await jwtVerify(token, keys, { + issuer: opts.issuer, + audience: opts.audience, + algorithms, + }) + payload = result.payload + } catch (err) { + if ( + err instanceof joseErrors.JWTExpired || + err instanceof joseErrors.JWTClaimValidationFailed || + err instanceof joseErrors.JWSSignatureVerificationFailed || + err instanceof joseErrors.JWTInvalid || + err instanceof joseErrors.JWSInvalid || + err instanceof joseErrors.JOSENotSupported || + err instanceof joseErrors.JWKSNoMatchingKey || + err instanceof joseErrors.JWKSMultipleMatchingKeys || + err instanceof joseErrors.JOSEAlgNotAllowed + ) { + // Token-shape / claim / signature / alg failures → 401 invalid_token. + // Infra failures (a failed JWKS read) are NOT listed here and propagate + // as 500 — an outage must not read to the client as a bad token. + throw new InvalidTokenError( + err instanceof joseErrors.JOSEError ? `${err.code}: ${err.message}` : 'Invalid token', + ) + } + // Database / key-material failures: surface as 500, not 401, so an infra + // blip isn't reported to the client as a bad token. + throw err + } + + // requireBearerAuth REQUIRES a numeric expiresAt (else it 401s "no expiration"). + if (typeof payload.exp !== 'number') { + throw new InvalidTokenError('Token has no exp claim') + } + + // A legitimate better-auth token always carries `sub`; without it, tenancy + // would resolve grants for an `undefined` subject (fail-closed empty pool, + // but still a malformed token). Reject rather than admit a subject-less JWT. + const sub = payload.sub + if (typeof sub !== 'string' || sub === '') { + throw new InvalidTokenError('Token has no sub claim') + } + + return { + token, + clientId: extractClientId(payload, sub), + scopes: extractScopes(payload), + expiresAt: payload.exp, // seconds — the middleware's presence+expiry check reads this + extra: { sub, iss: payload.iss }, + } + }, + } +} diff --git a/packages/mcp-server/src/cli.ts b/packages/mcp-server/src/cli.ts new file mode 100644 index 0000000..7f1f175 --- /dev/null +++ b/packages/mcp-server/src/cli.ts @@ -0,0 +1,427 @@ +#!/usr/bin/env node +import { realpathSync } from 'node:fs' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { FileConnectionSource, resolveDataDir } from '@1c-odata/mcp/internal' +import { Command } from 'commander' +import { buildAuth } from './auth/better-auth.js' +import { resolveCanonicalUrls } from './auth/config.js' +import { createHttpServer } from './index.js' +import { logger } from './logger.js' +import { type Keyring, loadKeyring } from './store/crypto.js' +import { createDb, type Dialect } from './store/db.js' +import { runAuthMigrations } from './store/migrate.js' +import { countAdmins } from './store/repos.js' +import { readPackageVersion } from './version.js' + +const DEFAULT_PORT = 3000 +const DEFAULT_HOST = '127.0.0.1' + +export interface ServeOptions { + dataDir?: string + insecureStorage?: boolean + port: string + host: string + publicUrl?: string + pgUrl?: string + authDataDir?: string + encKey?: string +} + +interface AdminCreateOptions { + email: string + password: string + name?: string + publicUrl?: string + pgUrl?: string + authDataDir?: string + force?: boolean +} + +interface SetPasswordOptions { + email: string + password: string + publicUrl?: string + pgUrl?: string + authDataDir?: string +} + +/** + * Minimal structural view of better-auth's `$context` — the internal handle the + * break-glass `set-password` drives. `setUserPassword`/`listUsers` (admin plugin) + * require an admin SESSION and 401 on the header-less CLI path, so we instead go + * through the same internal adapter + password hasher those endpoints use. + */ +interface AuthContext { + password: { hash(plain: string): Promise } + internalAdapter: { + findUserByEmail(email: string): Promise<{ user: { id: string } } | null> + findAccounts(userId: string): Promise<{ providerId: string }[]> + updatePassword(userId: string, hashed: string): Promise + createAccount(input: { userId: string; providerId: string; accountId: string; password: string }): Promise + } +} + +/** + * Build the encryption keyring iff a KEK is supplied (flag or env) — this is what + * opts multi-tenancy in on the auth path. Absent → undefined (auth without + * tenancy, the Slice-2 file source). `loadKeyring` throws {@link MissingEncryptionKeyError} + * loudly on a malformed/short key, failing boot rather than corrupting writes. + */ +export function resolveKeyring(env: NodeJS.ProcessEnv, opts: Pick): Keyring | undefined { + const encKey = opts.encKey ?? env.ONEC_MCP_ENC_KEY + if (encKey === undefined || encKey === '') return undefined + // Pass the WHOLE env through and override only the current key (so `--enc-key` + // still wins over `ONEC_MCP_ENC_KEY`). Hand-listing the vars here is what silently + // dropped `ONEC_MCP_ENC_KEYS_PREVIOUS` — with the docs telling operators to rotate, + // that would have stranded every secret sealed under the retired key. loadKeyring + // owns which vars it reads; this function must not duplicate that list. + return loadKeyring({ ...env, ONEC_MCP_ENC_KEY: encKey }) +} + +/** + * The `Host`-header value(s) a client presents for the public origin. The + * transport matches the raw `Host` header by EXACT string, and clients drop a + * default port (`https://x` → `Host: x`, `https://x:8443` → `Host: x:8443`), so + * `URL.host` is the canonical form — plus, when the port is defaulted, the + * explicit `host:defaultPort` form in case a proxy keeps it. Returns `[]` for an + * unparseable URL (the origin is validated for real by `resolveCanonicalUrls`). + */ +export function publicUrlHostVariants(publicUrl: string): string[] { + let url: URL + try { + url = new URL(publicUrl) + } catch { + return [] + } + const variants = [url.host] + if (url.port === '') { + const def = url.protocol === 'https:' ? '443' : url.protocol === 'http:' ? '80' : '' + if (def !== '') variants.push(`${url.hostname}:${def}`) + } + return variants +} + +/** + * `Host` allowlist for the transport's DNS-rebinding guard. The transport matches + * the raw `Host` header by exact string, so entries are raw `Host` values — + * `host` (default port omitted, as clients send it) or `host:port`. + * `ONEC_MCP_ALLOWED_HOSTS` (comma-separated) is an explicit override, respected + * verbatim. Otherwise derive it from the bind address plus the loopback + * aliases a local client may present, AND — when `--public-url` is set — the + * public origin's `Host` form, so a reverse-proxy deployment that forwards the + * original `Host` needs no separate `ONEC_MCP_ALLOWED_HOSTS`. + */ +export function resolveAllowedHosts(env: NodeJS.ProcessEnv, host: string, port: number, publicUrl?: string): string[] { + const override = env.ONEC_MCP_ALLOWED_HOSTS?.trim() + if (override !== undefined && override !== '') { + return override + .split(',') + .map((h) => h.trim()) + .filter((h) => h !== '') + } + // A bare IPv6 literal (e.g. `::1`) appears bracketed in the `Host` header + // (`[::1]:3000`); match that form so the guard doesn't reject legit requests. + const bind = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host + // Include both loopback families: a client may reach the server via either the + // IPv4 or the IPv6 loopback regardless of the bind address (e.g. `--host ::` + // still answers `http://[::1]:`), and each yields a distinct `Host` header. + const hosts = [`${bind}:${port}`, `localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`] + // Behind a proxy the public `Host` (from --public-url) differs from the bind + // address; auto-allow it so the common single-instance proxy case needs no + // ONEC_MCP_ALLOWED_HOSTS. An explicit override above still wins. + if (publicUrl !== undefined && publicUrl !== '') hosts.push(...publicUrlHostVariants(publicUrl)) + return [...new Set(hosts)] +} + +/** + * Parse + validate `--port`; fail early and clearly instead of letting `listen` + * throw late on NaN. Port 0 (OS-assigned ephemeral) is rejected: the bound port + * would differ from the one baked into the DNS-rebinding allowlist, so the guard + * would reject every request. + */ +function parsePort(raw: string): number { + const port = Number(raw) + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`Invalid --port ${JSON.stringify(raw)}: expected an integer in 1..65535`) + } + return port +} + +/** + * Resolve the auth store dialect. Prod: --pg-url / DATABASE_URL → node-postgres. + * Dev: --auth-data-dir / ONEC_MCP_AUTH_DATA_DIR → persistent pglite; absent → + * in-memory pglite (state resets each run — dev/demo only). + */ +function resolveDialect(env: NodeJS.ProcessEnv, opts: ServeOptions): Dialect { + const pg = opts.pgUrl ?? env.DATABASE_URL + if (pg !== undefined && pg !== '') return { kind: 'pg', connectionString: pg } + const dir = opts.authDataDir ?? env.ONEC_MCP_AUTH_DATA_DIR + return dir !== undefined && dir !== '' ? { kind: 'pglite', dataDir: dir } : { kind: 'pglite' } +} + +/** Build the commander program. Exported for unit tests. */ +export function buildProgram(): Command { + const program = new Command() + program + .name('1c-odata-mcp-server') + .description('Streamable HTTP MCP server for 1С:Enterprise OData V3 (read-only)') + .version(readPackageVersion()) + + program + .command('serve') + .description('Run the read-only MCP server over Streamable HTTP') + // Optional + routed through resolveDataDir so the server locates the SAME + // config.json + credentials as the `1c-odata-mcp` CLI: honors + // ONEC_MCP_DATA_DIR and the absolute-path guard, defaulting to the per-OS dir. + .option('--data-dir ', 'data directory for config + credentials (default: per-OS config dir)') + .option('--insecure-storage', 'store passwords in a 0600 file instead of the OS keychain', false) + .option('--port ', 'TCP port to listen on', String(DEFAULT_PORT)) + .option('--host ', 'host/interface to bind', DEFAULT_HOST) + // Auth (Slice 2). Absent --public-url → no-auth server (Slice-1 behavior). + .option('--public-url ', 'external https origin enabling OAuth auth (e.g. https://mcp.example.com)') + .option('--pg-url ', 'Postgres connection string for the auth store (else DATABASE_URL; else pglite)') + .option('--auth-data-dir ', 'persist the pglite auth store here (dev; else in-memory)') + .option( + '--enc-key ', + 'base64 32-byte AES-256 key for DB-backed secret encryption (enables multi-tenancy; else ONEC_MCP_ENC_KEY)', + ) + .action(async (opts: ServeOptions) => { + const dataDir = resolveDataDir(process.env, opts.dataDir) + const insecure = opts.insecureStorage === true + const port = parsePort(opts.port) + const source = new FileConnectionSource({ dataDir, insecure }) + + const publicUrl = opts.publicUrl ?? process.env.ONEC_MCP_PUBLIC_URL + // Fold the public origin's Host into the DNS-rebinding allowlist so a reverse + // proxy forwarding the original Host needs no separate ONEC_MCP_ALLOWED_HOSTS. + const allowedHosts = resolveAllowedHosts(process.env, opts.host, port, publicUrl) + let auth: NonNullable[0]['auth']> | undefined + if (publicUrl !== undefined && publicUrl !== '') { + // `||` not `??`: an empty-string BETTER_AUTH_SECRET (e.g. `${VAR}` with the + // host var unset) must fall through to AUTH_SECRET, not shadow it. + const secret = process.env.BETTER_AUTH_SECRET || process.env.AUTH_SECRET + if (secret === undefined || secret === '') { + throw new Error('BETTER_AUTH_SECRET (or AUTH_SECRET) is required when --public-url enables auth') + } + // Multi-tenancy is opt-in on the auth path: supply a KEK (flag or env) to + // switch from the file source to DB-backed, per-user-scoped bases. Absent + // → auth without tenancy (Slice-2 file source). + const keyring = resolveKeyring(process.env, opts) + const dialect = resolveDialect(process.env, opts) + // Tenancy stores admins, grants, and AES-GCM-encrypted 1С secrets in this + // DB. An in-memory pglite (no --pg-url/DATABASE_URL, no --auth-data-dir) is + // wiped on every restart — silently losing them AND reopening /setup on the + // public URL. Refuse it, mirroring the admin-create/set-password guards. + if (keyring !== undefined && dialect.kind === 'pglite' && dialect.dataDir === undefined) { + throw new Error( + 'Multi-tenancy (--enc-key / ONEC_MCP_ENC_KEY) needs a PERSISTENT auth store, else every ' + + 'restart wipes admins + encrypted base secrets and reopens /setup. ' + + 'Set --pg-url / DATABASE_URL, or --auth-data-dir / ONEC_MCP_AUTH_DATA_DIR.', + ) + } + auth = { + publicUrl, + dialect, + secret, + ...(keyring !== undefined ? { keyring } : {}), + } + } + + const { server, close } = await createHttpServer({ + source, + dataDir, + allowedHosts, + ...(auth !== undefined ? { auth } : {}), + }) + server.on('error', (err: Error) => { + logger.error({ err: err.message }, 'HTTP server error') + // A listen failure (EADDRINUSE / EACCES) must not exit 0 — set a non-zero + // exit code so an orchestrator/CI sees the boot failure. `close()` is + // idempotent, so this is safe alongside the SIGINT/SIGTERM path. + process.exitCode = 1 + void close() + }) + server.listen(port, opts.host, () => { + logger.info( + { host: opts.host, port, dataDir, allowedHosts, auth: auth !== undefined }, + 'MCP HTTP server listening', + ) + }) + // Graceful shutdown: stop accepting connections, then drain the auth store + // (pg pool). Once both close, the event loop empties and the process exits. + const shutdown = (sig: NodeJS.Signals): void => { + logger.info({ sig }, 'shutting down') + server.close(() => { + void close() + }) + // Close only IDLE keep-alive sockets so `server.close` resolves promptly + // (an idle undici keep-alive would otherwise hold it open to the + // orchestrator's finite SIGTERM→SIGKILL window, ~10s under compose). + // Active requests keep their sockets and drain normally — do NOT use + // closeAllConnections(), which would abort an in-flight /setup or admin write. + server.closeIdleConnections() + } + process.once('SIGINT', () => shutdown('SIGINT')) + process.once('SIGTERM', () => shutdown('SIGTERM')) + }) + + program + .command('admin-create') + .description( + 'Bootstrap the FIRST admin user directly in the auth store (header-less seed; no admin session required)', + ) + .requiredOption('--email ', 'admin email') + .requiredOption('--password ', 'admin password') + .option('--name ', 'display name', 'Admin') + .option('--public-url ', 'external https origin (else ONEC_MCP_PUBLIC_URL)') + .option('--pg-url ', 'Postgres connection string for the auth store (else DATABASE_URL; else pglite)') + .option('--auth-data-dir ', 'persist the pglite auth store here (must match `serve`)') + .option('--force', 'create the admin even if one already exists (bypasses the bootstrap-only guard)') + .action(async (opts: AdminCreateOptions) => { + const publicUrl = opts.publicUrl ?? process.env.ONEC_MCP_PUBLIC_URL + if (publicUrl === undefined || publicUrl === '') { + throw new Error('--public-url (or ONEC_MCP_PUBLIC_URL) is required') + } + // `||` not `??`: an empty-string BETTER_AUTH_SECRET must fall through to AUTH_SECRET. + const secret = process.env.BETTER_AUTH_SECRET || process.env.AUTH_SECRET + if (secret === undefined || secret === '') { + throw new Error('BETTER_AUTH_SECRET (or AUTH_SECRET) is required') + } + const dialect = resolveDialect(process.env, { ...opts, port: '', host: '' } as ServeOptions) + // A bootstrapped admin must OUTLIVE this process. An in-memory pglite (no + // --pg-url/DATABASE_URL and no --auth-data-dir/ONEC_MCP_AUTH_DATA_DIR) is + // discarded on close, so the admin would vanish before any `serve` — a silent + // no-op. Require a persistent store and fail loudly instead. + if (dialect.kind === 'pglite' && dialect.dataDir === undefined) { + throw new Error( + 'admin-create needs a PERSISTENT auth store, else the admin is lost when this process exits. ' + + 'Pass --pg-url (or DATABASE_URL), or --auth-data-dir (or ONEC_MCP_AUTH_DATA_DIR) matching your `serve`.', + ) + } + const dbHandle = createDb(dialect) + try { + await runAuthMigrations(dbHandle) + // Bootstrap-only by contract (the README calls this the CLI equivalent of the + // one-time /setup wizard, which self-closes once an admin exists). This call + // bypasses every session check, so without a gate a repeat run would silently + // mint extra admins. The sanctioned way to add an admin afterwards is the + // /admin panel: an existing admin promotes a user. + // + // Scope: this is check-then-act, so it closes the REPEAT-RUN case, not two + // invocations racing against one empty Postgres — both would read 0 and both + // create. Preventing that needs DB-level serialization (advisory lock, or a + // consume-once sentinel like the /setup token); an operator running the + // bootstrap command twice at once is not the case this guard is aimed at. + if (opts.force !== true && (await countAdmins(dbHandle.db)) > 0) { + throw new Error( + 'An administrator already exists — admin-create is for the initial bootstrap only. ' + + 'Promote a user from the /admin panel instead, or pass --force to add one anyway.', + ) + } + const auth = buildAuth({ urls: resolveCanonicalUrls(publicUrl), db: dbHandle.db, secret }) + // Header-less call: session is null and (ctx.request || ctx.headers) is + // falsy, so both the create-check and the set-role check are skipped — + // the ONLY sanctioned first-admin seed (better-auth has no CLI for this). + const { user } = await auth.api.createUser({ + body: { email: opts.email, password: opts.password, name: opts.name ?? 'Admin', role: 'admin' }, + }) + logger.info({ id: user.id, email: user.email }, 'admin user created') + } finally { + await dbHandle.close() + } + }) + + program + .command('set-password') + .description( + "Break-glass: reset an existing user's password directly in the auth store (no session; for a forgotten password)", + ) + .requiredOption('--email ', 'email of the user to update') + .requiredOption('--password ', 'the new password') + .option('--public-url ', 'external https origin (else ONEC_MCP_PUBLIC_URL)') + .option('--pg-url ', 'Postgres connection string for the auth store (else DATABASE_URL; else pglite)') + .option('--auth-data-dir ', 'persist the pglite auth store here (must match `serve`)') + .action(async (opts: SetPasswordOptions) => { + const publicUrl = opts.publicUrl ?? process.env.ONEC_MCP_PUBLIC_URL + if (publicUrl === undefined || publicUrl === '') { + throw new Error('--public-url (or ONEC_MCP_PUBLIC_URL) is required') + } + // `||` not `??`: an empty-string BETTER_AUTH_SECRET must fall through to AUTH_SECRET. + const secret = process.env.BETTER_AUTH_SECRET || process.env.AUTH_SECRET + if (secret === undefined || secret === '') { + throw new Error('BETTER_AUTH_SECRET (or AUTH_SECRET) is required') + } + const dialect = resolveDialect(process.env, { ...opts, port: '', host: '' } as ServeOptions) + // Same guard as admin-create: an in-memory pglite is discarded on close, so a + // password reset against it would be a silent no-op. Require a PERSISTENT store. + if (dialect.kind === 'pglite' && dialect.dataDir === undefined) { + throw new Error( + 'set-password needs a PERSISTENT auth store (the SAME one your `serve` uses). ' + + 'Pass --pg-url (or DATABASE_URL), or --auth-data-dir (or ONEC_MCP_AUTH_DATA_DIR).', + ) + } + const dbHandle = createDb(dialect) + try { + await runAuthMigrations(dbHandle) + const auth = buildAuth({ urls: resolveCanonicalUrls(publicUrl), db: dbHandle.db, secret }) + // The admin plugin's setUserPassword/listUsers require an admin SESSION and + // 401 on this header-less path, so drive the SAME internal adapter + hasher + // they use under the hood. `$context` is a promise on the better-auth + // instance; it is not part of our exported `Auth` alias (the inferred type is + // not .d.ts-portable), so reach it through a narrowly-typed cast. + const ctx = (await (auth as unknown as { $context: Promise }).$context) satisfies AuthContext + const found = await ctx.internalAdapter.findUserByEmail(opts.email) + if (found === null) { + // Fail loudly — never create a user here (that is admin-create's job). + throw new Error(`No user with email ${JSON.stringify(opts.email)} in the auth store.`) + } + const userId = found.user.id + const hashed = await ctx.password.hash(opts.password) + const accounts = await ctx.internalAdapter.findAccounts(userId) + const credential = accounts.find((a) => a.providerId === 'credential') + if (credential !== undefined) { + await ctx.internalAdapter.updatePassword(userId, hashed) + } else { + // No password account yet (e.g. an SSO-only user) — create the credential. + await ctx.internalAdapter.createAccount({ + userId, + providerId: 'credential', + accountId: userId, + password: hashed, + }) + } + logger.info({ id: userId, email: opts.email }, 'password updated') + } finally { + await dbHandle.close() + } + }) + + return program +} + +// Run when invoked as the bin. Canonicalize argv[1] through realpath — pnpm +// exposes the package as a symlink, so argv[1] and import.meta.url otherwise +// disagree even for the same file. +function realArgvUrl(): string | undefined { + const raw = process.argv[1] + if (raw === undefined) return undefined + try { + return pathToFileURL(realpathSync(raw)).href + } catch { + try { + return pathToFileURL(resolve(raw)).href + } catch { + return undefined + } + } +} + +if (realArgvUrl() === import.meta.url) { + buildProgram() + .parseAsync(process.argv) + .catch((err: unknown) => { + logger.error({ err: err instanceof Error ? err.message : String(err) }, 'fatal') + process.exitCode = 1 + }) +} diff --git a/packages/mcp-server/src/http/account/router.ts b/packages/mcp-server/src/http/account/router.ts new file mode 100644 index 0000000..8ecefe0 --- /dev/null +++ b/packages/mcp-server/src/http/account/router.ts @@ -0,0 +1,119 @@ +// src/http/account/router.ts +// +// The self-service surface for EVERY signed-in role — deliberately outside the +// admin gate. A plain user has no other web page: their password is provisioned +// by an admin, so /account is where they rotate it. It also owns sign-out (the +// nav's form posts here from both admin and account pages). +// +// Reuses the admin panel's security kit verbatim: `adminCsp` (locked CSP), +// `adminCsrf` (same-origin check on unsafe methods), the flash/OOB error +// contract, and the app shell (nav sections collapse for non-admins — see +// appShell). Mounted on the tenancy path next to /admin. +import { fromNodeHeaders } from 'better-auth/node' +import express, { type ErrorRequestHandler, type Request, type Response, type Router } from 'express' +import type { Auth } from '../../auth/better-auth.js' +import { logger } from '../../logger.js' +import { adminCsp, adminCsrf, isHtmx, resolveSessionOr401 } from '../admin/middleware.js' +import { flash, page } from '../admin/views.js' + +export interface CreateAccountRouterOptions { + auth: Auth + /** Canonical public origin — the same-origin CSRF target. */ + publicUrl: string +} + +/** Same async-handler adapter contract as the admin/setup routers. */ +function wrap(handler: (req: Request, res: Response) => Promise) { + return (req: Request, res: Response, next: (err?: unknown) => void): void => { + handler(req, res).catch(next) + } +} + +export function createAccountRouter(opts: CreateAccountRouterOptions): Router { + const { auth } = opts + const router = express.Router() + router.use(adminCsp) + router.use(express.urlencoded({ extended: false })) + router.use(adminCsrf(opts.publicUrl)) + + // Sign-out is registered BEFORE the session gate: it must work even when the + // session just expired (the gate would bounce to /sign-in and strand the POST). + router.post( + '/sign-out', + wrap(async (req, res) => { + try { + const out = await auth.api.signOut({ headers: fromNodeHeaders(req.headers), returnHeaders: true }) + // Forward better-auth's cookie-clearing headers so the browser drops the session. + const cookies = out.headers.getSetCookie() + if (cookies.length > 0) res.setHeader('Set-Cookie', cookies) + } catch { + // No/invalid session — nothing to clear; landing on sign-in is right either way. + } + res.redirect(303, '/sign-in') + }), + ) + + // Gate: ANY authenticated user (no role check — this is the self-service page). + // The SAME resolver /admin uses, so the 401/redirect + locals behavior can't + // drift between the two surfaces. Stash the role for the page's own render. + router.use((req, res, next) => { + resolveSessionOr401(auth, req, res) + .then((session) => { + if (session === null) return // unauthenticated response already sent + res.locals.actorRole = session.role ?? 'user' + next() + }) + .catch(next) + }) + + router.get('/', (_req, res) => { + const email = String((res.locals as { navUser?: { email: string } }).navUser?.email ?? '') + const role = String((res.locals as { actorRole?: string }).actorRole ?? 'user') + page(res, 'account_page', { email, role }, 'Account', 'account') + }) + + router.post( + '/password', + wrap(async (req, res) => { + const current = req.body.current + const password = req.body.password + if (typeof current !== 'string' || current === '' || typeof password !== 'string' || password.length < 8) { + flash(res, 400, 'Both passwords are required; the new one must be at least 8 characters.') + return + } + try { + // revokeOtherSessions rotates the CURRENT session too: better-auth issues a + // fresh session cookie and invalidates the old token. We MUST forward that + // Set-Cookie, or the browser keeps the now-dead cookie and is bounced to + // sign-in on its next request. Same header-forwarding contract as sign-out. + const out = await auth.api.changePassword({ + headers: fromNodeHeaders(req.headers), + body: { currentPassword: current, newPassword: password, revokeOtherSessions: true }, + returnHeaders: true, + }) + const cookies = out.headers.getSetCookie() + if (cookies.length > 0) res.setHeader('Set-Cookie', cookies) + } catch (err) { + // Wrong current password / policy refusal — redacted, never echo details. + logger.warn({ err: err instanceof Error ? err.message : String(err) }, 'account password change failed') + flash(res, 400, 'Password change failed — check the current password.') + return + } + flash(res, 200, 'Password changed. Your other sessions were signed out.', 'ok') + }), + ) + + router.use(accountErrorHandler) + return router +} + +/** Terminal error handler — same redaction contract as the admin router's. */ +const accountErrorHandler: ErrorRequestHandler = (err, req, res, _next) => { + logger.error({ err: err instanceof Error ? err.message : String(err), path: req.baseUrl }, 'account handler failed') + if (res.headersSent) return + if (isHtmx(req)) { + flash(res, 500, 'Internal error — the operation did not complete.') + return + } + res.status(500).type('html').send('

    Internal error — the operation did not complete.

    ') +} diff --git a/packages/mcp-server/src/http/admin/admin-js-asset.ts b/packages/mcp-server/src/http/admin/admin-js-asset.ts new file mode 100644 index 0000000..f4a3b62 --- /dev/null +++ b/packages/mcp-server/src/http/admin/admin-js-asset.ts @@ -0,0 +1,170 @@ +// src/http/admin/admin-js-asset.ts +/** + * The panel's own client helpers, served same-origin at /admin/assets/admin.js + * (the admin CSP is `script-src 'self'` — no inline scripts). Delegated click + * handlers keyed on data attributes, so htmx-swapped fragments need no re-wiring: + * + * data-gen-password="" — fill the referenced input with a fresh + * random password (crypto.getRandomValues, rejection-sampled — no modulo + * bias; ~20 chars over a 70-symbol alphabet ≈ 122 bits). Generated client- + * side so the value only ever crosses the wire inside the form submit. + * data-copy="" — copy the referenced input's value to the clipboard + * (navigator.clipboard on secure contexts, execCommand fallback for plain + * HTTP over a LAN) with a transient "✓ copied" label. + * data-dialog-close="" — close the referenced (drawer Cancel, + * confirm-modal Cancel, the drawer's × button). + * + * Beyond the delegated clicks it wires two hypermedia behaviours to the shell's + * persistent dialogs (both live OUTSIDE #main, so htmx content swaps never replace + * them): + * - Drawer follow: the right-side drawer opens when #drawer-body receives content + * (an edit/new/password form htmx-swapped in) and closes when it is emptied (a + * successful save sends an OOB empty #drawer-body). Visibility simply tracks + * whether the body has content — no per-trigger open/close plumbing. + * - Styled confirm: htmx:confirm is intercepted so hx-confirm prompts render in + * the centered #confirm modal instead of the browser's native confirm(), then + * issueRequest() resumes the original request on OK. + */ +export const ADMIN_JS = `(function () { + 'use strict'; + var ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#%^&*-_=+'; + var LENGTH = 20; + function generate() { + var out = ''; + // Rejection sampling: only accept values below the largest multiple of the + // alphabet size, so every character is uniformly likely (no modulo bias). + var max = Math.floor(4294967296 / ALPHABET.length) * ALPHABET.length; + while (out.length < LENGTH) { + var buf = new Uint32Array(LENGTH); + crypto.getRandomValues(buf); + for (var i = 0; i < buf.length && out.length < LENGTH; i++) { + if (buf[i] < max) out += ALPHABET[buf[i] % ALPHABET.length]; + } + } + return out; + } + function copyText(input, done) { + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(input.value).then(done, function () {}); + return; + } + // Plain-HTTP fallback (LAN deployments): select + the legacy copy command. + input.focus(); + input.select(); + try { document.execCommand('copy'); done(); } catch (e) { /* leave selected for manual copy */ } + } + // Resolve a selector taken from a data-* attribute safely. Our templates only + // emit '#id' selectors; getElementById needs no CSS escaping and never throws on + // an id with odd characters (and matches the literal id, which is what we want). + // Anything else goes through querySelector in a try/catch so a malformed selector + // can't throw and break the whole delegated click handler. + function pick(sel) { + if (!sel) return null; + if (sel.charAt(0) === '#' && sel.indexOf(' ') === -1) return document.getElementById(sel.slice(1)); + try { return document.querySelector(sel); } catch (e) { return null; } + } + document.addEventListener('click', function (e) { + var gen = e.target.closest('[data-gen-password]'); + if (gen) { + var target = pick(gen.getAttribute('data-gen-password')); + if (target) { + target.type = 'text'; // show what will be copied/submitted + target.value = generate(); + } + return; + } + var copy = e.target.closest('[data-copy]'); + if (copy) { + var src = pick(copy.getAttribute('data-copy')); + if (!src) return; + copyText(src, function () { + var prev = copy.textContent; + copy.textContent = '✓ copied'; + setTimeout(function () { copy.textContent = prev; }, 1500); + }); + return; + } + var closer = e.target.closest('[data-dialog-close]'); + if (closer) closeDialog(pick(closer.getAttribute('data-dialog-close'))); + }); + + // Open a dialog. Prefer the native modal (backdrop + focus containment); on an + // engine without support, fall back to the open attribute the CSS + // already keys on — degraded but functional, and never throws (a bare showModal() + // TypeError would abort admin.js and kill the copy/gen/confirm handlers too). + function openDialog(dlg) { + if (!dlg) return; + if (typeof dlg.showModal === 'function') dlg.showModal(); + else dlg.setAttribute('open', ''); + } + // Close a dialog and, for the drawer, reset its body so a dismissed form can't + // linger behind the next open. Done imperatively (not via the 'close' event) so + // it is deterministic across engines — Cancel / × / backdrop all route here. + function closeDialog(dlg) { + if (!dlg) return; + if (typeof dlg.close === 'function') dlg.close(); + else dlg.removeAttribute('open'); // mirror of openDialog's fallback + if (dlg.id === 'drawer') { + var body = document.getElementById('drawer-body'); + if (body) body.innerHTML = ''; + } + } + + // ── Right-side drawer: visibility follows #drawer-body's content ── + function syncDrawer() { + var drawer = document.getElementById('drawer'); + var body = document.getElementById('drawer-body'); + if (!drawer || !body) return; + var hasContent = body.children.length > 0; + var isOpen = drawer.hasAttribute('open'); // true for both native .open and the fallback + if (hasContent && !isOpen) openDialog(drawer); + else if (!hasContent && isOpen) closeDialog(drawer); + } + // htmx settles the main swap and any OOB swaps; recheck after both. + document.body.addEventListener('htmx:afterSwap', syncDrawer); + document.body.addEventListener('htmx:oobAfterSwap', syncDrawer); + + function wireDialog(id) { + var dlg = document.getElementById(id); + if (!dlg) return; + // A click on the ::backdrop registers as a click on the itself + // (content sits in child nodes) — treat it as dismiss. + dlg.addEventListener('click', function (e) { if (e.target === dlg) closeDialog(dlg); }); + return dlg; + } + var drawer = wireDialog('drawer'); + // Backstop for the native 'close' event (e.g. Esc closes a without + // routing through closeDialog): clear the body there too. + if (drawer) drawer.addEventListener('close', function () { + var body = document.getElementById('drawer-body'); + if (body) body.innerHTML = ''; + }); + wireDialog('confirm'); + + // ── Styled confirm: intercept htmx:confirm so hx-confirm uses our modal ── + document.body.addEventListener('htmx:confirm', function (e) { + var question = e.detail.question; // null when the element has no hx-confirm + if (!question) return; // nothing to confirm → let htmx proceed normally + e.preventDefault(); // defer the request until the user answers + var modal = document.getElementById('confirm'); + var msg = document.getElementById('confirm-msg'); + var ok = document.getElementById('confirm-ok'); + if (!modal || !msg || !ok) { + // Confirm markup missing (the shell always renders it, so this shouldn't + // happen): fall back to the browser's NATIVE confirm rather than firing a + // destructive request unconfirmed. + if (window.confirm(question)) e.detail.issueRequest(true); + return; + } + msg.textContent = question; + // Replace the OK button to drop any handler bound for a previous prompt. + var fresh = ok.cloneNode(true); + ok.parentNode.replaceChild(fresh, ok); + fresh.addEventListener('click', function () { + closeDialog(modal); + e.detail.issueRequest(true); // true = skip re-confirming, run the request now + }); + openDialog(modal); + }); +})(); +` diff --git a/packages/mcp-server/src/http/admin/bases.ts b/packages/mcp-server/src/http/admin/bases.ts new file mode 100644 index 0000000..66a151f --- /dev/null +++ b/packages/mcp-server/src/http/admin/bases.ts @@ -0,0 +1,399 @@ +// src/http/admin/bases.ts + +import type { StoredConnection } from '@1c-odata/mcp/internal' +import { assertValidConnectionName, verifyConnectivity } from '@1c-odata/mcp/internal' +import type { Request, Response } from 'express' +import { decrypt, encrypt } from '../../store/crypto.js' +import { BaseRepo, SecretRepo } from '../../store/repos.js' +import type { AdminDeps } from './router.js' +import { drawerFormError, partial, render } from './views.js' + +/** Sentinel: a CREATE lost the uniqueness race inside the save transaction (rolled back). */ +class DuplicateBaseError extends Error { + constructor() { + super('base name already exists') + } +} + +/** Coarse redaction: verifyConnectivity errors may echo a URL; keep only the class + status hint. */ +function redact(err: unknown): string { + const msg = err instanceof Error ? err.message : String(err) + return msg.replace(/https?:\/\/[^\s"']+/g, '').slice(0, 200) +} + +/** Auth-ish $metadata errors (401/403, or "unauthorized"/"forbidden"/"credential"/"password" wording) → auth_failed; anything else → unreachable. */ +export function classifyProbe(err: unknown): { status: 'auth_failed' | 'unreachable'; message: string } { + const msg = err instanceof Error ? err.message : String(err) + // Word tokens are matched as prefixes/substrings, NOT `\b…\b`: a trailing word + // boundary fails mid-word, so `\bunauthor\b` misses "Unauthorized" and + // `\bcredential\b` misses "credentials" — both common. Keep `\b…\b` only around + // the numeric codes so "401" doesn't match inside e.g. "14013". + const authy = /\b(?:401|403)\b|unauthor|forbidden|credential|password/i.test(msg) + return { status: authy ? 'auth_failed' : 'unreachable', message: redact(err) } +} + +/** + * Re-render the form with an error. Never echoes the password back into the DOM. + * ALWAYS renders through the OOB wrapper targeting the stable #drawer-body: neither + * form's own swap target can host an error re-render (the edit form submits + * hx-swap="none", the create form appends into #bases-tbody), and the error must + * show INSIDE the open drawer (a #flash toast is hidden behind the dialog's top + * layer). The mode flag is explicit — a create error must re-render a CREATE form + * even though the typed `name` is present (see the _base_form template note). + */ +function reform(res: Response, body: Record, error: string, editName?: string): void { + const { password: _pw, ...safe } = body + drawerFormError(res, '_base_form', { + ...safe, + // The mode flag is derived from the ROUTE, never from the body: `...safe` + // would otherwise let a tampered `edit` field in a create POST flip the + // re-render into an hx-put edit form aimed at the existing base. + edit: editName !== undefined, + ...(editName !== undefined ? { name: editName } : {}), + error, + }) +} + +/** A well-formed IANA zone name, checked exactly as `@1c-odata/client`'s validateConnection does. */ +function isValidTimezone(tz: string): boolean { + if (tz === '') return false + try { + new Intl.DateTimeFormat('en-US', { timeZone: tz }) + return true + } catch { + return false + } +} + +/** Resolve the stored secret for `name` back to plaintext, or '' when absent/undecryptable. */ +async function storedPassword(deps: AdminDeps, name: string): Promise { + const sealed = await deps.secretRepo.get(name) + if (!sealed) return '' + try { + return decrypt(deps.keyring, name, sealed) + } catch { + return '' + } +} + +/** + * Drop any embedded `user:pass@` before persisting a base URL. A base password + * belongs in the sealed secret, never in the stored `base_url` — a userinfo URL + * would both leak the password (list view / list_connections) and be rejected by + * ConnectionPool. Mirrors verifyConnectivity's internal stripping. + */ +function stripUserinfo(url: string): string { + try { + const u = new URL(url) + if (u.username === '' && u.password === '') return url + u.username = '' + u.password = '' + return u.toString() + } catch { + return url // not parseable — leave as-is; name/url validation elsewhere handles it + } +} + +/** + * Cloud instance-metadata endpoints, which a probe must never reach: they answer + * unauthenticated to anything running on the host and hand out cloud credentials, so + * an admin pointing a "base" at one turns the probe into an SSRF exfiltration + * channel. Entries are the CANONICAL spelling of each address — {@link canonicalHost} + * folds the equivalent spellings onto these before the lookup, so one entry covers + * every way of writing its address. + * + * Deliberately a POINTED denylist, not an allowlist or a private-range block: + * admins legitimately host 1С on RFC1918 addresses and internal DNS names, so + * blanket-blocking internal targets would break the product's normal case. This only + * removes the endpoints that are never a 1С base under any topology. + */ +const METADATA_HOSTS = new Set([ + '169.254.169.254', // AWS IMDS / Azure IMDS / GCP / OpenStack / Oracle + 'fd00:ec2::254', // AWS IMDS over IPv6 + 'metadata.google.internal', // GCP + 'metadata', // GCP's documented short alias (resolves via the GCE search domain) +]) + +/** An IPv4-mapped IPv6 host as the WHATWG parser serializes it: `::ffff:a9fe:a9fe`. */ +const V4_MAPPED_HEX = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/ +/** The dotted spelling of the same, in case a host reaches us un-normalized: `::ffff:169.254.169.254`. */ +const V4_MAPPED_DOTTED = /^::ffff:((?:\d{1,3}\.){3}\d{1,3})$/ + +/** + * The URL host reduced to ONE spelling per address, so a single denylist entry can + * match every way of writing it. The WHATWG parser already folds most variants for + * us — decimal/octal IPv4 (`http://2852039166/`), an IPv4 trailing dot, IPv6 + * zero-expansion, and case — but two survive and are handled here: + * - a DNS name's root label (`metadata.google.internal.`), which the parser keeps, and + * - an IPv4-mapped IPv6 literal (`[::ffff:169.254.169.254]` → `::ffff:a9fe:a9fe`), + * which the kernel routes to the plain IPv4 address, so it must fold to it here. + */ +function canonicalHost(url: string): string | undefined { + let host: string + try { + // `hostname` keeps IPv6 in brackets — strip them so a literal can match. + host = new URL(url).hostname.toLowerCase().replace(/^\[|\]$/g, '') + } catch { + return undefined // unparseable — the connectivity probe reports it far better + } + host = host.replace(/\.$/, '') // drop the DNS root label + const hex = V4_MAPPED_HEX.exec(host) + if (hex?.[1] !== undefined && hex[2] !== undefined) { + const hi = Number.parseInt(hex[1], 16) + const lo = Number.parseInt(hex[2], 16) + return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}` + } + const dotted = V4_MAPPED_DOTTED.exec(host) + return dotted?.[1] ?? host +} + +/** + * Reject a probe aimed at a cloud-metadata endpoint, returning the refusal message + * (or undefined when the target is fine). Applied to the RESOLVED credential's URL, + * so it covers both a caller-typed URL and a reused stored one. + * + * Not SSRF-proof by design: a host that merely RESOLVES to a metadata IP (a DNS name + * an admin controls) is not caught — that would need resolution plus a + * rebinding-safe re-check at connect time. The caller is an authenticated admin who + * can already read the store, so this is defence-in-depth against misconfiguration + * and casual abuse, not a hard boundary. What it does guarantee is that every + * *spelling* of a listed address is refused, not just the canonical one. + */ +function blockedProbeTarget(url: string): string | undefined { + const host = canonicalHost(url) + return host !== undefined && METADATA_HOSTS.has(host) + ? `Refusing to probe ${host} — cloud instance-metadata endpoints are not valid 1С bases.` + : undefined +} + +type ProbeCredential = { baseUrl: string; login: string; password: string } + +/** + * Resolve the (baseUrl, login, password) to probe. A typed password always + * verifies against the request-supplied pair. A BLANK password reuses the base's + * stored secret ONLY when the request's baseUrl+login still match the stored + * descriptor — so a reused secret can never be sent to a caller-changed target, + * which would exfiltrate a credential the panel is designed never to reveal. + * Changing the URL/login therefore requires re-entering the password. + */ +async function resolveProbeCredential( + deps: AdminDeps, + name: string, + reqBaseUrl: string, + reqLogin: string, + reqPassword: string, +): Promise { + if (reqPassword !== '') return { baseUrl: reqBaseUrl, login: reqLogin, password: reqPassword } + if (name === '') return { error: 'Password required.' } + const stored = await deps.baseRepo.get(name) + const secret = await storedPassword(deps, name) + if (stored === undefined || secret === '') return { error: 'Password required.' } + if (reqBaseUrl !== stored.baseUrl || reqLogin !== stored.login) { + return { error: 'Re-enter the password to verify a changed URL or login.' } + } + // Reuse the stored secret against the base's OWN stored pair — never the request's. + return { baseUrl: stored.baseUrl, login: stored.login, password: secret } +} + +/** POST /admin/bases/verify — probe only, no persistence. */ +export async function verifyBase(req: Request, res: Response, deps: AdminDeps): Promise { + const baseUrl = String(req.body.baseUrl ?? '').trim() + const login = String(req.body.login ?? '').trim() + const reqPassword = String(req.body.password ?? '') + const name = typeof req.body.name === 'string' ? req.body.name : '' + const cred = await resolveProbeCredential(deps, name, baseUrl, login, reqPassword) + if ('error' in cred) { + partial(res, '_verify_result', { ok: false, error: cred.error }) + return + } + const blocked = blockedProbeTarget(cred.baseUrl) + if (blocked !== undefined) { + partial(res, '_verify_result', { ok: false, error: blocked }) + return + } + try { + await verifyConnectivity(cred) + partial(res, '_verify_result', { ok: true }) + } catch (err) { + partial(res, '_verify_result', { ok: false, error: redact(err) }) + } +} + +/** + * The free, local-only form validations as one gate: the connection-name shape and + * the IANA `serverTimezone` (REQUIRED with no default per CLAUDE.md — a wrong or + * blank zone silently shifts DateTime parsing). Returns the refusal message, or + * undefined when the input is well-formed. Both run before any query or probe. + */ +function validateBaseForm(name: string, serverTimezone: string): string | undefined { + try { + assertValidConnectionName(name) + } catch { + return `Invalid base name "${name}" — must start with an ASCII letter or digit, then letters, digits, hyphens or underscores.` + } + if (!isValidTimezone(serverTimezone)) { + return `Invalid server timezone "${serverTimezone}" — use an IANA zone (e.g. "Europe/Moscow").` + } + return undefined +} + +/** + * The whole pre-persist gate: resolve which credential to probe, refuse a + * cloud-metadata target, then actually verify connectivity. Returns the refusal + * message, or undefined once the pair is verified — saving must NEVER persist an + * unverified credential, so every early exit here aborts the save. + */ +async function verifyBeforeSave( + deps: AdminDeps, + name: string, + baseUrl: string, + login: string, + password: string, +): Promise { + // A typed password verifies the request pair; a blank field reuses the stored + // secret ONLY against the base's own stored URL+login (never a request-changed + // target — that would exfiltrate it). + const cred = await resolveProbeCredential(deps, name, baseUrl, login, password) + if ('error' in cred) { + return cred.error === 'Password required.' ? 'Password required to verify before saving.' : cred.error + } + // Refuse a cloud-metadata target BEFORE the probe. Saving always verifies first, + // so this also keeps such a URL from entering the store THROUGH THE PANEL — but it + // is not retroactive: a row written out-of-band (direct SQL) or before this guard + // existed is still probed by the health job on its interval. + const blocked = blockedProbeTarget(cred.baseUrl) + if (blocked !== undefined) return blocked + try { + await verifyConnectivity(cred) + } catch (err) { + return `Verification failed: ${redact(err)}` + } + return undefined +} + +/** Shared create/update. `editName` set on PUT (name is the path param, immutable). */ +async function saveBase(req: Request, res: Response, deps: AdminDeps, editName?: string): Promise { + const name = editName ?? String(req.body.name ?? '') + const baseUrl = String(req.body.baseUrl ?? '').trim() + const login = String(req.body.login ?? '').trim() + const password = String(req.body.password ?? '') + const serverTimezone = String(req.body.serverTimezone ?? '').trim() + const label = String(req.body.label ?? '').trim() + + const invalid = validateBaseForm(name, serverTimezone) + if (invalid !== undefined) { + reform(res, req.body, invalid, editName) + return + } + + // A CREATE must never silently overwrite an existing base: upsert() would + // replace its URL/login/secret and the swap would append a DUPLICATE row (same + // DOM id) to the table. This pre-check (after the free local validations, before + // the network probe) catches the common case with a friendly error; the + // transaction below re-enforces it atomically for the concurrent-create race. + const duplicateError = `Base "${name}" already exists — use Edit on its row instead.` + if (editName === undefined && (await deps.baseRepo.get(name)) !== undefined) { + reform(res, req.body, duplicateError) + return + } + + // VERIFY FIRST — never persist an unverified credential pair. + const failure = await verifyBeforeSave(deps, name, baseUrl, login, password) + if (failure !== undefined) { + reform(res, req.body, failure, editName) + return + } + + // Persist descriptor + (only if a new password was typed) the sealed secret, + // atomically. Ordering: base row before secret row (FK). Strip any embedded + // userinfo so the base password never lands in the stored base_url — and reuse + // the SAME stripped value for the success-row render so the DOM matches the DB. + const cleanBaseUrl = stripUserinfo(baseUrl) + // Carry an existing DataShape override across an edit. The form never renders or + // submits `shape`, so building the descriptor without it would upsert `shape: null` + // and silently drop an override set out-of-band (SQL / import). A CREATE has no + // prior row, hence no shape to preserve. + const descriptor: StoredConnection = { + baseUrl: cleanBaseUrl, + login, + serverTimezone, + ...(label !== '' ? { label } : {}), + } + try { + await deps.db.transaction(async (tx) => { + // drizzle's transaction handle is a valid query executor for the repos' insert + // upserts, but its type lacks the `$client` field of the top-level `AuthDb` + // union, so cast through `unknown`. Ordering: base row before secret row (FK). + const txDb = tx as unknown as typeof deps.db + const baseRepo = new BaseRepo(txDb) + if (editName !== undefined) { + // Carry an existing DataShape override across the edit: the form never renders + // or submits `shape`, so upserting the form's descriptor alone would write + // shape:null and silently drop an override set out-of-band (SQL / import). + // A CREATE has no prior row, hence nothing to carry. + // + // Still read-then-write: a plain SELECT takes no row lock, so under READ + // COMMITTED a `shape` committed by another writer between this read and the + // upsert below is lost. Closing that would need `SELECT … FOR UPDATE`, which + // buys nothing today — `shape` has no writer in the product (no UI field), so + // the only "concurrent writer" is a human running SQL during an edit. + const existingShape = (await baseRepo.get(name))?.shape + await baseRepo.upsert(name, { + ...descriptor, + ...(existingShape !== undefined ? { shape: existingShape } : {}), + }) + } else if (!(await baseRepo.create(name, descriptor))) { + // Concurrent create won the race after our pre-check — roll back rather + // than overwrite (the atomic enforcement of the invariant above). + throw new DuplicateBaseError() + } + if (password !== '') { + await new SecretRepo(txDb).put(name, encrypt(deps.keyring, name, password)) + } + }) + } catch (err) { + if (err instanceof DuplicateBaseError) { + reform(res, req.body, duplicateError) + return + } + throw err + } + await deps.healthRepo.upsert(name, 'ok') // seed green; the job re-probes on its interval + + // Evict the process-global cache so the next tool call re-fetches $metadata with + // the new URL/credentials. MUST be the SHARED pool, not a ScopedPool. + deps.sharedPool.refresh(name) + + // A secret necessarily exists here: create (and edit-with-password) just stored + // one, and the edit-with-blank path only passed the verify gate by successfully + // loading the stored secret above — no extra query needed. health is 'ok': we + // only reach persistence after verifyConnectivity resolved, and we just seeded it. + const base = { name, baseUrl: cleanBaseUrl, login, serverTimezone, label, hasSecret: true, health: 'ok' } + // On success the drawer closes (OOB) and a toast shows, for BOTH new and edit. + const chrome = render('_drawer_close') + render('_flash', { kind: 'ok', message: `Base "${name}" saved.` }) + if (editName !== undefined) { + // Edit form submits hx-swap="none" — the row updates via an OOB fragment. + res.type('html').send(renderOob(name, base) + chrome) + return + } + // Create form appends the row (beforeend); the empty-state placeholder hides + // itself via CSS (:only-child) once a real row exists. + res.type('html').send(render('_base_row', { base }) + chrome) +} + +/** Out-of-band row replacement for edits (the form target is #bases-tbody with hx-swap=none). */ +function renderOob(name: string, base: Record): string { + const row = render('_base_row', { base }) + return row.replace(' (req: Request, res: Response) => saveBase(req, res, deps) +export const updateBase = (deps: AdminDeps) => (req: Request, res: Response) => + saveBase(req, res, deps, String(req.params.name)) + +export async function deleteBase(req: Request, res: Response, deps: AdminDeps): Promise { + const name = String(req.params.name) + await deps.baseRepo.delete(name) // cascades to secret/grants/health + deps.sharedPool.refresh(name) + res.status(200).type('html').send('') // outerHTML swap removes the row +} diff --git a/packages/mcp-server/src/http/admin/dashboard.ts b/packages/mcp-server/src/http/admin/dashboard.ts new file mode 100644 index 0000000..34c95ec --- /dev/null +++ b/packages/mcp-server/src/http/admin/dashboard.ts @@ -0,0 +1,62 @@ +// src/http/admin/dashboard.ts +import type { Request, Response } from 'express' +import type { AdminDeps } from './router.js' +import { adminServerInfo } from './server-info.js' +import { page, partial } from './views.js' + +interface HealthRow { + baseName: string + status: string + lastCheck: string + error: string + /** Being re-probed in the current sweep (lastCheck predates the sweep start) → spinner. */ + checking: boolean +} + +/** + * Every base joined to its latest health row (a base with no row yet shows + * `unknown`). `checking` marks a base still being probed in the active on-demand + * sweep: `optimisticAll` (the button's own response) shows every base as checking + * up front; the follow-up polls derive it per base from `since` (the sweep start) — + * a base whose last probe predates it, or that has no row yet, is still checking. + * Listing BASES (not health rows) makes a never-probed base visible with its spinner. + */ +async function buildRows(deps: AdminDeps, since: Date | null, optimisticAll: boolean): Promise { + const [bases, health] = await Promise.all([deps.baseRepo.list(), deps.healthRepo.list()]) + const byName = new Map(health.map((h) => [h.baseName, h])) + return bases.map((b) => { + const h = byName.get(b.name) + return { + baseName: b.name, + status: h?.status ?? 'unknown', + lastCheck: h !== undefined ? h.lastCheck.toISOString().replace('T', ' ').slice(0, 19) : '', + error: h?.error ?? '', + checking: optimisticAll || (since !== null && (h === undefined || h.lastCheck < since)), + } + }) +} + +/** GET /admin — dashboard shell. */ +export async function dashboardPage(_req: Request, res: Response, deps: AdminDeps): Promise { + const [serverInfo, rows] = await Promise.all([adminServerInfo(deps), buildRows(deps, deps.checkingSince(), false)]) + page(res, 'dashboard', { serverInfo, rows, anyChecking: rows.some((r) => r.checking) }, 'Dashboard', 'dashboard') +} + +/** GET /admin/health/table — htmx poll target (also the fast poll while a sweep runs). */ +export async function healthTable(_req: Request, res: Response, deps: AdminDeps): Promise { + const rows = await buildRows(deps, deps.checkingSince(), false) + partial(res, '_health_rows', { rows, anyChecking: rows.some((r) => r.checking) }) +} + +/** + * POST /admin/health/check — the "check connections now" button. Kicks the guarded + * sweep in the BACKGROUND (startOnDemandCheck does NOT await) so the response returns + * immediately showing EVERY base as "checking" (optimistic); the fast poll (a + * self-triggering row the fragment emits while `anyChecking`) then flips each base to + * its result as the sweep writes it, and stops once the on-demand check settles. + */ +export async function checkHealthNow(_req: Request, res: Response, deps: AdminDeps): Promise { + deps.startOnDemandCheck() // marks the check active + launches the sweep (fire-and-forget) + const rows = await buildRows(deps, deps.checkingSince(), true) + partial(res, '_health_rows', { rows, anyChecking: rows.some((r) => r.checking) }) +} diff --git a/packages/mcp-server/src/http/admin/grants.ts b/packages/mcp-server/src/http/admin/grants.ts new file mode 100644 index 0000000..176e29d --- /dev/null +++ b/packages/mcp-server/src/http/admin/grants.ts @@ -0,0 +1,87 @@ +// src/http/admin/grants.ts +import { fromNodeHeaders } from 'better-auth/node' +import type { Request, Response } from 'express' +import type { GrantScope } from '../../store/repos.js' +import type { AdminDeps } from './router.js' +import { flash, page, partial, render } from './views.js' + +/** + * Postgres `foreign_key_violation`. `grants.sub` FKs `user.id` and + * `grants.base_name` FKs `bases.name`, so granting against a user/base that was + * deleted while the matrix was on screen raises this instead of inserting. Both + * dialects are Postgres (pg + pglite), so the SQLSTATE is the reliable signal — + * message text is not. + */ +const PG_FOREIGN_KEY_VIOLATION = '23503' + +/** + * Walk the `cause` chain, not just the thrown error: drizzle re-throws driver + * failures wrapped in its own `DrizzleQueryError` ("Failed query: …"), which carries + * no SQLSTATE — the pg/pglite error holding `code` sits underneath. The depth cap is + * a cheap guard against a self-referential chain. + */ +function isForeignKeyViolation(err: unknown): boolean { + for (let e: unknown = err, depth = 0; e !== null && e !== undefined && depth < 5; depth++) { + if (typeof e === 'object' && (e as { code?: unknown }).code === PG_FOREIGN_KEY_VIOLATION) return true + e = (e as { cause?: unknown }).cause + } + return false +} + +/** GET /admin/grants — user × base matrix. */ +export async function grantsPage(req: Request, res: Response, deps: AdminDeps): Promise { + const { users } = await deps.auth.api.listUsers({ + headers: fromNodeHeaders(req.headers), + query: { limit: 200, sortBy: 'email', sortDirection: 'asc' }, + }) + const bases = (await deps.baseRepo.list()).map((b) => b.name) + + // matrix key = `${sub}|${base}` → scope. ONE query for every grant, not an + // N+1 resolve() per user (up to 200 sequential queries for a 200-user list). + const matrix: Record = {} + for (const g of await deps.grantRepo.listAll()) matrix[`${g.sub}|${g.baseName}`] = g.scope + page(res, 'grants_editor', { users, bases, matrix }, 'Grants', 'grants') +} + +/** POST /admin/grants/toggle — set/revoke one cell, return the swapped cell. */ +export async function toggleGrant(req: Request, res: Response, deps: AdminDeps): Promise { + // Validate presence explicitly: `String(undefined)` would coerce a missing field + // to the literal "undefined" and silently grant/revoke a bogus (sub, base). + const sub = req.body.sub + const base = req.body.base + if (typeof sub !== 'string' || sub === '' || typeof base !== 'string' || base === '') { + flash(res, 400, 'Missing sub or base.') + return + } + const scope: GrantScope = req.body.scope === 'write' ? 'write' : 'read' + const granted = req.body.granted === 'on' + + try { + if (granted) await deps.grantRepo.grant(sub, base, scope) + else await deps.grantRepo.revoke(sub, base) + } catch (err) { + // The user or base was deleted while this matrix was open, so the grant INSERT + // hit an FK. Snap the checkbox back to server truth (NOT granted — nothing was + // written) and toast "reload", instead of a generic 500 that leaves the box + // looking applied. A revoke can't hit this (a DELETE of an already-cascaded row + // is a no-op), so only the grant path lands here. + // + // 200, like the last-admin snap-back in users.ts: when the body IS server truth + // for the request's own target, it must swap. (4xx would swap too — shell.ts + // overrides htmx's responseHandling to make error bodies swappable — but the + // status carries no meaning htmx acts on here, and matching the existing idiom + // keeps this independent of that config.) + if (!isForeignKeyViolation(err)) throw err + res + .type('html') + .send( + render('_grant_cell', { sub, base, granted: false, scope }) + + render('_flash', { kind: 'err', message: 'That user or base no longer exists — reload the page.' }), + ) + return + } + + // The cell's aria-label needs only the base (the user comes from the row's + // ), so nothing user-controlled rides in hx-vals. + partial(res, '_grant_cell', { sub, base, granted, scope }) +} diff --git a/packages/mcp-server/src/http/admin/health-job.ts b/packages/mcp-server/src/http/admin/health-job.ts new file mode 100644 index 0000000..7b936b2 --- /dev/null +++ b/packages/mcp-server/src/http/admin/health-job.ts @@ -0,0 +1,163 @@ +// src/http/admin/health-job.ts +import { verifyReachability } from '@1c-odata/mcp/internal' +import { decrypt, type Keyring } from '../../store/crypto.js' +import type { BaseRepo, HealthRepo, SecretRepo } from '../../store/repos.js' +import { classifyProbe } from './bases.js' + +/** + * The health probe is a LIGHT reachability check ({@link verifyReachability} — a + * GET on the OData service root), NOT a full `$metadata` download: on real bases + * the root is ~20× smaller (KB–hundreds of KB vs 10–15 MB), so a short timeout is + * safe even on a slow link. Hence a 5s default (vs the 120s `$metadata` default). + */ +const DEFAULT_PROBE_TIMEOUT_MS = 5_000 + +/** Bases probed concurrently per sweep — total sweep time ≈ slowest base, not the sum. */ +const HEALTH_CONCURRENCY = 6 + +export interface HealthJob { + /** Run one sweep now (resolves when the sweep settles). For tests + startup seed + the "check now" button. */ + runOnce(): Promise + /** + * Stop the timer AND await any in-flight sweep, so a probe/HealthRepo write can't + * race the DB handle closing during shutdown. Idempotent. + */ + stop(): Promise +} + +export interface HealthSweepDeps { + baseRepo: BaseRepo + secretRepo: SecretRepo + healthRepo: HealthRepo + keyring: Keyring + probeTimeoutMs?: number + log?: { error(obj: unknown, msg?: string): void } +} + +export interface HealthJobDeps extends HealthSweepDeps { + intervalMs?: number +} + +/** + * Probe one base and record its health. A PROBE failure (unreachable / bad creds / + * decrypt) is turned into an auth_failed/unreachable row, not thrown; a repo/DB + * failure (secretRepo.get / healthRepo.upsert) still rejects — the caller's + * per-base worker catch handles that (so one base can't abort the sweep). + */ +async function probeBase( + deps: HealthSweepDeps, + base: { name: string; baseUrl: string; login: string }, + timeout: number, +): Promise { + const sealed = await deps.secretRepo.get(base.name) + if (sealed === null) { + await deps.healthRepo.upsert(base.name, 'auth_failed', 'No password assigned') + return + } + let password: string + try { + password = decrypt(deps.keyring, base.name, sealed) + } catch { + await deps.healthRepo.upsert(base.name, 'auth_failed', 'Secret decryption failed') + return + } + try { + await verifyReachability({ baseUrl: base.baseUrl, login: base.login, password, timeout }) + await deps.healthRepo.upsert(base.name, 'ok') + } catch (err) { + const { status, message } = classifyProbe(err) + await deps.healthRepo.upsert(base.name, status, message) + } +} + +/** + * Probe every base once (LIGHT reachability probe) and record ok / auth_failed / + * unreachable in HealthRepo. The shared body of the periodic job (below) AND the + * admin panel's on-demand "check now" button. Bases are probed with bounded + * concurrency ({@link HEALTH_CONCURRENCY}), so the sweep time is ≈ the slowest + * single base rather than the sum. Never throws — a sweep-level failure is logged, + * not propagated (so the timer / the request handler can't crash). + */ +export async function runHealthSweep(deps: HealthSweepDeps): Promise { + const timeout = deps.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS + try { + const bases = await deps.baseRepo.list() + // Bounded worker pool: `HEALTH_CONCURRENCY` workers pull from a shared index. + // Single-threaded JS makes `i++` atomic (no await between read and increment), + // so every worker gets a distinct base. + let i = 0 + const workers = Array.from({ length: Math.min(HEALTH_CONCURRENCY, bases.length) }, async () => { + while (i < bases.length) { + const base = bases[i++] + if (base === undefined) continue + try { + await probeBase(deps, base, timeout) + } catch (err) { + // A repo/DB write failure for THIS base must not reject the worker: that + // would make Promise.all settle early while other workers still run, + // breaking stop()'s "await every in-flight sweep" guarantee (a late write + // could then race the DB handle closing). Log and keep draining. + deps.log?.error( + { err: err instanceof Error ? err.message : String(err), base: base.name }, + 'health probe failed', + ) + } + } + }) + await Promise.all(workers) + } catch (err) { + // Log a serializable shape: a bare Error stringifies to `{}` under the JSON + // sink, dropping the message. Never let a sweep throw out of the caller. + deps.log?.error({ err: err instanceof Error ? err.message : String(err) }, 'health sweep failed') + } +} + +/** + * Single-instance periodic health job: every intervalMs, run {@link runHealthSweep}. + * No cross-replica coordination — one writer assumed (pglite is single-process; + * multi-replica pg would multiply probe load — tracked separately). + */ +export function startHealthJob(deps: HealthJobDeps): HealthJob { + // Floor the interval at 1s (this is a PUBLIC function, callable outside the env + // path): a tiny value would both hammer the 1С servers and leave no room for a + // positive probe timeout below it. So the interval is always ≥ 1000ms. + const intervalMs = Math.max(1000, deps.intervalMs ?? 60_000) + // #97: the probe timeout must stay BELOW the scheduling interval — a probe that + // can outlast the period is an invalid config. Clamp it below the interval (the + // re-entrancy guard would coalesce a late sweep anyway, but this keeps the + // configured timing honest) and surface the misconfiguration. The interval floor + // above guarantees the clamped result stays positive (≥ 999ms). + let probeTimeoutMs = deps.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS + // Defensive (public fn): a non-positive / non-finite override (0, negative, NaN) + // would forward an invalid timeout to the transport — fall back to the default. + if (!Number.isFinite(probeTimeoutMs) || probeTimeoutMs <= 0) probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS + if (probeTimeoutMs >= intervalMs) { + deps.log?.error({ probeTimeoutMs, intervalMs }, 'health probe timeout >= interval; clamped below the interval') + probeTimeoutMs = intervalMs - 1 + } + const sweepDeps: HealthSweepDeps = { ...deps, probeTimeoutMs } + let running = false + // The currently-running sweep (or a settled promise). `stop()` awaits it so an + // in-flight probe/write can't race the DB handle closing at shutdown. + let inflight: Promise = Promise.resolve() + + /** Start a sweep, or return the in-flight one — a slow sweep must not stack (re-entrancy guard). */ + function sweep(): Promise { + if (running) return inflight + running = true + inflight = runHealthSweep(sweepDeps).finally(() => { + running = false + }) + return inflight + } + + const timer = setInterval(() => void sweep(), intervalMs) + timer.unref?.() // don't keep the event loop alive + return { + runOnce: sweep, + async stop() { + clearInterval(timer) + await inflight + }, + } +} diff --git a/packages/mcp-server/src/http/admin/htmx-asset.ts b/packages/mcp-server/src/http/admin/htmx-asset.ts new file mode 100644 index 0000000..0f583c0 --- /dev/null +++ b/packages/mcp-server/src/http/admin/htmx-asset.ts @@ -0,0 +1,24 @@ +// src/http/admin/htmx-asset.ts +/** + * Vendored htmx (checked in, no CDN — CSP-safe `script-src 'self'`). The minified + * source is embedded base64-encoded so the raw JS (which contains backticks and + * `${'{'}}` sequences) survives as a plain module constant with no escaping hazard; + * it decodes once at load. Kept as a code constant so it bundles into dist with no + * `files`/copy build wiring. + * + * Pinned: htmx.org v2.0.4 — https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js + * Upgrades: re-run `base64` on the new dist and paste; note it in a changeset. + */ +const HTMX_MIN_JS_B64 = + 'dmFyIGh0bXg9ZnVuY3Rpb24oKXsidXNlIHN0cmljdCI7Y29uc3QgUT17b25Mb2FkOm51bGwscHJvY2VzczpudWxsLG9uOm51bGwsb2ZmOm51bGwsdHJpZ2dlcjpudWxsLGFqYXg6bnVsbCxmaW5kOm51bGwsZmluZEFsbDpudWxsLGNsb3Nlc3Q6bnVsbCx2YWx1ZXM6ZnVuY3Rpb24oZSx0KXtjb25zdCBuPWNuKGUsdHx8InBvc3QiKTtyZXR1cm4gbi52YWx1ZXN9LHJlbW92ZTpudWxsLGFkZENsYXNzOm51bGwscmVtb3ZlQ2xhc3M6bnVsbCx0b2dnbGVDbGFzczpudWxsLHRha2VDbGFzczpudWxsLHN3YXA6bnVsbCxkZWZpbmVFeHRlbnNpb246bnVsbCxyZW1vdmVFeHRlbnNpb246bnVsbCxsb2dBbGw6bnVsbCxsb2dOb25lOm51bGwsbG9nZ2VyOm51bGwsY29uZmlnOntoaXN0b3J5RW5hYmxlZDp0cnVlLGhpc3RvcnlDYWNoZVNpemU6MTAscmVmcmVzaE9uSGlzdG9yeU1pc3M6ZmFsc2UsZGVmYXVsdFN3YXBTdHlsZToiaW5uZXJIVE1MIixkZWZhdWx0U3dhcERlbGF5OjAsZGVmYXVsdFNldHRsZURlbGF5OjIwLGluY2x1ZGVJbmRpY2F0b3JTdHlsZXM6dHJ1ZSxpbmRpY2F0b3JDbGFzczoiaHRteC1pbmRpY2F0b3IiLHJlcXVlc3RDbGFzczoiaHRteC1yZXF1ZXN0IixhZGRlZENsYXNzOiJodG14LWFkZGVkIixzZXR0bGluZ0NsYXNzOiJodG14LXNldHRsaW5nIixzd2FwcGluZ0NsYXNzOiJodG14LXN3YXBwaW5nIixhbGxvd0V2YWw6dHJ1ZSxhbGxvd1NjcmlwdFRhZ3M6dHJ1ZSxpbmxpbmVTY3JpcHROb25jZToiIixpbmxpbmVTdHlsZU5vbmNlOiIiLGF0dHJpYnV0ZXNUb1NldHRsZTpbImNsYXNzIiwic3R5bGUiLCJ3aWR0aCIsImhlaWdodCJdLHdpdGhDcmVkZW50aWFsczpmYWxzZSx0aW1lb3V0OjAsd3NSZWNvbm5lY3REZWxheToiZnVsbC1qaXR0ZXIiLHdzQmluYXJ5VHlwZToiYmxvYiIsZGlzYWJsZVNlbGVjdG9yOiJbaHgtZGlzYWJsZV0sIFtkYXRhLWh4LWRpc2FibGVdIixzY3JvbGxCZWhhdmlvcjoiaW5zdGFudCIsZGVmYXVsdEZvY3VzU2Nyb2xsOmZhbHNlLGdldENhY2hlQnVzdGVyUGFyYW06ZmFsc2UsZ2xvYmFsVmlld1RyYW5zaXRpb25zOmZhbHNlLG1ldGhvZHNUaGF0VXNlVXJsUGFyYW1zOlsiZ2V0IiwiZGVsZXRlIl0sc2VsZlJlcXVlc3RzT25seTp0cnVlLGlnbm9yZVRpdGxlOmZhbHNlLHNjcm9sbEludG9WaWV3T25Cb29zdDp0cnVlLHRyaWdnZXJTcGVjc0NhY2hlOm51bGwsZGlzYWJsZUluaGVyaXRhbmNlOmZhbHNlLHJlc3BvbnNlSGFuZGxpbmc6W3tjb2RlOiIyMDQiLHN3YXA6ZmFsc2V9LHtjb2RlOiJbMjNdLi4iLHN3YXA6dHJ1ZX0se2NvZGU6Ils0NV0uLiIsc3dhcDpmYWxzZSxlcnJvcjp0cnVlfV0sYWxsb3dOZXN0ZWRPb2JTd2Fwczp0cnVlfSxwYXJzZUludGVydmFsOm51bGwsXzpudWxsLHZlcnNpb246IjIuMC40In07US5vbkxvYWQ9ajtRLnByb2Nlc3M9a3Q7US5vbj15ZTtRLm9mZj1iZTtRLnRyaWdnZXI9aGU7US5hamF4PVJuO1EuZmluZD11O1EuZmluZEFsbD14O1EuY2xvc2VzdD1nO1EucmVtb3ZlPXo7US5hZGRDbGFzcz1LO1EucmVtb3ZlQ2xhc3M9RztRLnRvZ2dsZUNsYXNzPVc7US50YWtlQ2xhc3M9WjtRLnN3YXA9JGU7US5kZWZpbmVFeHRlbnNpb249Rm47US5yZW1vdmVFeHRlbnNpb249Qm47US5sb2dBbGw9VjtRLmxvZ05vbmU9XztRLnBhcnNlSW50ZXJ2YWw9ZDtRLl89ZTtjb25zdCBuPXthZGRUcmlnZ2VySGFuZGxlcjpTdCxib2R5Q29udGFpbnM6bGUsY2FuQWNjZXNzTG9jYWxTdG9yYWdlOkIsZmluZFRoaXNFbGVtZW50OlNlLGZpbHRlclZhbHVlczpobixzd2FwOiRlLGhhc0F0dHJpYnV0ZTpzLGdldEF0dHJpYnV0ZVZhbHVlOnRlLGdldENsb3Nlc3RBdHRyaWJ1dGVWYWx1ZTpyZSxnZXRDbG9zZXN0TWF0Y2g6byxnZXRFeHByZXNzaW9uVmFyczpFbixnZXRIZWFkZXJzOmZuLGdldElucHV0VmFsdWVzOmNuLGdldEludGVybmFsRGF0YTppZSxnZXRTd2FwU3BlY2lmaWNhdGlvbjpnbixnZXRUcmlnZ2VyU3BlY3M6c3QsZ2V0VGFyZ2V0OkVlLG1ha2VGcmFnbWVudDpQLG1lcmdlT2JqZWN0czpjZSxtYWtlU2V0dGxlSW5mbzp4bixvb2JTd2FwOkhlLHF1ZXJ5U2VsZWN0b3JFeHQ6YWUsc2V0dGxlSW1tZWRpYXRlbHk6S3Qsc2hvdWxkQ2FuY2VsOmh0LHRyaWdnZXJFdmVudDpoZSx0cmlnZ2VyRXJyb3JFdmVudDpmZSx3aXRoRXh0ZW5zaW9uczpGdH07Y29uc3Qgcj1bImdldCIsInBvc3QiLCJwdXQiLCJkZWxldGUiLCJwYXRjaCJdO2NvbnN0IEg9ci5tYXAoZnVuY3Rpb24oZSl7cmV0dXJuIltoeC0iK2UrIl0sIFtkYXRhLWh4LSIrZSsiXSJ9KS5qb2luKCIsICIpO2Z1bmN0aW9uIGQoZSl7aWYoZT09dW5kZWZpbmVkKXtyZXR1cm4gdW5kZWZpbmVkfWxldCB0PU5hTjtpZihlLnNsaWNlKC0yKT09Im1zIil7dD1wYXJzZUZsb2F0KGUuc2xpY2UoMCwtMikpfWVsc2UgaWYoZS5zbGljZSgtMSk9PSJzIil7dD1wYXJzZUZsb2F0KGUuc2xpY2UoMCwtMSkpKjFlM31lbHNlIGlmKGUuc2xpY2UoLTEpPT0ibSIpe3Q9cGFyc2VGbG9hdChlLnNsaWNlKDAsLTEpKSoxZTMqNjB9ZWxzZXt0PXBhcnNlRmxvYXQoZSl9cmV0dXJuIGlzTmFOKHQpP3VuZGVmaW5lZDp0fWZ1bmN0aW9uIGVlKGUsdCl7cmV0dXJuIGUgaW5zdGFuY2VvZiBFbGVtZW50JiZlLmdldEF0dHJpYnV0ZSh0KX1mdW5jdGlvbiBzKGUsdCl7cmV0dXJuISFlLmhhc0F0dHJpYnV0ZSYmKGUuaGFzQXR0cmlidXRlKHQpfHxlLmhhc0F0dHJpYnV0ZSgiZGF0YS0iK3QpKX1mdW5jdGlvbiB0ZShlLHQpe3JldHVybiBlZShlLHQpfHxlZShlLCJkYXRhLSIrdCl9ZnVuY3Rpb24gYyhlKXtjb25zdCB0PWUucGFyZW50RWxlbWVudDtpZighdCYmZS5wYXJlbnROb2RlIGluc3RhbmNlb2YgU2hhZG93Um9vdClyZXR1cm4gZS5wYXJlbnROb2RlO3JldHVybiB0fWZ1bmN0aW9uIG5lKCl7cmV0dXJuIGRvY3VtZW50fWZ1bmN0aW9uIG0oZSx0KXtyZXR1cm4gZS5nZXRSb290Tm9kZT9lLmdldFJvb3ROb2RlKHtjb21wb3NlZDp0fSk6bmUoKX1mdW5jdGlvbiBvKGUsdCl7d2hpbGUoZSYmIXQoZSkpe2U9YyhlKX1yZXR1cm4gZXx8bnVsbH1mdW5jdGlvbiBpKGUsdCxuKXtjb25zdCByPXRlKHQsbik7Y29uc3Qgbz10ZSh0LCJoeC1kaXNpbmhlcml0Iik7dmFyIGk9dGUodCwiaHgtaW5oZXJpdCIpO2lmKGUhPT10KXtpZihRLmNvbmZpZy5kaXNhYmxlSW5oZXJpdGFuY2Upe2lmKGkmJihpPT09IioifHxpLnNwbGl0KCIgIikuaW5kZXhPZihuKT49MCkpe3JldHVybiByfWVsc2V7cmV0dXJuIG51bGx9fWlmKG8mJihvPT09IioifHxvLnNwbGl0KCIgIikuaW5kZXhPZihuKT49MCkpe3JldHVybiJ1bnNldCJ9fXJldHVybiByfWZ1bmN0aW9uIHJlKHQsbil7bGV0IHI9bnVsbDtvKHQsZnVuY3Rpb24oZSl7cmV0dXJuISEocj1pKHQsdWUoZSksbikpfSk7aWYociE9PSJ1bnNldCIpe3JldHVybiByfX1mdW5jdGlvbiBoKGUsdCl7Y29uc3Qgbj1lIGluc3RhbmNlb2YgRWxlbWVudCYmKGUubWF0Y2hlc3x8ZS5tYXRjaGVzU2VsZWN0b3J8fGUubXNNYXRjaGVzU2VsZWN0b3J8fGUubW96TWF0Y2hlc1NlbGVjdG9yfHxlLndlYmtpdE1hdGNoZXNTZWxlY3Rvcnx8ZS5vTWF0Y2hlc1NlbGVjdG9yKTtyZXR1cm4hIW4mJm4uY2FsbChlLHQpfWZ1bmN0aW9uIFQoZSl7Y29uc3QgdD0vPChbYS16XVteXC9cMD5ceDIwXHRcclxuXGZdKikvaTtjb25zdCBuPXQuZXhlYyhlKTtpZihuKXtyZXR1cm4gblsxXS50b0xvd2VyQ2FzZSgpfWVsc2V7cmV0dXJuIiJ9fWZ1bmN0aW9uIHEoZSl7Y29uc3QgdD1uZXcgRE9NUGFyc2VyO3JldHVybiB0LnBhcnNlRnJvbVN0cmluZyhlLCJ0ZXh0L2h0bWwiKX1mdW5jdGlvbiBMKGUsdCl7d2hpbGUodC5jaGlsZE5vZGVzLmxlbmd0aD4wKXtlLmFwcGVuZCh0LmNoaWxkTm9kZXNbMF0pfX1mdW5jdGlvbiBBKGUpe2NvbnN0IHQ9bmUoKS5jcmVhdGVFbGVtZW50KCJzY3JpcHQiKTtzZShlLmF0dHJpYnV0ZXMsZnVuY3Rpb24oZSl7dC5zZXRBdHRyaWJ1dGUoZS5uYW1lLGUudmFsdWUpfSk7dC50ZXh0Q29udGVudD1lLnRleHRDb250ZW50O3QuYXN5bmM9ZmFsc2U7aWYoUS5jb25maWcuaW5saW5lU2NyaXB0Tm9uY2Upe3Qubm9uY2U9US5jb25maWcuaW5saW5lU2NyaXB0Tm9uY2V9cmV0dXJuIHR9ZnVuY3Rpb24gTihlKXtyZXR1cm4gZS5tYXRjaGVzKCJzY3JpcHQiKSYmKGUudHlwZT09PSJ0ZXh0L2phdmFzY3JpcHQifHxlLnR5cGU9PT0ibW9kdWxlInx8ZS50eXBlPT09IiIpfWZ1bmN0aW9uIEkoZSl7QXJyYXkuZnJvbShlLnF1ZXJ5U2VsZWN0b3JBbGwoInNjcmlwdCIpKS5mb3JFYWNoKGU9PntpZihOKGUpKXtjb25zdCB0PUEoZSk7Y29uc3Qgbj1lLnBhcmVudE5vZGU7dHJ5e24uaW5zZXJ0QmVmb3JlKHQsZSl9Y2F0Y2goZSl7TyhlKX1maW5hbGx5e2UucmVtb3ZlKCl9fX0pfWZ1bmN0aW9uIFAoZSl7Y29uc3QgdD1lLnJlcGxhY2UoLzxoZWFkKFxzW14+XSopPz5bXHNcU10qPzxcL2hlYWQ+L2ksIiIpO2NvbnN0IG49VCh0KTtsZXQgcjtpZihuPT09Imh0bWwiKXtyPW5ldyBEb2N1bWVudEZyYWdtZW50O2NvbnN0IGk9cShlKTtMKHIsaS5ib2R5KTtyLnRpdGxlPWkudGl0bGV9ZWxzZSBpZihuPT09ImJvZHkiKXtyPW5ldyBEb2N1bWVudEZyYWdtZW50O2NvbnN0IGk9cSh0KTtMKHIsaS5ib2R5KTtyLnRpdGxlPWkudGl0bGV9ZWxzZXtjb25zdCBpPXEoJzxib2R5Pjx0ZW1wbGF0ZSBjbGFzcz0iaW50ZXJuYWwtaHRteC13cmFwcGVyIj4nK3QrIjwvdGVtcGxhdGU+PC9ib2R5PiIpO3I9aS5xdWVyeVNlbGVjdG9yKCJ0ZW1wbGF0ZSIpLmNvbnRlbnQ7ci50aXRsZT1pLnRpdGxlO3ZhciBvPXIucXVlcnlTZWxlY3RvcigidGl0bGUiKTtpZihvJiZvLnBhcmVudE5vZGU9PT1yKXtvLnJlbW92ZSgpO3IudGl0bGU9by5pbm5lclRleHR9fWlmKHIpe2lmKFEuY29uZmlnLmFsbG93U2NyaXB0VGFncyl7SShyKX1lbHNle3IucXVlcnlTZWxlY3RvckFsbCgic2NyaXB0IikuZm9yRWFjaChlPT5lLnJlbW92ZSgpKX19cmV0dXJuIHJ9ZnVuY3Rpb24gb2UoZSl7aWYoZSl7ZSgpfX1mdW5jdGlvbiB0KGUsdCl7cmV0dXJuIE9iamVjdC5wcm90b3R5cGUudG9TdHJpbmcuY2FsbChlKT09PSJbb2JqZWN0ICIrdCsiXSJ9ZnVuY3Rpb24gayhlKXtyZXR1cm4gdHlwZW9mIGU9PT0iZnVuY3Rpb24ifWZ1bmN0aW9uIEQoZSl7cmV0dXJuIHQoZSwiT2JqZWN0Iil9ZnVuY3Rpb24gaWUoZSl7Y29uc3QgdD0iaHRteC1pbnRlcm5hbC1kYXRhIjtsZXQgbj1lW3RdO2lmKCFuKXtuPWVbdF09e319cmV0dXJuIG59ZnVuY3Rpb24gTSh0KXtjb25zdCBuPVtdO2lmKHQpe2ZvcihsZXQgZT0wO2U8dC5sZW5ndGg7ZSsrKXtuLnB1c2godFtlXSl9fXJldHVybiBufWZ1bmN0aW9uIHNlKHQsbil7aWYodCl7Zm9yKGxldCBlPTA7ZTx0Lmxlbmd0aDtlKyspe24odFtlXSl9fX1mdW5jdGlvbiBYKGUpe2NvbnN0IHQ9ZS5nZXRCb3VuZGluZ0NsaWVudFJlY3QoKTtjb25zdCBuPXQudG9wO2NvbnN0IHI9dC5ib3R0b207cmV0dXJuIG48d2luZG93LmlubmVySGVpZ2h0JiZyPj0wfWZ1bmN0aW9uIGxlKGUpe3JldHVybiBlLmdldFJvb3ROb2RlKHtjb21wb3NlZDp0cnVlfSk9PT1kb2N1bWVudH1mdW5jdGlvbiBGKGUpe3JldHVybiBlLnRyaW0oKS5zcGxpdCgvXHMrLyl9ZnVuY3Rpb24gY2UoZSx0KXtmb3IoY29uc3QgbiBpbiB0KXtpZih0Lmhhc093blByb3BlcnR5KG4pKXtlW25dPXRbbl19fXJldHVybiBlfWZ1bmN0aW9uIFMoZSl7dHJ5e3JldHVybiBKU09OLnBhcnNlKGUpfWNhdGNoKGUpe08oZSk7cmV0dXJuIG51bGx9fWZ1bmN0aW9uIEIoKXtjb25zdCBlPSJodG14OmxvY2FsU3RvcmFnZVRlc3QiO3RyeXtsb2NhbFN0b3JhZ2Uuc2V0SXRlbShlLGUpO2xvY2FsU3RvcmFnZS5yZW1vdmVJdGVtKGUpO3JldHVybiB0cnVlfWNhdGNoKGUpe3JldHVybiBmYWxzZX19ZnVuY3Rpb24gVSh0KXt0cnl7Y29uc3QgZT1uZXcgVVJMKHQpO2lmKGUpe3Q9ZS5wYXRobmFtZStlLnNlYXJjaH1pZighL15cLyQvLnRlc3QodCkpe3Q9dC5yZXBsYWNlKC9cLyskLywiIil9cmV0dXJuIHR9Y2F0Y2goZSl7cmV0dXJuIHR9fWZ1bmN0aW9uIGUoZSl7cmV0dXJuIHZuKG5lKCkuYm9keSxmdW5jdGlvbigpe3JldHVybiBldmFsKGUpfSl9ZnVuY3Rpb24gaih0KXtjb25zdCBlPVEub24oImh0bXg6bG9hZCIsZnVuY3Rpb24oZSl7dChlLmRldGFpbC5lbHQpfSk7cmV0dXJuIGV9ZnVuY3Rpb24gVigpe1EubG9nZ2VyPWZ1bmN0aW9uKGUsdCxuKXtpZihjb25zb2xlKXtjb25zb2xlLmxvZyh0LGUsbil9fX1mdW5jdGlvbiBfKCl7US5sb2dnZXI9bnVsbH1mdW5jdGlvbiB1KGUsdCl7aWYodHlwZW9mIGUhPT0ic3RyaW5nIil7cmV0dXJuIGUucXVlcnlTZWxlY3Rvcih0KX1lbHNle3JldHVybiB1KG5lKCksZSl9fWZ1bmN0aW9uIHgoZSx0KXtpZih0eXBlb2YgZSE9PSJzdHJpbmciKXtyZXR1cm4gZS5xdWVyeVNlbGVjdG9yQWxsKHQpfWVsc2V7cmV0dXJuIHgobmUoKSxlKX19ZnVuY3Rpb24gRSgpe3JldHVybiB3aW5kb3d9ZnVuY3Rpb24geihlLHQpe2U9eShlKTtpZih0KXtFKCkuc2V0VGltZW91dChmdW5jdGlvbigpe3ooZSk7ZT1udWxsfSx0KX1lbHNle2MoZSkucmVtb3ZlQ2hpbGQoZSl9fWZ1bmN0aW9uIHVlKGUpe3JldHVybiBlIGluc3RhbmNlb2YgRWxlbWVudD9lOm51bGx9ZnVuY3Rpb24gJChlKXtyZXR1cm4gZSBpbnN0YW5jZW9mIEhUTUxFbGVtZW50P2U6bnVsbH1mdW5jdGlvbiBKKGUpe3JldHVybiB0eXBlb2YgZT09PSJzdHJpbmciP2U6bnVsbH1mdW5jdGlvbiBmKGUpe3JldHVybiBlIGluc3RhbmNlb2YgRWxlbWVudHx8ZSBpbnN0YW5jZW9mIERvY3VtZW50fHxlIGluc3RhbmNlb2YgRG9jdW1lbnRGcmFnbWVudD9lOm51bGx9ZnVuY3Rpb24gSyhlLHQsbil7ZT11ZSh5KGUpKTtpZighZSl7cmV0dXJufWlmKG4pe0UoKS5zZXRUaW1lb3V0KGZ1bmN0aW9uKCl7SyhlLHQpO2U9bnVsbH0sbil9ZWxzZXtlLmNsYXNzTGlzdCYmZS5jbGFzc0xpc3QuYWRkKHQpfX1mdW5jdGlvbiBHKGUsdCxuKXtsZXQgcj11ZSh5KGUpKTtpZighcil7cmV0dXJufWlmKG4pe0UoKS5zZXRUaW1lb3V0KGZ1bmN0aW9uKCl7RyhyLHQpO3I9bnVsbH0sbil9ZWxzZXtpZihyLmNsYXNzTGlzdCl7ci5jbGFzc0xpc3QucmVtb3ZlKHQpO2lmKHIuY2xhc3NMaXN0Lmxlbmd0aD09PTApe3IucmVtb3ZlQXR0cmlidXRlKCJjbGFzcyIpfX19fWZ1bmN0aW9uIFcoZSx0KXtlPXkoZSk7ZS5jbGFzc0xpc3QudG9nZ2xlKHQpfWZ1bmN0aW9uIFooZSx0KXtlPXkoZSk7c2UoZS5wYXJlbnRFbGVtZW50LmNoaWxkcmVuLGZ1bmN0aW9uKGUpe0coZSx0KX0pO0sodWUoZSksdCl9ZnVuY3Rpb24gZyhlLHQpe2U9dWUoeShlKSk7aWYoZSYmZS5jbG9zZXN0KXtyZXR1cm4gZS5jbG9zZXN0KHQpfWVsc2V7ZG97aWYoZT09bnVsbHx8aChlLHQpKXtyZXR1cm4gZX19d2hpbGUoZT1lJiZ1ZShjKGUpKSk7cmV0dXJuIG51bGx9fWZ1bmN0aW9uIGwoZSx0KXtyZXR1cm4gZS5zdWJzdHJpbmcoMCx0Lmxlbmd0aCk9PT10fWZ1bmN0aW9uIFkoZSx0KXtyZXR1cm4gZS5zdWJzdHJpbmcoZS5sZW5ndGgtdC5sZW5ndGgpPT09dH1mdW5jdGlvbiBnZShlKXtjb25zdCB0PWUudHJpbSgpO2lmKGwodCwiPCIpJiZZKHQsIi8+Iikpe3JldHVybiB0LnN1YnN0cmluZygxLHQubGVuZ3RoLTIpfWVsc2V7cmV0dXJuIHR9fWZ1bmN0aW9uIHAodCxyLG4pe2lmKHIuaW5kZXhPZigiZ2xvYmFsICIpPT09MCl7cmV0dXJuIHAodCxyLnNsaWNlKDcpLHRydWUpfXQ9eSh0KTtjb25zdCBvPVtdO3tsZXQgdD0wO2xldCBuPTA7Zm9yKGxldCBlPTA7ZTxyLmxlbmd0aDtlKyspe2NvbnN0IGw9cltlXTtpZihsPT09IiwiJiZ0PT09MCl7by5wdXNoKHIuc3Vic3RyaW5nKG4sZSkpO249ZSsxO2NvbnRpbnVlfWlmKGw9PT0iPCIpe3QrK31lbHNlIGlmKGw9PT0iLyImJmU8ci5sZW5ndGgtMSYmcltlKzFdPT09Ij4iKXt0LS19fWlmKG48ci5sZW5ndGgpe28ucHVzaChyLnN1YnN0cmluZyhuKSl9fWNvbnN0IGk9W107Y29uc3Qgcz1bXTt3aGlsZShvLmxlbmd0aD4wKXtjb25zdCByPWdlKG8uc2hpZnQoKSk7bGV0IGU7aWYoci5pbmRleE9mKCJjbG9zZXN0ICIpPT09MCl7ZT1nKHVlKHQpLGdlKHIuc3Vic3RyKDgpKSl9ZWxzZSBpZihyLmluZGV4T2YoImZpbmQgIik9PT0wKXtlPXUoZih0KSxnZShyLnN1YnN0cig1KSkpfWVsc2UgaWYocj09PSJuZXh0Inx8cj09PSJuZXh0RWxlbWVudFNpYmxpbmciKXtlPXVlKHQpLm5leHRFbGVtZW50U2libGluZ31lbHNlIGlmKHIuaW5kZXhPZigibmV4dCAiKT09PTApe2U9cGUodCxnZShyLnN1YnN0cig1KSksISFuKX1lbHNlIGlmKHI9PT0icHJldmlvdXMifHxyPT09InByZXZpb3VzRWxlbWVudFNpYmxpbmciKXtlPXVlKHQpLnByZXZpb3VzRWxlbWVudFNpYmxpbmd9ZWxzZSBpZihyLmluZGV4T2YoInByZXZpb3VzICIpPT09MCl7ZT1tZSh0LGdlKHIuc3Vic3RyKDkpKSwhIW4pfWVsc2UgaWYocj09PSJkb2N1bWVudCIpe2U9ZG9jdW1lbnR9ZWxzZSBpZihyPT09IndpbmRvdyIpe2U9d2luZG93fWVsc2UgaWYocj09PSJib2R5Iil7ZT1kb2N1bWVudC5ib2R5fWVsc2UgaWYocj09PSJyb290Iil7ZT1tKHQsISFuKX1lbHNlIGlmKHI9PT0iaG9zdCIpe2U9dC5nZXRSb290Tm9kZSgpLmhvc3R9ZWxzZXtzLnB1c2gocil9aWYoZSl7aS5wdXNoKGUpfX1pZihzLmxlbmd0aD4wKXtjb25zdCBlPXMuam9pbigiLCIpO2NvbnN0IGM9ZihtKHQsISFuKSk7aS5wdXNoKC4uLk0oYy5xdWVyeVNlbGVjdG9yQWxsKGUpKSl9cmV0dXJuIGl9dmFyIHBlPWZ1bmN0aW9uKHQsZSxuKXtjb25zdCByPWYobSh0LG4pKS5xdWVyeVNlbGVjdG9yQWxsKGUpO2ZvcihsZXQgZT0wO2U8ci5sZW5ndGg7ZSsrKXtjb25zdCBvPXJbZV07aWYoby5jb21wYXJlRG9jdW1lbnRQb3NpdGlvbih0KT09PU5vZGUuRE9DVU1FTlRfUE9TSVRJT05fUFJFQ0VESU5HKXtyZXR1cm4gb319fTt2YXIgbWU9ZnVuY3Rpb24odCxlLG4pe2NvbnN0IHI9ZihtKHQsbikpLnF1ZXJ5U2VsZWN0b3JBbGwoZSk7Zm9yKGxldCBlPXIubGVuZ3RoLTE7ZT49MDtlLS0pe2NvbnN0IG89cltlXTtpZihvLmNvbXBhcmVEb2N1bWVudFBvc2l0aW9uKHQpPT09Tm9kZS5ET0NVTUVOVF9QT1NJVElPTl9GT0xMT1dJTkcpe3JldHVybiBvfX19O2Z1bmN0aW9uIGFlKGUsdCl7aWYodHlwZW9mIGUhPT0ic3RyaW5nIil7cmV0dXJuIHAoZSx0KVswXX1lbHNle3JldHVybiBwKG5lKCkuYm9keSxlKVswXX19ZnVuY3Rpb24geShlLHQpe2lmKHR5cGVvZiBlPT09InN0cmluZyIpe3JldHVybiB1KGYodCl8fGRvY3VtZW50LGUpfWVsc2V7cmV0dXJuIGV9fWZ1bmN0aW9uIHhlKGUsdCxuLHIpe2lmKGsodCkpe3JldHVybnt0YXJnZXQ6bmUoKS5ib2R5LGV2ZW50OkooZSksbGlzdGVuZXI6dCxvcHRpb25zOm59fWVsc2V7cmV0dXJue3RhcmdldDp5KGUpLGV2ZW50OkoodCksbGlzdGVuZXI6bixvcHRpb25zOnJ9fX1mdW5jdGlvbiB5ZSh0LG4scixvKXtWbihmdW5jdGlvbigpe2NvbnN0IGU9eGUodCxuLHIsbyk7ZS50YXJnZXQuYWRkRXZlbnRMaXN0ZW5lcihlLmV2ZW50LGUubGlzdGVuZXIsZS5vcHRpb25zKX0pO2NvbnN0IGU9ayhuKTtyZXR1cm4gZT9uOnJ9ZnVuY3Rpb24gYmUodCxuLHIpe1ZuKGZ1bmN0aW9uKCl7Y29uc3QgZT14ZSh0LG4scik7ZS50YXJnZXQucmVtb3ZlRXZlbnRMaXN0ZW5lcihlLmV2ZW50LGUubGlzdGVuZXIpfSk7cmV0dXJuIGsobik/bjpyfWNvbnN0IHZlPW5lKCkuY3JlYXRlRWxlbWVudCgib3V0cHV0Iik7ZnVuY3Rpb24gd2UoZSx0KXtjb25zdCBuPXJlKGUsdCk7aWYobil7aWYobj09PSJ0aGlzIil7cmV0dXJuW1NlKGUsdCldfWVsc2V7Y29uc3Qgcj1wKGUsbik7aWYoci5sZW5ndGg9PT0wKXtPKCdUaGUgc2VsZWN0b3IgIicrbisnIiBvbiAnK3QrIiByZXR1cm5lZCBubyBtYXRjaGVzISIpO3JldHVyblt2ZV19ZWxzZXtyZXR1cm4gcn19fX1mdW5jdGlvbiBTZShlLHQpe3JldHVybiB1ZShvKGUsZnVuY3Rpb24oZSl7cmV0dXJuIHRlKHVlKGUpLHQpIT1udWxsfSkpfWZ1bmN0aW9uIEVlKGUpe2NvbnN0IHQ9cmUoZSwiaHgtdGFyZ2V0Iik7aWYodCl7aWYodD09PSJ0aGlzIil7cmV0dXJuIFNlKGUsImh4LXRhcmdldCIpfWVsc2V7cmV0dXJuIGFlKGUsdCl9fWVsc2V7Y29uc3Qgbj1pZShlKTtpZihuLmJvb3N0ZWQpe3JldHVybiBuZSgpLmJvZHl9ZWxzZXtyZXR1cm4gZX19fWZ1bmN0aW9uIENlKHQpe2NvbnN0IG49US5jb25maWcuYXR0cmlidXRlc1RvU2V0dGxlO2ZvcihsZXQgZT0wO2U8bi5sZW5ndGg7ZSsrKXtpZih0PT09bltlXSl7cmV0dXJuIHRydWV9fXJldHVybiBmYWxzZX1mdW5jdGlvbiBPZSh0LG4pe3NlKHQuYXR0cmlidXRlcyxmdW5jdGlvbihlKXtpZighbi5oYXNBdHRyaWJ1dGUoZS5uYW1lKSYmQ2UoZS5uYW1lKSl7dC5yZW1vdmVBdHRyaWJ1dGUoZS5uYW1lKX19KTtzZShuLmF0dHJpYnV0ZXMsZnVuY3Rpb24oZSl7aWYoQ2UoZS5uYW1lKSl7dC5zZXRBdHRyaWJ1dGUoZS5uYW1lLGUudmFsdWUpfX0pfWZ1bmN0aW9uIFJlKHQsZSl7Y29uc3Qgbj1VbihlKTtmb3IobGV0IGU9MDtlPG4ubGVuZ3RoO2UrKyl7Y29uc3Qgcj1uW2VdO3RyeXtpZihyLmlzSW5saW5lU3dhcCh0KSl7cmV0dXJuIHRydWV9fWNhdGNoKGUpe08oZSl9fXJldHVybiB0PT09Im91dGVySFRNTCJ9ZnVuY3Rpb24gSGUoZSxvLGksdCl7dD10fHxuZSgpO2xldCBuPSIjIitlZShvLCJpZCIpO2xldCBzPSJvdXRlckhUTUwiO2lmKGU9PT0idHJ1ZSIpe31lbHNlIGlmKGUuaW5kZXhPZigiOiIpPjApe3M9ZS5zdWJzdHJpbmcoMCxlLmluZGV4T2YoIjoiKSk7bj1lLnN1YnN0cmluZyhlLmluZGV4T2YoIjoiKSsxKX1lbHNle3M9ZX1vLnJlbW92ZUF0dHJpYnV0ZSgiaHgtc3dhcC1vb2IiKTtvLnJlbW92ZUF0dHJpYnV0ZSgiZGF0YS1oeC1zd2FwLW9vYiIpO2NvbnN0IHI9cCh0LG4sZmFsc2UpO2lmKHIpe3NlKHIsZnVuY3Rpb24oZSl7bGV0IHQ7Y29uc3Qgbj1vLmNsb25lTm9kZSh0cnVlKTt0PW5lKCkuY3JlYXRlRG9jdW1lbnRGcmFnbWVudCgpO3QuYXBwZW5kQ2hpbGQobik7aWYoIVJlKHMsZSkpe3Q9ZihuKX1jb25zdCByPXtzaG91bGRTd2FwOnRydWUsdGFyZ2V0OmUsZnJhZ21lbnQ6dH07aWYoIWhlKGUsImh0bXg6b29iQmVmb3JlU3dhcCIscikpcmV0dXJuO2U9ci50YXJnZXQ7aWYoci5zaG91bGRTd2FwKXtxZSh0KTtfZShzLGUsZSx0LGkpO1RlKCl9c2UoaS5lbHRzLGZ1bmN0aW9uKGUpe2hlKGUsImh0bXg6b29iQWZ0ZXJTd2FwIixyKX0pfSk7by5wYXJlbnROb2RlLnJlbW92ZUNoaWxkKG8pfWVsc2V7by5wYXJlbnROb2RlLnJlbW92ZUNoaWxkKG8pO2ZlKG5lKCkuYm9keSwiaHRteDpvb2JFcnJvck5vVGFyZ2V0Iix7Y29udGVudDpvfSl9cmV0dXJuIGV9ZnVuY3Rpb24gVGUoKXtjb25zdCBlPXUoIiMtLWh0bXgtcHJlc2VydmUtcGFudHJ5LS0iKTtpZihlKXtmb3IoY29uc3QgdCBvZlsuLi5lLmNoaWxkcmVuXSl7Y29uc3Qgbj11KCIjIit0LmlkKTtuLnBhcmVudE5vZGUubW92ZUJlZm9yZSh0LG4pO24ucmVtb3ZlKCl9ZS5yZW1vdmUoKX19ZnVuY3Rpb24gcWUoZSl7c2UoeChlLCJbaHgtcHJlc2VydmVdLCBbZGF0YS1oeC1wcmVzZXJ2ZV0iKSxmdW5jdGlvbihlKXtjb25zdCB0PXRlKGUsImlkIik7Y29uc3Qgbj1uZSgpLmdldEVsZW1lbnRCeUlkKHQpO2lmKG4hPW51bGwpe2lmKGUubW92ZUJlZm9yZSl7bGV0IGU9dSgiIy0taHRteC1wcmVzZXJ2ZS1wYW50cnktLSIpO2lmKGU9PW51bGwpe25lKCkuYm9keS5pbnNlcnRBZGphY2VudEhUTUwoImFmdGVyZW5kIiwiPGRpdiBpZD0nLS1odG14LXByZXNlcnZlLXBhbnRyeS0tJz48L2Rpdj4iKTtlPXUoIiMtLWh0bXgtcHJlc2VydmUtcGFudHJ5LS0iKX1lLm1vdmVCZWZvcmUobixudWxsKX1lbHNle2UucGFyZW50Tm9kZS5yZXBsYWNlQ2hpbGQobixlKX19fSl9ZnVuY3Rpb24gTGUobCxlLGMpe3NlKGUucXVlcnlTZWxlY3RvckFsbCgiW2lkXSIpLGZ1bmN0aW9uKHQpe2NvbnN0IG49ZWUodCwiaWQiKTtpZihuJiZuLmxlbmd0aD4wKXtjb25zdCByPW4ucmVwbGFjZSgiJyIsIlxcJyIpO2NvbnN0IG89dC50YWdOYW1lLnJlcGxhY2UoIjoiLCJcXDoiKTtjb25zdCBlPWYobCk7Y29uc3QgaT1lJiZlLnF1ZXJ5U2VsZWN0b3IobysiW2lkPSciK3IrIiddIik7aWYoaSYmaSE9PWUpe2NvbnN0IHM9dC5jbG9uZU5vZGUoKTtPZSh0LGkpO2MudGFza3MucHVzaChmdW5jdGlvbigpe09lKHQscyl9KX19fSl9ZnVuY3Rpb24gQWUoZSl7cmV0dXJuIGZ1bmN0aW9uKCl7RyhlLFEuY29uZmlnLmFkZGVkQ2xhc3MpO2t0KHVlKGUpKTtOZShmKGUpKTtoZShlLCJodG14OmxvYWQiKX19ZnVuY3Rpb24gTmUoZSl7Y29uc3QgdD0iW2F1dG9mb2N1c10iO2NvbnN0IG49JChoKGUsdCk/ZTplLnF1ZXJ5U2VsZWN0b3IodCkpO2lmKG4hPW51bGwpe24uZm9jdXMoKX19ZnVuY3Rpb24gYShlLHQsbixyKXtMZShlLG4scik7d2hpbGUobi5jaGlsZE5vZGVzLmxlbmd0aD4wKXtjb25zdCBvPW4uZmlyc3RDaGlsZDtLKHVlKG8pLFEuY29uZmlnLmFkZGVkQ2xhc3MpO2UuaW5zZXJ0QmVmb3JlKG8sdCk7aWYoby5ub2RlVHlwZSE9PU5vZGUuVEVYVF9OT0RFJiZvLm5vZGVUeXBlIT09Tm9kZS5DT01NRU5UX05PREUpe3IudGFza3MucHVzaChBZShvKSl9fX1mdW5jdGlvbiBJZShlLHQpe2xldCBuPTA7d2hpbGUobjxlLmxlbmd0aCl7dD0odDw8NSktdCtlLmNoYXJDb2RlQXQobisrKXwwfXJldHVybiB0fWZ1bmN0aW9uIFBlKHQpe2xldCBuPTA7aWYodC5hdHRyaWJ1dGVzKXtmb3IobGV0IGU9MDtlPHQuYXR0cmlidXRlcy5sZW5ndGg7ZSsrKXtjb25zdCByPXQuYXR0cmlidXRlc1tlXTtpZihyLnZhbHVlKXtuPUllKHIubmFtZSxuKTtuPUllKHIudmFsdWUsbil9fX1yZXR1cm4gbn1mdW5jdGlvbiBrZSh0KXtjb25zdCBuPWllKHQpO2lmKG4ub25IYW5kbGVycyl7Zm9yKGxldCBlPTA7ZTxuLm9uSGFuZGxlcnMubGVuZ3RoO2UrKyl7Y29uc3Qgcj1uLm9uSGFuZGxlcnNbZV07YmUodCxyLmV2ZW50LHIubGlzdGVuZXIpfWRlbGV0ZSBuLm9uSGFuZGxlcnN9fWZ1bmN0aW9uIERlKGUpe2NvbnN0IHQ9aWUoZSk7aWYodC50aW1lb3V0KXtjbGVhclRpbWVvdXQodC50aW1lb3V0KX1pZih0Lmxpc3RlbmVySW5mb3Mpe3NlKHQubGlzdGVuZXJJbmZvcyxmdW5jdGlvbihlKXtpZihlLm9uKXtiZShlLm9uLGUudHJpZ2dlcixlLmxpc3RlbmVyKX19KX1rZShlKTtzZShPYmplY3Qua2V5cyh0KSxmdW5jdGlvbihlKXtpZihlIT09ImZpcnN0SW5pdENvbXBsZXRlZCIpZGVsZXRlIHRbZV19KX1mdW5jdGlvbiBiKGUpe2hlKGUsImh0bXg6YmVmb3JlQ2xlYW51cEVsZW1lbnQiKTtEZShlKTtpZihlLmNoaWxkcmVuKXtzZShlLmNoaWxkcmVuLGZ1bmN0aW9uKGUpe2IoZSl9KX19ZnVuY3Rpb24gTWUodCxlLG4pe2lmKHQgaW5zdGFuY2VvZiBFbGVtZW50JiZ0LnRhZ05hbWU9PT0iQk9EWSIpe3JldHVybiBWZSh0LGUsbil9bGV0IHI7Y29uc3Qgbz10LnByZXZpb3VzU2libGluZztjb25zdCBpPWModCk7aWYoIWkpe3JldHVybn1hKGksdCxlLG4pO2lmKG89PW51bGwpe3I9aS5maXJzdENoaWxkfWVsc2V7cj1vLm5leHRTaWJsaW5nfW4uZWx0cz1uLmVsdHMuZmlsdGVyKGZ1bmN0aW9uKGUpe3JldHVybiBlIT09dH0pO3doaWxlKHImJnIhPT10KXtpZihyIGluc3RhbmNlb2YgRWxlbWVudCl7bi5lbHRzLnB1c2gocil9cj1yLm5leHRTaWJsaW5nfWIodCk7aWYodCBpbnN0YW5jZW9mIEVsZW1lbnQpe3QucmVtb3ZlKCl9ZWxzZXt0LnBhcmVudE5vZGUucmVtb3ZlQ2hpbGQodCl9fWZ1bmN0aW9uIFhlKGUsdCxuKXtyZXR1cm4gYShlLGUuZmlyc3RDaGlsZCx0LG4pfWZ1bmN0aW9uIEZlKGUsdCxuKXtyZXR1cm4gYShjKGUpLGUsdCxuKX1mdW5jdGlvbiBCZShlLHQsbil7cmV0dXJuIGEoZSxudWxsLHQsbil9ZnVuY3Rpb24gVWUoZSx0LG4pe3JldHVybiBhKGMoZSksZS5uZXh0U2libGluZyx0LG4pfWZ1bmN0aW9uIGplKGUpe2IoZSk7Y29uc3QgdD1jKGUpO2lmKHQpe3JldHVybiB0LnJlbW92ZUNoaWxkKGUpfX1mdW5jdGlvbiBWZShlLHQsbil7Y29uc3Qgcj1lLmZpcnN0Q2hpbGQ7YShlLHIsdCxuKTtpZihyKXt3aGlsZShyLm5leHRTaWJsaW5nKXtiKHIubmV4dFNpYmxpbmcpO2UucmVtb3ZlQ2hpbGQoci5uZXh0U2libGluZyl9YihyKTtlLnJlbW92ZUNoaWxkKHIpfX1mdW5jdGlvbiBfZSh0LGUsbixyLG8pe3N3aXRjaCh0KXtjYXNlIm5vbmUiOnJldHVybjtjYXNlIm91dGVySFRNTCI6TWUobixyLG8pO3JldHVybjtjYXNlImFmdGVyYmVnaW4iOlhlKG4scixvKTtyZXR1cm47Y2FzZSJiZWZvcmViZWdpbiI6RmUobixyLG8pO3JldHVybjtjYXNlImJlZm9yZWVuZCI6QmUobixyLG8pO3JldHVybjtjYXNlImFmdGVyZW5kIjpVZShuLHIsbyk7cmV0dXJuO2Nhc2UiZGVsZXRlIjpqZShuKTtyZXR1cm47ZGVmYXVsdDp2YXIgaT1VbihlKTtmb3IobGV0IGU9MDtlPGkubGVuZ3RoO2UrKyl7Y29uc3Qgcz1pW2VdO3RyeXtjb25zdCBsPXMuaGFuZGxlU3dhcCh0LG4scixvKTtpZihsKXtpZihBcnJheS5pc0FycmF5KGwpKXtmb3IobGV0IGU9MDtlPGwubGVuZ3RoO2UrKyl7Y29uc3QgYz1sW2VdO2lmKGMubm9kZVR5cGUhPT1Ob2RlLlRFWFRfTk9ERSYmYy5ub2RlVHlwZSE9PU5vZGUuQ09NTUVOVF9OT0RFKXtvLnRhc2tzLnB1c2goQWUoYykpfX19cmV0dXJufX1jYXRjaChlKXtPKGUpfX1pZih0PT09ImlubmVySFRNTCIpe1ZlKG4scixvKX1lbHNle19lKFEuY29uZmlnLmRlZmF1bHRTd2FwU3R5bGUsZSxuLHIsbyl9fX1mdW5jdGlvbiB6ZShlLG4scil7dmFyIHQ9eChlLCJbaHgtc3dhcC1vb2JdLCBbZGF0YS1oeC1zd2FwLW9vYl0iKTtzZSh0LGZ1bmN0aW9uKGUpe2lmKFEuY29uZmlnLmFsbG93TmVzdGVkT29iU3dhcHN8fGUucGFyZW50RWxlbWVudD09PW51bGwpe2NvbnN0IHQ9dGUoZSwiaHgtc3dhcC1vb2IiKTtpZih0IT1udWxsKXtIZSh0LGUsbixyKX19ZWxzZXtlLnJlbW92ZUF0dHJpYnV0ZSgiaHgtc3dhcC1vb2IiKTtlLnJlbW92ZUF0dHJpYnV0ZSgiZGF0YS1oeC1zd2FwLW9vYiIpfX0pO3JldHVybiB0Lmxlbmd0aD4wfWZ1bmN0aW9uICRlKGUsdCxyLG8pe2lmKCFvKXtvPXt9fWU9eShlKTtjb25zdCBpPW8uY29udGV4dEVsZW1lbnQ/bShvLmNvbnRleHRFbGVtZW50LGZhbHNlKTpuZSgpO2NvbnN0IG49ZG9jdW1lbnQuYWN0aXZlRWxlbWVudDtsZXQgcz17fTt0cnl7cz17ZWx0Om4sc3RhcnQ6bj9uLnNlbGVjdGlvblN0YXJ0Om51bGwsZW5kOm4/bi5zZWxlY3Rpb25FbmQ6bnVsbH19Y2F0Y2goZSl7fWNvbnN0IGw9eG4oZSk7aWYoci5zd2FwU3R5bGU9PT0idGV4dENvbnRlbnQiKXtlLnRleHRDb250ZW50PXR9ZWxzZXtsZXQgbj1QKHQpO2wudGl0bGU9bi50aXRsZTtpZihvLnNlbGVjdE9PQil7Y29uc3QgdT1vLnNlbGVjdE9PQi5zcGxpdCgiLCIpO2ZvcihsZXQgdD0wO3Q8dS5sZW5ndGg7dCsrKXtjb25zdCBhPXVbdF0uc3BsaXQoIjoiLDIpO2xldCBlPWFbMF0udHJpbSgpO2lmKGUuaW5kZXhPZigiIyIpPT09MCl7ZT1lLnN1YnN0cmluZygxKX1jb25zdCBmPWFbMV18fCJ0cnVlIjtjb25zdCBoPW4ucXVlcnlTZWxlY3RvcigiIyIrZSk7aWYoaCl7SGUoZixoLGwsaSl9fX16ZShuLGwsaSk7c2UoeChuLCJ0ZW1wbGF0ZSIpLGZ1bmN0aW9uKGUpe2lmKGUuY29udGVudCYmemUoZS5jb250ZW50LGwsaSkpe2UucmVtb3ZlKCl9fSk7aWYoby5zZWxlY3Qpe2NvbnN0IGQ9bmUoKS5jcmVhdGVEb2N1bWVudEZyYWdtZW50KCk7c2Uobi5xdWVyeVNlbGVjdG9yQWxsKG8uc2VsZWN0KSxmdW5jdGlvbihlKXtkLmFwcGVuZENoaWxkKGUpfSk7bj1kfXFlKG4pO19lKHIuc3dhcFN0eWxlLG8uY29udGV4dEVsZW1lbnQsZSxuLGwpO1RlKCl9aWYocy5lbHQmJiFsZShzLmVsdCkmJmVlKHMuZWx0LCJpZCIpKXtjb25zdCBnPWRvY3VtZW50LmdldEVsZW1lbnRCeUlkKGVlKHMuZWx0LCJpZCIpKTtjb25zdCBwPXtwcmV2ZW50U2Nyb2xsOnIuZm9jdXNTY3JvbGwhPT11bmRlZmluZWQ/IXIuZm9jdXNTY3JvbGw6IVEuY29uZmlnLmRlZmF1bHRGb2N1c1Njcm9sbH07aWYoZyl7aWYocy5zdGFydCYmZy5zZXRTZWxlY3Rpb25SYW5nZSl7dHJ5e2cuc2V0U2VsZWN0aW9uUmFuZ2Uocy5zdGFydCxzLmVuZCl9Y2F0Y2goZSl7fX1nLmZvY3VzKHApfX1lLmNsYXNzTGlzdC5yZW1vdmUoUS5jb25maWcuc3dhcHBpbmdDbGFzcyk7c2UobC5lbHRzLGZ1bmN0aW9uKGUpe2lmKGUuY2xhc3NMaXN0KXtlLmNsYXNzTGlzdC5hZGQoUS5jb25maWcuc2V0dGxpbmdDbGFzcyl9aGUoZSwiaHRteDphZnRlclN3YXAiLG8uZXZlbnRJbmZvKX0pO2lmKG8uYWZ0ZXJTd2FwQ2FsbGJhY2spe28uYWZ0ZXJTd2FwQ2FsbGJhY2soKX1pZighci5pZ25vcmVUaXRsZSl7a24obC50aXRsZSl9Y29uc3QgYz1mdW5jdGlvbigpe3NlKGwudGFza3MsZnVuY3Rpb24oZSl7ZS5jYWxsKCl9KTtzZShsLmVsdHMsZnVuY3Rpb24oZSl7aWYoZS5jbGFzc0xpc3Qpe2UuY2xhc3NMaXN0LnJlbW92ZShRLmNvbmZpZy5zZXR0bGluZ0NsYXNzKX1oZShlLCJodG14OmFmdGVyU2V0dGxlIixvLmV2ZW50SW5mbyl9KTtpZihvLmFuY2hvcil7Y29uc3QgZT11ZSh5KCIjIitvLmFuY2hvcikpO2lmKGUpe2Uuc2Nyb2xsSW50b1ZpZXcoe2Jsb2NrOiJzdGFydCIsYmVoYXZpb3I6ImF1dG8ifSl9fXluKGwuZWx0cyxyKTtpZihvLmFmdGVyU2V0dGxlQ2FsbGJhY2spe28uYWZ0ZXJTZXR0bGVDYWxsYmFjaygpfX07aWYoci5zZXR0bGVEZWxheT4wKXtFKCkuc2V0VGltZW91dChjLHIuc2V0dGxlRGVsYXkpfWVsc2V7YygpfX1mdW5jdGlvbiBKZShlLHQsbil7Y29uc3Qgcj1lLmdldFJlc3BvbnNlSGVhZGVyKHQpO2lmKHIuaW5kZXhPZigieyIpPT09MCl7Y29uc3Qgbz1TKHIpO2Zvcihjb25zdCBpIGluIG8pe2lmKG8uaGFzT3duUHJvcGVydHkoaSkpe2xldCBlPW9baV07aWYoRChlKSl7bj1lLnRhcmdldCE9PXVuZGVmaW5lZD9lLnRhcmdldDpufWVsc2V7ZT17dmFsdWU6ZX19aGUobixpLGUpfX19ZWxzZXtjb25zdCBzPXIuc3BsaXQoIiwiKTtmb3IobGV0IGU9MDtlPHMubGVuZ3RoO2UrKyl7aGUobixzW2VdLnRyaW0oKSxbXSl9fX1jb25zdCBLZT0vXHMvO2NvbnN0IHY9L1tccyxdLztjb25zdCBHZT0vW18kYS16QS1aXS87Y29uc3QgV2U9L1tfJGEtekEtWjAtOV0vO2NvbnN0IFplPVsnIicsIiciLCIvIl07Y29uc3Qgdz0vW15cc10vO2NvbnN0IFllPS9beyhdLztjb25zdCBRZT0vW30pXS87ZnVuY3Rpb24gZXQoZSl7Y29uc3QgdD1bXTtsZXQgbj0wO3doaWxlKG48ZS5sZW5ndGgpe2lmKEdlLmV4ZWMoZS5jaGFyQXQobikpKXt2YXIgcj1uO3doaWxlKFdlLmV4ZWMoZS5jaGFyQXQobisxKSkpe24rK310LnB1c2goZS5zdWJzdHJpbmcocixuKzEpKX1lbHNlIGlmKFplLmluZGV4T2YoZS5jaGFyQXQobikpIT09LTEpe2NvbnN0IG89ZS5jaGFyQXQobik7dmFyIHI9bjtuKys7d2hpbGUobjxlLmxlbmd0aCYmZS5jaGFyQXQobikhPT1vKXtpZihlLmNoYXJBdChuKT09PSJcXCIpe24rK31uKyt9dC5wdXNoKGUuc3Vic3RyaW5nKHIsbisxKSl9ZWxzZXtjb25zdCBpPWUuY2hhckF0KG4pO3QucHVzaChpKX1uKyt9cmV0dXJuIHR9ZnVuY3Rpb24gdHQoZSx0LG4pe3JldHVybiBHZS5leGVjKGUuY2hhckF0KDApKSYmZSE9PSJ0cnVlIiYmZSE9PSJmYWxzZSImJmUhPT0idGhpcyImJmUhPT1uJiZ0IT09Ii4ifWZ1bmN0aW9uIG50KHIsbyxpKXtpZihvWzBdPT09IlsiKXtvLnNoaWZ0KCk7bGV0IGU9MTtsZXQgdD0iIHJldHVybiAoZnVuY3Rpb24oIitpKyIpeyByZXR1cm4gKCI7bGV0IG49bnVsbDt3aGlsZShvLmxlbmd0aD4wKXtjb25zdCBzPW9bMF07aWYocz09PSJdIil7ZS0tO2lmKGU9PT0wKXtpZihuPT09bnVsbCl7dD10KyJ0cnVlIn1vLnNoaWZ0KCk7dCs9Iil9KSI7dHJ5e2NvbnN0IGw9dm4ocixmdW5jdGlvbigpe3JldHVybiBGdW5jdGlvbih0KSgpfSxmdW5jdGlvbigpe3JldHVybiB0cnVlfSk7bC5zb3VyY2U9dDtyZXR1cm4gbH1jYXRjaChlKXtmZShuZSgpLmJvZHksImh0bXg6c3ludGF4OmVycm9yIix7ZXJyb3I6ZSxzb3VyY2U6dH0pO3JldHVybiBudWxsfX19ZWxzZSBpZihzPT09IlsiKXtlKyt9aWYodHQocyxuLGkpKXt0Kz0iKCgiK2krIi4iK3MrIikgPyAoIitpKyIuIitzKyIpIDogKHdpbmRvdy4iK3MrIikpIn1lbHNle3Q9dCtzfW49by5zaGlmdCgpfX19ZnVuY3Rpb24gQyhlLHQpe2xldCBuPSIiO3doaWxlKGUubGVuZ3RoPjAmJiF0LnRlc3QoZVswXSkpe24rPWUuc2hpZnQoKX1yZXR1cm4gbn1mdW5jdGlvbiBydChlKXtsZXQgdDtpZihlLmxlbmd0aD4wJiZZZS50ZXN0KGVbMF0pKXtlLnNoaWZ0KCk7dD1DKGUsUWUpLnRyaW0oKTtlLnNoaWZ0KCl9ZWxzZXt0PUMoZSx2KX1yZXR1cm4gdH1jb25zdCBvdD0iaW5wdXQsIHRleHRhcmVhLCBzZWxlY3QiO2Z1bmN0aW9uIGl0KGUsdCxuKXtjb25zdCByPVtdO2NvbnN0IG89ZXQodCk7ZG97QyhvLHcpO2NvbnN0IGw9by5sZW5ndGg7Y29uc3QgYz1DKG8sL1ssXFtcc10vKTtpZihjIT09IiIpe2lmKGM9PT0iZXZlcnkiKXtjb25zdCB1PXt0cmlnZ2VyOiJldmVyeSJ9O0Mobyx3KTt1LnBvbGxJbnRlcnZhbD1kKEMobywvWyxcW1xzXS8pKTtDKG8sdyk7dmFyIGk9bnQoZSxvLCJldmVudCIpO2lmKGkpe3UuZXZlbnRGaWx0ZXI9aX1yLnB1c2godSl9ZWxzZXtjb25zdCBhPXt0cmlnZ2VyOmN9O3ZhciBpPW50KGUsbywiZXZlbnQiKTtpZihpKXthLmV2ZW50RmlsdGVyPWl9QyhvLHcpO3doaWxlKG8ubGVuZ3RoPjAmJm9bMF0hPT0iLCIpe2NvbnN0IGY9by5zaGlmdCgpO2lmKGY9PT0iY2hhbmdlZCIpe2EuY2hhbmdlZD10cnVlfWVsc2UgaWYoZj09PSJvbmNlIil7YS5vbmNlPXRydWV9ZWxzZSBpZihmPT09ImNvbnN1bWUiKXthLmNvbnN1bWU9dHJ1ZX1lbHNlIGlmKGY9PT0iZGVsYXkiJiZvWzBdPT09IjoiKXtvLnNoaWZ0KCk7YS5kZWxheT1kKEMobyx2KSl9ZWxzZSBpZihmPT09ImZyb20iJiZvWzBdPT09IjoiKXtvLnNoaWZ0KCk7aWYoWWUudGVzdChvWzBdKSl7dmFyIHM9cnQobyl9ZWxzZXt2YXIgcz1DKG8sdik7aWYocz09PSJjbG9zZXN0Inx8cz09PSJmaW5kInx8cz09PSJuZXh0Inx8cz09PSJwcmV2aW91cyIpe28uc2hpZnQoKTtjb25zdCBoPXJ0KG8pO2lmKGgubGVuZ3RoPjApe3MrPSIgIitofX19YS5mcm9tPXN9ZWxzZSBpZihmPT09InRhcmdldCImJm9bMF09PT0iOiIpe28uc2hpZnQoKTthLnRhcmdldD1ydChvKX1lbHNlIGlmKGY9PT0idGhyb3R0bGUiJiZvWzBdPT09IjoiKXtvLnNoaWZ0KCk7YS50aHJvdHRsZT1kKEMobyx2KSl9ZWxzZSBpZihmPT09InF1ZXVlIiYmb1swXT09PSI6Iil7by5zaGlmdCgpO2EucXVldWU9QyhvLHYpfWVsc2UgaWYoZj09PSJyb290IiYmb1swXT09PSI6Iil7by5zaGlmdCgpO2FbZl09cnQobyl9ZWxzZSBpZihmPT09InRocmVzaG9sZCImJm9bMF09PT0iOiIpe28uc2hpZnQoKTthW2ZdPUMobyx2KX1lbHNle2ZlKGUsImh0bXg6c3ludGF4OmVycm9yIix7dG9rZW46by5zaGlmdCgpfSl9QyhvLHcpfXIucHVzaChhKX19aWYoby5sZW5ndGg9PT1sKXtmZShlLCJodG14OnN5bnRheDplcnJvciIse3Rva2VuOm8uc2hpZnQoKX0pfUMobyx3KX13aGlsZShvWzBdPT09IiwiJiZvLnNoaWZ0KCkpO2lmKG4pe25bdF09cn1yZXR1cm4gcn1mdW5jdGlvbiBzdChlKXtjb25zdCB0PXRlKGUsImh4LXRyaWdnZXIiKTtsZXQgbj1bXTtpZih0KXtjb25zdCByPVEuY29uZmlnLnRyaWdnZXJTcGVjc0NhY2hlO249ciYmclt0XXx8aXQoZSx0LHIpfWlmKG4ubGVuZ3RoPjApe3JldHVybiBufWVsc2UgaWYoaChlLCJmb3JtIikpe3JldHVyblt7dHJpZ2dlcjoic3VibWl0In1dfWVsc2UgaWYoaChlLCdpbnB1dFt0eXBlPSJidXR0b24iXSwgaW5wdXRbdHlwZT0ic3VibWl0Il0nKSl7cmV0dXJuW3t0cmlnZ2VyOiJjbGljayJ9XX1lbHNlIGlmKGgoZSxvdCkpe3JldHVyblt7dHJpZ2dlcjoiY2hhbmdlIn1dfWVsc2V7cmV0dXJuW3t0cmlnZ2VyOiJjbGljayJ9XX19ZnVuY3Rpb24gbHQoZSl7aWUoZSkuY2FuY2VsbGVkPXRydWV9ZnVuY3Rpb24gY3QoZSx0LG4pe2NvbnN0IHI9aWUoZSk7ci50aW1lb3V0PUUoKS5zZXRUaW1lb3V0KGZ1bmN0aW9uKCl7aWYobGUoZSkmJnIuY2FuY2VsbGVkIT09dHJ1ZSl7aWYoIWd0KG4sZSxNdCgiaHg6cG9sbDp0cmlnZ2VyIix7dHJpZ2dlclNwZWM6bix0YXJnZXQ6ZX0pKSl7dChlKX1jdChlLHQsbil9fSxuLnBvbGxJbnRlcnZhbCl9ZnVuY3Rpb24gdXQoZSl7cmV0dXJuIGxvY2F0aW9uLmhvc3RuYW1lPT09ZS5ob3N0bmFtZSYmZWUoZSwiaHJlZiIpJiZlZShlLCJocmVmIikuaW5kZXhPZigiIyIpIT09MH1mdW5jdGlvbiBhdChlKXtyZXR1cm4gZyhlLFEuY29uZmlnLmRpc2FibGVTZWxlY3Rvcil9ZnVuY3Rpb24gZnQodCxuLGUpe2lmKHQgaW5zdGFuY2VvZiBIVE1MQW5jaG9yRWxlbWVudCYmdXQodCkmJih0LnRhcmdldD09PSIifHx0LnRhcmdldD09PSJfc2VsZiIpfHx0LnRhZ05hbWU9PT0iRk9STSImJlN0cmluZyhlZSh0LCJtZXRob2QiKSkudG9Mb3dlckNhc2UoKSE9PSJkaWFsb2ciKXtuLmJvb3N0ZWQ9dHJ1ZTtsZXQgcixvO2lmKHQudGFnTmFtZT09PSJBIil7cj0iZ2V0IjtvPWVlKHQsImhyZWYiKX1lbHNle2NvbnN0IGk9ZWUodCwibWV0aG9kIik7cj1pP2kudG9Mb3dlckNhc2UoKToiZ2V0IjtvPWVlKHQsImFjdGlvbiIpO2lmKG89PW51bGx8fG89PT0iIil7bz1uZSgpLmxvY2F0aW9uLmhyZWZ9aWYocj09PSJnZXQiJiZvLmluY2x1ZGVzKCI/Iikpe289by5yZXBsYWNlKC9cP1teI10rLywiIil9fWUuZm9yRWFjaChmdW5jdGlvbihlKXtwdCh0LGZ1bmN0aW9uKGUsdCl7Y29uc3Qgbj11ZShlKTtpZihhdChuKSl7YihuKTtyZXR1cm59ZGUocixvLG4sdCl9LG4sZSx0cnVlKX0pfX1mdW5jdGlvbiBodChlLHQpe2NvbnN0IG49dWUodCk7aWYoIW4pe3JldHVybiBmYWxzZX1pZihlLnR5cGU9PT0ic3VibWl0Inx8ZS50eXBlPT09ImNsaWNrIil7aWYobi50YWdOYW1lPT09IkZPUk0iKXtyZXR1cm4gdHJ1ZX1pZihoKG4sJ2lucHV0W3R5cGU9InN1Ym1pdCJdLCBidXR0b24nKSYmKGgobiwiW2Zvcm1dIil8fGcobiwiZm9ybSIpIT09bnVsbCkpe3JldHVybiB0cnVlfWlmKG4gaW5zdGFuY2VvZiBIVE1MQW5jaG9yRWxlbWVudCYmbi5ocmVmJiYobi5nZXRBdHRyaWJ1dGUoImhyZWYiKT09PSIjInx8bi5nZXRBdHRyaWJ1dGUoImhyZWYiKS5pbmRleE9mKCIjIikhPT0wKSl7cmV0dXJuIHRydWV9fXJldHVybiBmYWxzZX1mdW5jdGlvbiBkdChlLHQpe3JldHVybiBpZShlKS5ib29zdGVkJiZlIGluc3RhbmNlb2YgSFRNTEFuY2hvckVsZW1lbnQmJnQudHlwZT09PSJjbGljayImJih0LmN0cmxLZXl8fHQubWV0YUtleSl9ZnVuY3Rpb24gZ3QoZSx0LG4pe2NvbnN0IHI9ZS5ldmVudEZpbHRlcjtpZihyKXt0cnl7cmV0dXJuIHIuY2FsbCh0LG4pIT09dHJ1ZX1jYXRjaChlKXtjb25zdCBvPXIuc291cmNlO2ZlKG5lKCkuYm9keSwiaHRteDpldmVudEZpbHRlcjplcnJvciIse2Vycm9yOmUsc291cmNlOm99KTtyZXR1cm4gdHJ1ZX19cmV0dXJuIGZhbHNlfWZ1bmN0aW9uIHB0KGwsYyxlLHUsYSl7Y29uc3QgZj1pZShsKTtsZXQgdDtpZih1LmZyb20pe3Q9cChsLHUuZnJvbSl9ZWxzZXt0PVtsXX1pZih1LmNoYW5nZWQpe2lmKCEoImxhc3RWYWx1ZSJpbiBmKSl7Zi5sYXN0VmFsdWU9bmV3IFdlYWtNYXB9dC5mb3JFYWNoKGZ1bmN0aW9uKGUpe2lmKCFmLmxhc3RWYWx1ZS5oYXModSkpe2YubGFzdFZhbHVlLnNldCh1LG5ldyBXZWFrTWFwKX1mLmxhc3RWYWx1ZS5nZXQodSkuc2V0KGUsZS52YWx1ZSl9KX1zZSh0LGZ1bmN0aW9uKGkpe2NvbnN0IHM9ZnVuY3Rpb24oZSl7aWYoIWxlKGwpKXtpLnJlbW92ZUV2ZW50TGlzdGVuZXIodS50cmlnZ2VyLHMpO3JldHVybn1pZihkdChsLGUpKXtyZXR1cm59aWYoYXx8aHQoZSxsKSl7ZS5wcmV2ZW50RGVmYXVsdCgpfWlmKGd0KHUsbCxlKSl7cmV0dXJufWNvbnN0IHQ9aWUoZSk7dC50cmlnZ2VyU3BlYz11O2lmKHQuaGFuZGxlZEZvcj09bnVsbCl7dC5oYW5kbGVkRm9yPVtdfWlmKHQuaGFuZGxlZEZvci5pbmRleE9mKGwpPDApe3QuaGFuZGxlZEZvci5wdXNoKGwpO2lmKHUuY29uc3VtZSl7ZS5zdG9wUHJvcGFnYXRpb24oKX1pZih1LnRhcmdldCYmZS50YXJnZXQpe2lmKCFoKHVlKGUudGFyZ2V0KSx1LnRhcmdldCkpe3JldHVybn19aWYodS5vbmNlKXtpZihmLnRyaWdnZXJlZE9uY2Upe3JldHVybn1lbHNle2YudHJpZ2dlcmVkT25jZT10cnVlfX1pZih1LmNoYW5nZWQpe2NvbnN0IG49ZXZlbnQudGFyZ2V0O2NvbnN0IHI9bi52YWx1ZTtjb25zdCBvPWYubGFzdFZhbHVlLmdldCh1KTtpZihvLmhhcyhuKSYmby5nZXQobik9PT1yKXtyZXR1cm59by5zZXQobixyKX1pZihmLmRlbGF5ZWQpe2NsZWFyVGltZW91dChmLmRlbGF5ZWQpfWlmKGYudGhyb3R0bGUpe3JldHVybn1pZih1LnRocm90dGxlPjApe2lmKCFmLnRocm90dGxlKXtoZShsLCJodG14OnRyaWdnZXIiKTtjKGwsZSk7Zi50aHJvdHRsZT1FKCkuc2V0VGltZW91dChmdW5jdGlvbigpe2YudGhyb3R0bGU9bnVsbH0sdS50aHJvdHRsZSl9fWVsc2UgaWYodS5kZWxheT4wKXtmLmRlbGF5ZWQ9RSgpLnNldFRpbWVvdXQoZnVuY3Rpb24oKXtoZShsLCJodG14OnRyaWdnZXIiKTtjKGwsZSl9LHUuZGVsYXkpfWVsc2V7aGUobCwiaHRteDp0cmlnZ2VyIik7YyhsLGUpfX19O2lmKGUubGlzdGVuZXJJbmZvcz09bnVsbCl7ZS5saXN0ZW5lckluZm9zPVtdfWUubGlzdGVuZXJJbmZvcy5wdXNoKHt0cmlnZ2VyOnUudHJpZ2dlcixsaXN0ZW5lcjpzLG9uOml9KTtpLmFkZEV2ZW50TGlzdGVuZXIodS50cmlnZ2VyLHMpfSl9bGV0IG10PWZhbHNlO2xldCB4dD1udWxsO2Z1bmN0aW9uIHl0KCl7aWYoIXh0KXt4dD1mdW5jdGlvbigpe210PXRydWV9O3dpbmRvdy5hZGRFdmVudExpc3RlbmVyKCJzY3JvbGwiLHh0KTt3aW5kb3cuYWRkRXZlbnRMaXN0ZW5lcigicmVzaXplIix4dCk7c2V0SW50ZXJ2YWwoZnVuY3Rpb24oKXtpZihtdCl7bXQ9ZmFsc2U7c2UobmUoKS5xdWVyeVNlbGVjdG9yQWxsKCJbaHgtdHJpZ2dlcio9J3JldmVhbGVkJ10sW2RhdGEtaHgtdHJpZ2dlcio9J3JldmVhbGVkJ10iKSxmdW5jdGlvbihlKXtidChlKX0pfX0sMjAwKX19ZnVuY3Rpb24gYnQoZSl7aWYoIXMoZSwiZGF0YS1oeC1yZXZlYWxlZCIpJiZYKGUpKXtlLnNldEF0dHJpYnV0ZSgiZGF0YS1oeC1yZXZlYWxlZCIsInRydWUiKTtjb25zdCB0PWllKGUpO2lmKHQuaW5pdEhhc2gpe2hlKGUsInJldmVhbGVkIil9ZWxzZXtlLmFkZEV2ZW50TGlzdGVuZXIoImh0bXg6YWZ0ZXJQcm9jZXNzTm9kZSIsZnVuY3Rpb24oKXtoZShlLCJyZXZlYWxlZCIpfSx7b25jZTp0cnVlfSl9fX1mdW5jdGlvbiB2dChlLHQsbixyKXtjb25zdCBvPWZ1bmN0aW9uKCl7aWYoIW4ubG9hZGVkKXtuLmxvYWRlZD10cnVlO2hlKGUsImh0bXg6dHJpZ2dlciIpO3QoZSl9fTtpZihyPjApe0UoKS5zZXRUaW1lb3V0KG8scil9ZWxzZXtvKCl9fWZ1bmN0aW9uIHd0KHQsbixlKXtsZXQgaT1mYWxzZTtzZShyLGZ1bmN0aW9uKHIpe2lmKHModCwiaHgtIityKSl7Y29uc3Qgbz10ZSh0LCJoeC0iK3IpO2k9dHJ1ZTtuLnBhdGg9bztuLnZlcmI9cjtlLmZvckVhY2goZnVuY3Rpb24oZSl7U3QodCxlLG4sZnVuY3Rpb24oZSx0KXtjb25zdCBuPXVlKGUpO2lmKGcobixRLmNvbmZpZy5kaXNhYmxlU2VsZWN0b3IpKXtiKG4pO3JldHVybn1kZShyLG8sbix0KX0pfSl9fSk7cmV0dXJuIGl9ZnVuY3Rpb24gU3QocixlLHQsbil7aWYoZS50cmlnZ2VyPT09InJldmVhbGVkIil7eXQoKTtwdChyLG4sdCxlKTtidCh1ZShyKSl9ZWxzZSBpZihlLnRyaWdnZXI9PT0iaW50ZXJzZWN0Iil7Y29uc3Qgbz17fTtpZihlLnJvb3Qpe28ucm9vdD1hZShyLGUucm9vdCl9aWYoZS50aHJlc2hvbGQpe28udGhyZXNob2xkPXBhcnNlRmxvYXQoZS50aHJlc2hvbGQpfWNvbnN0IGk9bmV3IEludGVyc2VjdGlvbk9ic2VydmVyKGZ1bmN0aW9uKHQpe2ZvcihsZXQgZT0wO2U8dC5sZW5ndGg7ZSsrKXtjb25zdCBuPXRbZV07aWYobi5pc0ludGVyc2VjdGluZyl7aGUociwiaW50ZXJzZWN0Iik7YnJlYWt9fX0sbyk7aS5vYnNlcnZlKHVlKHIpKTtwdCh1ZShyKSxuLHQsZSl9ZWxzZSBpZighdC5maXJzdEluaXRDb21wbGV0ZWQmJmUudHJpZ2dlcj09PSJsb2FkIil7aWYoIWd0KGUscixNdCgibG9hZCIse2VsdDpyfSkpKXt2dCh1ZShyKSxuLHQsZS5kZWxheSl9fWVsc2UgaWYoZS5wb2xsSW50ZXJ2YWw+MCl7dC5wb2xsaW5nPXRydWU7Y3QodWUociksbixlKX1lbHNle3B0KHIsbix0LGUpfX1mdW5jdGlvbiBFdChlKXtjb25zdCB0PXVlKGUpO2lmKCF0KXtyZXR1cm4gZmFsc2V9Y29uc3Qgbj10LmF0dHJpYnV0ZXM7Zm9yKGxldCBlPTA7ZTxuLmxlbmd0aDtlKyspe2NvbnN0IHI9bltlXS5uYW1lO2lmKGwociwiaHgtb246Iil8fGwociwiZGF0YS1oeC1vbjoiKXx8bChyLCJoeC1vbi0iKXx8bChyLCJkYXRhLWh4LW9uLSIpKXtyZXR1cm4gdHJ1ZX19cmV0dXJuIGZhbHNlfWNvbnN0IEN0PShuZXcgWFBhdGhFdmFsdWF0b3IpLmNyZWF0ZUV4cHJlc3Npb24oJy4vLypbQCpbIHN0YXJ0cy13aXRoKG5hbWUoKSwgImh4LW9uOiIpIG9yIHN0YXJ0cy13aXRoKG5hbWUoKSwgImRhdGEtaHgtb246Iikgb3InKycgc3RhcnRzLXdpdGgobmFtZSgpLCAiaHgtb24tIikgb3Igc3RhcnRzLXdpdGgobmFtZSgpLCAiZGF0YS1oeC1vbi0iKSBdXScpO2Z1bmN0aW9uIE90KGUsdCl7aWYoRXQoZSkpe3QucHVzaCh1ZShlKSl9Y29uc3Qgbj1DdC5ldmFsdWF0ZShlKTtsZXQgcj1udWxsO3doaWxlKHI9bi5pdGVyYXRlTmV4dCgpKXQucHVzaCh1ZShyKSl9ZnVuY3Rpb24gUnQoZSl7Y29uc3QgdD1bXTtpZihlIGluc3RhbmNlb2YgRG9jdW1lbnRGcmFnbWVudCl7Zm9yKGNvbnN0IG4gb2YgZS5jaGlsZE5vZGVzKXtPdChuLHQpfX1lbHNle090KGUsdCl9cmV0dXJuIHR9ZnVuY3Rpb24gSHQoZSl7aWYoZS5xdWVyeVNlbGVjdG9yQWxsKXtjb25zdCBuPSIsIFtoeC1ib29zdF0gYSwgW2RhdGEtaHgtYm9vc3RdIGEsIGFbaHgtYm9vc3RdLCBhW2RhdGEtaHgtYm9vc3RdIjtjb25zdCByPVtdO2Zvcihjb25zdCBpIGluIE1uKXtjb25zdCBzPU1uW2ldO2lmKHMuZ2V0U2VsZWN0b3JzKXt2YXIgdD1zLmdldFNlbGVjdG9ycygpO2lmKHQpe3IucHVzaCh0KX19fWNvbnN0IG89ZS5xdWVyeVNlbGVjdG9yQWxsKEgrbisiLCBmb3JtLCBbdHlwZT0nc3VibWl0J10sIisiIFtoeC1leHRdLCBbZGF0YS1oeC1leHRdLCBbaHgtdHJpZ2dlcl0sIFtkYXRhLWh4LXRyaWdnZXJdIityLmZsYXQoKS5tYXAoZT0+IiwgIitlKS5qb2luKCIiKSk7cmV0dXJuIG99ZWxzZXtyZXR1cm5bXX19ZnVuY3Rpb24gVHQoZSl7Y29uc3QgdD1nKHVlKGUudGFyZ2V0KSwiYnV0dG9uLCBpbnB1dFt0eXBlPSdzdWJtaXQnXSIpO2NvbnN0IG49THQoZSk7aWYobil7bi5sYXN0QnV0dG9uQ2xpY2tlZD10fX1mdW5jdGlvbiBxdChlKXtjb25zdCB0PUx0KGUpO2lmKHQpe3QubGFzdEJ1dHRvbkNsaWNrZWQ9bnVsbH19ZnVuY3Rpb24gTHQoZSl7Y29uc3QgdD1nKHVlKGUudGFyZ2V0KSwiYnV0dG9uLCBpbnB1dFt0eXBlPSdzdWJtaXQnXSIpO2lmKCF0KXtyZXR1cm59Y29uc3Qgbj15KCIjIitlZSh0LCJmb3JtIiksdC5nZXRSb290Tm9kZSgpKXx8Zyh0LCJmb3JtIik7aWYoIW4pe3JldHVybn1yZXR1cm4gaWUobil9ZnVuY3Rpb24gQXQoZSl7ZS5hZGRFdmVudExpc3RlbmVyKCJjbGljayIsVHQpO2UuYWRkRXZlbnRMaXN0ZW5lcigiZm9jdXNpbiIsVHQpO2UuYWRkRXZlbnRMaXN0ZW5lcigiZm9jdXNvdXQiLHF0KX1mdW5jdGlvbiBOdCh0LGUsbil7Y29uc3Qgcj1pZSh0KTtpZighQXJyYXkuaXNBcnJheShyLm9uSGFuZGxlcnMpKXtyLm9uSGFuZGxlcnM9W119bGV0IG87Y29uc3QgaT1mdW5jdGlvbihlKXt2bih0LGZ1bmN0aW9uKCl7aWYoYXQodCkpe3JldHVybn1pZighbyl7bz1uZXcgRnVuY3Rpb24oImV2ZW50IixuKX1vLmNhbGwodCxlKX0pfTt0LmFkZEV2ZW50TGlzdGVuZXIoZSxpKTtyLm9uSGFuZGxlcnMucHVzaCh7ZXZlbnQ6ZSxsaXN0ZW5lcjppfSl9ZnVuY3Rpb24gSXQodCl7a2UodCk7Zm9yKGxldCBlPTA7ZTx0LmF0dHJpYnV0ZXMubGVuZ3RoO2UrKyl7Y29uc3Qgbj10LmF0dHJpYnV0ZXNbZV0ubmFtZTtjb25zdCByPXQuYXR0cmlidXRlc1tlXS52YWx1ZTtpZihsKG4sImh4LW9uIil8fGwobiwiZGF0YS1oeC1vbiIpKXtjb25zdCBvPW4uaW5kZXhPZigiLW9uIikrMztjb25zdCBpPW4uc2xpY2UobyxvKzEpO2lmKGk9PT0iLSJ8fGk9PT0iOiIpe2xldCBlPW4uc2xpY2UobysxKTtpZihsKGUsIjoiKSl7ZT0iaHRteCIrZX1lbHNlIGlmKGwoZSwiLSIpKXtlPSJodG14OiIrZS5zbGljZSgxKX1lbHNlIGlmKGwoZSwiaHRteC0iKSl7ZT0iaHRteDoiK2Uuc2xpY2UoNSl9TnQodCxlLHIpfX19fWZ1bmN0aW9uIFB0KHQpe2lmKGcodCxRLmNvbmZpZy5kaXNhYmxlU2VsZWN0b3IpKXtiKHQpO3JldHVybn1jb25zdCBuPWllKHQpO2NvbnN0IGU9UGUodCk7aWYobi5pbml0SGFzaCE9PWUpe0RlKHQpO24uaW5pdEhhc2g9ZTtoZSh0LCJodG14OmJlZm9yZVByb2Nlc3NOb2RlIik7Y29uc3Qgcj1zdCh0KTtjb25zdCBvPXd0KHQsbixyKTtpZighbyl7aWYocmUodCwiaHgtYm9vc3QiKT09PSJ0cnVlIil7ZnQodCxuLHIpfWVsc2UgaWYocyh0LCJoeC10cmlnZ2VyIikpe3IuZm9yRWFjaChmdW5jdGlvbihlKXtTdCh0LGUsbixmdW5jdGlvbigpe30pfSl9fWlmKHQudGFnTmFtZT09PSJGT1JNInx8ZWUodCwidHlwZSIpPT09InN1Ym1pdCImJnModCwiZm9ybSIpKXtBdCh0KX1uLmZpcnN0SW5pdENvbXBsZXRlZD10cnVlO2hlKHQsImh0bXg6YWZ0ZXJQcm9jZXNzTm9kZSIpfX1mdW5jdGlvbiBrdChlKXtlPXkoZSk7aWYoZyhlLFEuY29uZmlnLmRpc2FibGVTZWxlY3Rvcikpe2IoZSk7cmV0dXJufVB0KGUpO3NlKEh0KGUpLGZ1bmN0aW9uKGUpe1B0KGUpfSk7c2UoUnQoZSksSXQpfWZ1bmN0aW9uIER0KGUpe3JldHVybiBlLnJlcGxhY2UoLyhbYS16MC05XSkoW0EtWl0pL2csIiQxLSQyIikudG9Mb3dlckNhc2UoKX1mdW5jdGlvbiBNdChlLHQpe2xldCBuO2lmKHdpbmRvdy5DdXN0b21FdmVudCYmdHlwZW9mIHdpbmRvdy5DdXN0b21FdmVudD09PSJmdW5jdGlvbiIpe249bmV3IEN1c3RvbUV2ZW50KGUse2J1YmJsZXM6dHJ1ZSxjYW5jZWxhYmxlOnRydWUsY29tcG9zZWQ6dHJ1ZSxkZXRhaWw6dH0pfWVsc2V7bj1uZSgpLmNyZWF0ZUV2ZW50KCJDdXN0b21FdmVudCIpO24uaW5pdEN1c3RvbUV2ZW50KGUsdHJ1ZSx0cnVlLHQpfXJldHVybiBufWZ1bmN0aW9uIGZlKGUsdCxuKXtoZShlLHQsY2Uoe2Vycm9yOnR9LG4pKX1mdW5jdGlvbiBYdChlKXtyZXR1cm4gZT09PSJodG14OmFmdGVyUHJvY2Vzc05vZGUifWZ1bmN0aW9uIEZ0KGUsdCl7c2UoVW4oZSksZnVuY3Rpb24oZSl7dHJ5e3QoZSl9Y2F0Y2goZSl7TyhlKX19KX1mdW5jdGlvbiBPKGUpe2lmKGNvbnNvbGUuZXJyb3Ipe2NvbnNvbGUuZXJyb3IoZSl9ZWxzZSBpZihjb25zb2xlLmxvZyl7Y29uc29sZS5sb2coIkVSUk9SOiAiLGUpfX1mdW5jdGlvbiBoZShlLHQsbil7ZT15KGUpO2lmKG49PW51bGwpe249e319bi5lbHQ9ZTtjb25zdCByPU10KHQsbik7aWYoUS5sb2dnZXImJiFYdCh0KSl7US5sb2dnZXIoZSx0LG4pfWlmKG4uZXJyb3Ipe08obi5lcnJvcik7aGUoZSwiaHRteDplcnJvciIse2Vycm9ySW5mbzpufSl9bGV0IG89ZS5kaXNwYXRjaEV2ZW50KHIpO2NvbnN0IGk9RHQodCk7aWYobyYmaSE9PXQpe2NvbnN0IHM9TXQoaSxyLmRldGFpbCk7bz1vJiZlLmRpc3BhdGNoRXZlbnQocyl9RnQodWUoZSksZnVuY3Rpb24oZSl7bz1vJiYoZS5vbkV2ZW50KHQscikhPT1mYWxzZSYmIXIuZGVmYXVsdFByZXZlbnRlZCl9KTtyZXR1cm4gb31sZXQgQnQ9bG9jYXRpb24ucGF0aG5hbWUrbG9jYXRpb24uc2VhcmNoO2Z1bmN0aW9uIFV0KCl7Y29uc3QgZT1uZSgpLnF1ZXJ5U2VsZWN0b3IoIltoeC1oaXN0b3J5LWVsdF0sW2RhdGEtaHgtaGlzdG9yeS1lbHRdIik7cmV0dXJuIGV8fG5lKCkuYm9keX1mdW5jdGlvbiBqdCh0LGUpe2lmKCFCKCkpe3JldHVybn1jb25zdCBuPV90KGUpO2NvbnN0IHI9bmUoKS50aXRsZTtjb25zdCBvPXdpbmRvdy5zY3JvbGxZO2lmKFEuY29uZmlnLmhpc3RvcnlDYWNoZVNpemU8PTApe2xvY2FsU3RvcmFnZS5yZW1vdmVJdGVtKCJodG14LWhpc3RvcnktY2FjaGUiKTtyZXR1cm59dD1VKHQpO2NvbnN0IGk9Uyhsb2NhbFN0b3JhZ2UuZ2V0SXRlbSgiaHRteC1oaXN0b3J5LWNhY2hlIikpfHxbXTtmb3IobGV0IGU9MDtlPGkubGVuZ3RoO2UrKyl7aWYoaVtlXS51cmw9PT10KXtpLnNwbGljZShlLDEpO2JyZWFrfX1jb25zdCBzPXt1cmw6dCxjb250ZW50Om4sdGl0bGU6cixzY3JvbGw6b307aGUobmUoKS5ib2R5LCJodG14Omhpc3RvcnlJdGVtQ3JlYXRlZCIse2l0ZW06cyxjYWNoZTppfSk7aS5wdXNoKHMpO3doaWxlKGkubGVuZ3RoPlEuY29uZmlnLmhpc3RvcnlDYWNoZVNpemUpe2kuc2hpZnQoKX13aGlsZShpLmxlbmd0aD4wKXt0cnl7bG9jYWxTdG9yYWdlLnNldEl0ZW0oImh0bXgtaGlzdG9yeS1jYWNoZSIsSlNPTi5zdHJpbmdpZnkoaSkpO2JyZWFrfWNhdGNoKGUpe2ZlKG5lKCkuYm9keSwiaHRteDpoaXN0b3J5Q2FjaGVFcnJvciIse2NhdXNlOmUsY2FjaGU6aX0pO2kuc2hpZnQoKX19fWZ1bmN0aW9uIFZ0KHQpe2lmKCFCKCkpe3JldHVybiBudWxsfXQ9VSh0KTtjb25zdCBuPVMobG9jYWxTdG9yYWdlLmdldEl0ZW0oImh0bXgtaGlzdG9yeS1jYWNoZSIpKXx8W107Zm9yKGxldCBlPTA7ZTxuLmxlbmd0aDtlKyspe2lmKG5bZV0udXJsPT09dCl7cmV0dXJuIG5bZV19fXJldHVybiBudWxsfWZ1bmN0aW9uIF90KGUpe2NvbnN0IHQ9US5jb25maWcucmVxdWVzdENsYXNzO2NvbnN0IG49ZS5jbG9uZU5vZGUodHJ1ZSk7c2UoeChuLCIuIit0KSxmdW5jdGlvbihlKXtHKGUsdCl9KTtzZSh4KG4sIltkYXRhLWRpc2FibGVkLWJ5LWh0bXhdIiksZnVuY3Rpb24oZSl7ZS5yZW1vdmVBdHRyaWJ1dGUoImRpc2FibGVkIil9KTtyZXR1cm4gbi5pbm5lckhUTUx9ZnVuY3Rpb24genQoKXtjb25zdCBlPVV0KCk7Y29uc3QgdD1CdHx8bG9jYXRpb24ucGF0aG5hbWUrbG9jYXRpb24uc2VhcmNoO2xldCBuO3RyeXtuPW5lKCkucXVlcnlTZWxlY3RvcignW2h4LWhpc3Rvcnk9ImZhbHNlIiBpXSxbZGF0YS1oeC1oaXN0b3J5PSJmYWxzZSIgaV0nKX1jYXRjaChlKXtuPW5lKCkucXVlcnlTZWxlY3RvcignW2h4LWhpc3Rvcnk9ImZhbHNlIl0sW2RhdGEtaHgtaGlzdG9yeT0iZmFsc2UiXScpfWlmKCFuKXtoZShuZSgpLmJvZHksImh0bXg6YmVmb3JlSGlzdG9yeVNhdmUiLHtwYXRoOnQsaGlzdG9yeUVsdDplfSk7anQodCxlKX1pZihRLmNvbmZpZy5oaXN0b3J5RW5hYmxlZCloaXN0b3J5LnJlcGxhY2VTdGF0ZSh7aHRteDp0cnVlfSxuZSgpLnRpdGxlLHdpbmRvdy5sb2NhdGlvbi5ocmVmKX1mdW5jdGlvbiAkdChlKXtpZihRLmNvbmZpZy5nZXRDYWNoZUJ1c3RlclBhcmFtKXtlPWUucmVwbGFjZSgvb3JnXC5odG14XC5jYWNoZS1idXN0ZXI9W14mXSomPy8sIiIpO2lmKFkoZSwiJiIpfHxZKGUsIj8iKSl7ZT1lLnNsaWNlKDAsLTEpfX1pZihRLmNvbmZpZy5oaXN0b3J5RW5hYmxlZCl7aGlzdG9yeS5wdXNoU3RhdGUoe2h0bXg6dHJ1ZX0sIiIsZSl9QnQ9ZX1mdW5jdGlvbiBKdChlKXtpZihRLmNvbmZpZy5oaXN0b3J5RW5hYmxlZCloaXN0b3J5LnJlcGxhY2VTdGF0ZSh7aHRteDp0cnVlfSwiIixlKTtCdD1lfWZ1bmN0aW9uIEt0KGUpe3NlKGUsZnVuY3Rpb24oZSl7ZS5jYWxsKHVuZGVmaW5lZCl9KX1mdW5jdGlvbiBHdChvKXtjb25zdCBlPW5ldyBYTUxIdHRwUmVxdWVzdDtjb25zdCBpPXtwYXRoOm8seGhyOmV9O2hlKG5lKCkuYm9keSwiaHRteDpoaXN0b3J5Q2FjaGVNaXNzIixpKTtlLm9wZW4oIkdFVCIsbyx0cnVlKTtlLnNldFJlcXVlc3RIZWFkZXIoIkhYLVJlcXVlc3QiLCJ0cnVlIik7ZS5zZXRSZXF1ZXN0SGVhZGVyKCJIWC1IaXN0b3J5LVJlc3RvcmUtUmVxdWVzdCIsInRydWUiKTtlLnNldFJlcXVlc3RIZWFkZXIoIkhYLUN1cnJlbnQtVVJMIixuZSgpLmxvY2F0aW9uLmhyZWYpO2Uub25sb2FkPWZ1bmN0aW9uKCl7aWYodGhpcy5zdGF0dXM+PTIwMCYmdGhpcy5zdGF0dXM8NDAwKXtoZShuZSgpLmJvZHksImh0bXg6aGlzdG9yeUNhY2hlTWlzc0xvYWQiLGkpO2NvbnN0IGU9UCh0aGlzLnJlc3BvbnNlKTtjb25zdCB0PWUucXVlcnlTZWxlY3RvcigiW2h4LWhpc3RvcnktZWx0XSxbZGF0YS1oeC1oaXN0b3J5LWVsdF0iKXx8ZTtjb25zdCBuPVV0KCk7Y29uc3Qgcj14bihuKTtrbihlLnRpdGxlKTtxZShlKTtWZShuLHQscik7VGUoKTtLdChyLnRhc2tzKTtCdD1vO2hlKG5lKCkuYm9keSwiaHRteDpoaXN0b3J5UmVzdG9yZSIse3BhdGg6byxjYWNoZU1pc3M6dHJ1ZSxzZXJ2ZXJSZXNwb25zZTp0aGlzLnJlc3BvbnNlfSl9ZWxzZXtmZShuZSgpLmJvZHksImh0bXg6aGlzdG9yeUNhY2hlTWlzc0xvYWRFcnJvciIsaSl9fTtlLnNlbmQoKX1mdW5jdGlvbiBXdChlKXt6dCgpO2U9ZXx8bG9jYXRpb24ucGF0aG5hbWUrbG9jYXRpb24uc2VhcmNoO2NvbnN0IHQ9VnQoZSk7aWYodCl7Y29uc3Qgbj1QKHQuY29udGVudCk7Y29uc3Qgcj1VdCgpO2NvbnN0IG89eG4ocik7a24odC50aXRsZSk7cWUobik7VmUocixuLG8pO1RlKCk7S3Qoby50YXNrcyk7RSgpLnNldFRpbWVvdXQoZnVuY3Rpb24oKXt3aW5kb3cuc2Nyb2xsVG8oMCx0LnNjcm9sbCl9LDApO0J0PWU7aGUobmUoKS5ib2R5LCJodG14Omhpc3RvcnlSZXN0b3JlIix7cGF0aDplLGl0ZW06dH0pfWVsc2V7aWYoUS5jb25maWcucmVmcmVzaE9uSGlzdG9yeU1pc3Mpe3dpbmRvdy5sb2NhdGlvbi5yZWxvYWQodHJ1ZSl9ZWxzZXtHdChlKX19fWZ1bmN0aW9uIFp0KGUpe2xldCB0PXdlKGUsImh4LWluZGljYXRvciIpO2lmKHQ9PW51bGwpe3Q9W2VdfXNlKHQsZnVuY3Rpb24oZSl7Y29uc3QgdD1pZShlKTt0LnJlcXVlc3RDb3VudD0odC5yZXF1ZXN0Q291bnR8fDApKzE7ZS5jbGFzc0xpc3QuYWRkLmNhbGwoZS5jbGFzc0xpc3QsUS5jb25maWcucmVxdWVzdENsYXNzKX0pO3JldHVybiB0fWZ1bmN0aW9uIFl0KGUpe2xldCB0PXdlKGUsImh4LWRpc2FibGVkLWVsdCIpO2lmKHQ9PW51bGwpe3Q9W119c2UodCxmdW5jdGlvbihlKXtjb25zdCB0PWllKGUpO3QucmVxdWVzdENvdW50PSh0LnJlcXVlc3RDb3VudHx8MCkrMTtlLnNldEF0dHJpYnV0ZSgiZGlzYWJsZWQiLCIiKTtlLnNldEF0dHJpYnV0ZSgiZGF0YS1kaXNhYmxlZC1ieS1odG14IiwiIil9KTtyZXR1cm4gdH1mdW5jdGlvbiBRdChlLHQpe3NlKGUuY29uY2F0KHQpLGZ1bmN0aW9uKGUpe2NvbnN0IHQ9aWUoZSk7dC5yZXF1ZXN0Q291bnQ9KHQucmVxdWVzdENvdW50fHwxKS0xfSk7c2UoZSxmdW5jdGlvbihlKXtjb25zdCB0PWllKGUpO2lmKHQucmVxdWVzdENvdW50PT09MCl7ZS5jbGFzc0xpc3QucmVtb3ZlLmNhbGwoZS5jbGFzc0xpc3QsUS5jb25maWcucmVxdWVzdENsYXNzKX19KTtzZSh0LGZ1bmN0aW9uKGUpe2NvbnN0IHQ9aWUoZSk7aWYodC5yZXF1ZXN0Q291bnQ9PT0wKXtlLnJlbW92ZUF0dHJpYnV0ZSgiZGlzYWJsZWQiKTtlLnJlbW92ZUF0dHJpYnV0ZSgiZGF0YS1kaXNhYmxlZC1ieS1odG14Iil9fSl9ZnVuY3Rpb24gZW4odCxuKXtmb3IobGV0IGU9MDtlPHQubGVuZ3RoO2UrKyl7Y29uc3Qgcj10W2VdO2lmKHIuaXNTYW1lTm9kZShuKSl7cmV0dXJuIHRydWV9fXJldHVybiBmYWxzZX1mdW5jdGlvbiB0bihlKXtjb25zdCB0PWU7aWYodC5uYW1lPT09IiJ8fHQubmFtZT09bnVsbHx8dC5kaXNhYmxlZHx8Zyh0LCJmaWVsZHNldFtkaXNhYmxlZF0iKSl7cmV0dXJuIGZhbHNlfWlmKHQudHlwZT09PSJidXR0b24ifHx0LnR5cGU9PT0ic3VibWl0Inx8dC50YWdOYW1lPT09ImltYWdlInx8dC50YWdOYW1lPT09InJlc2V0Inx8dC50YWdOYW1lPT09ImZpbGUiKXtyZXR1cm4gZmFsc2V9aWYodC50eXBlPT09ImNoZWNrYm94Inx8dC50eXBlPT09InJhZGlvIil7cmV0dXJuIHQuY2hlY2tlZH1yZXR1cm4gdHJ1ZX1mdW5jdGlvbiBubih0LGUsbil7aWYodCE9bnVsbCYmZSE9bnVsbCl7aWYoQXJyYXkuaXNBcnJheShlKSl7ZS5mb3JFYWNoKGZ1bmN0aW9uKGUpe24uYXBwZW5kKHQsZSl9KX1lbHNle24uYXBwZW5kKHQsZSl9fX1mdW5jdGlvbiBybih0LG4scil7aWYodCE9bnVsbCYmbiE9bnVsbCl7bGV0IGU9ci5nZXRBbGwodCk7aWYoQXJyYXkuaXNBcnJheShuKSl7ZT1lLmZpbHRlcihlPT5uLmluZGV4T2YoZSk8MCl9ZWxzZXtlPWUuZmlsdGVyKGU9PmUhPT1uKX1yLmRlbGV0ZSh0KTtzZShlLGU9PnIuYXBwZW5kKHQsZSkpfX1mdW5jdGlvbiBvbih0LG4scixvLGkpe2lmKG89PW51bGx8fGVuKHQsbykpe3JldHVybn1lbHNle3QucHVzaChvKX1pZih0bihvKSl7Y29uc3Qgcz1lZShvLCJuYW1lIik7bGV0IGU9by52YWx1ZTtpZihvIGluc3RhbmNlb2YgSFRNTFNlbGVjdEVsZW1lbnQmJm8ubXVsdGlwbGUpe2U9TShvLnF1ZXJ5U2VsZWN0b3JBbGwoIm9wdGlvbjpjaGVja2VkIikpLm1hcChmdW5jdGlvbihlKXtyZXR1cm4gZS52YWx1ZX0pfWlmKG8gaW5zdGFuY2VvZiBIVE1MSW5wdXRFbGVtZW50JiZvLmZpbGVzKXtlPU0oby5maWxlcyl9bm4ocyxlLG4pO2lmKGkpe3NuKG8scil9fWlmKG8gaW5zdGFuY2VvZiBIVE1MRm9ybUVsZW1lbnQpe3NlKG8uZWxlbWVudHMsZnVuY3Rpb24oZSl7aWYodC5pbmRleE9mKGUpPj0wKXtybihlLm5hbWUsZS52YWx1ZSxuKX1lbHNle3QucHVzaChlKX1pZihpKXtzbihlLHIpfX0pO25ldyBGb3JtRGF0YShvKS5mb3JFYWNoKGZ1bmN0aW9uKGUsdCl7aWYoZSBpbnN0YW5jZW9mIEZpbGUmJmUubmFtZT09PSIiKXtyZXR1cm59bm4odCxlLG4pfSl9fWZ1bmN0aW9uIHNuKGUsdCl7Y29uc3Qgbj1lO2lmKG4ud2lsbFZhbGlkYXRlKXtoZShuLCJodG14OnZhbGlkYXRpb246dmFsaWRhdGUiKTtpZighbi5jaGVja1ZhbGlkaXR5KCkpe3QucHVzaCh7ZWx0Om4sbWVzc2FnZTpuLnZhbGlkYXRpb25NZXNzYWdlLHZhbGlkaXR5Om4udmFsaWRpdHl9KTtoZShuLCJodG14OnZhbGlkYXRpb246ZmFpbGVkIix7bWVzc2FnZTpuLnZhbGlkYXRpb25NZXNzYWdlLHZhbGlkaXR5Om4udmFsaWRpdHl9KX19fWZ1bmN0aW9uIGxuKG4sZSl7Zm9yKGNvbnN0IHQgb2YgZS5rZXlzKCkpe24uZGVsZXRlKHQpfWUuZm9yRWFjaChmdW5jdGlvbihlLHQpe24uYXBwZW5kKHQsZSl9KTtyZXR1cm4gbn1mdW5jdGlvbiBjbihlLHQpe2NvbnN0IG49W107Y29uc3Qgcj1uZXcgRm9ybURhdGE7Y29uc3Qgbz1uZXcgRm9ybURhdGE7Y29uc3QgaT1bXTtjb25zdCBzPWllKGUpO2lmKHMubGFzdEJ1dHRvbkNsaWNrZWQmJiFsZShzLmxhc3RCdXR0b25DbGlja2VkKSl7cy5sYXN0QnV0dG9uQ2xpY2tlZD1udWxsfWxldCBsPWUgaW5zdGFuY2VvZiBIVE1MRm9ybUVsZW1lbnQmJmUubm9WYWxpZGF0ZSE9PXRydWV8fHRlKGUsImh4LXZhbGlkYXRlIik9PT0idHJ1ZSI7aWYocy5sYXN0QnV0dG9uQ2xpY2tlZCl7bD1sJiZzLmxhc3RCdXR0b25DbGlja2VkLmZvcm1Ob1ZhbGlkYXRlIT09dHJ1ZX1pZih0IT09ImdldCIpe29uKG4sbyxpLGcoZSwiZm9ybSIpLGwpfW9uKG4scixpLGUsbCk7aWYocy5sYXN0QnV0dG9uQ2xpY2tlZHx8ZS50YWdOYW1lPT09IkJVVFRPTiJ8fGUudGFnTmFtZT09PSJJTlBVVCImJmVlKGUsInR5cGUiKT09PSJzdWJtaXQiKXtjb25zdCB1PXMubGFzdEJ1dHRvbkNsaWNrZWR8fGU7Y29uc3QgYT1lZSh1LCJuYW1lIik7bm4oYSx1LnZhbHVlLG8pfWNvbnN0IGM9d2UoZSwiaHgtaW5jbHVkZSIpO3NlKGMsZnVuY3Rpb24oZSl7b24obixyLGksdWUoZSksbCk7aWYoIWgoZSwiZm9ybSIpKXtzZShmKGUpLnF1ZXJ5U2VsZWN0b3JBbGwob3QpLGZ1bmN0aW9uKGUpe29uKG4scixpLGUsbCl9KX19KTtsbihyLG8pO3JldHVybntlcnJvcnM6aSxmb3JtRGF0YTpyLHZhbHVlczpBbihyKX19ZnVuY3Rpb24gdW4oZSx0LG4pe2lmKGUhPT0iIil7ZSs9IiYifWlmKFN0cmluZyhuKT09PSJbb2JqZWN0IE9iamVjdF0iKXtuPUpTT04uc3RyaW5naWZ5KG4pfWNvbnN0IHI9ZW5jb2RlVVJJQ29tcG9uZW50KG4pO2UrPWVuY29kZVVSSUNvbXBvbmVudCh0KSsiPSIrcjtyZXR1cm4gZX1mdW5jdGlvbiBhbihlKXtlPXFuKGUpO2xldCBuPSIiO2UuZm9yRWFjaChmdW5jdGlvbihlLHQpe249dW4obix0LGUpfSk7cmV0dXJuIG59ZnVuY3Rpb24gZm4oZSx0LG4pe2NvbnN0IHI9eyJIWC1SZXF1ZXN0IjoidHJ1ZSIsIkhYLVRyaWdnZXIiOmVlKGUsImlkIiksIkhYLVRyaWdnZXItTmFtZSI6ZWUoZSwibmFtZSIpLCJIWC1UYXJnZXQiOnRlKHQsImlkIiksIkhYLUN1cnJlbnQtVVJMIjpuZSgpLmxvY2F0aW9uLmhyZWZ9O2JuKGUsImh4LWhlYWRlcnMiLGZhbHNlLHIpO2lmKG4hPT11bmRlZmluZWQpe3JbIkhYLVByb21wdCJdPW59aWYoaWUoZSkuYm9vc3RlZCl7clsiSFgtQm9vc3RlZCJdPSJ0cnVlIn1yZXR1cm4gcn1mdW5jdGlvbiBobihuLGUpe2NvbnN0IHQ9cmUoZSwiaHgtcGFyYW1zIik7aWYodCl7aWYodD09PSJub25lIil7cmV0dXJuIG5ldyBGb3JtRGF0YX1lbHNlIGlmKHQ9PT0iKiIpe3JldHVybiBufWVsc2UgaWYodC5pbmRleE9mKCJub3QgIik9PT0wKXtzZSh0LnNsaWNlKDQpLnNwbGl0KCIsIiksZnVuY3Rpb24oZSl7ZT1lLnRyaW0oKTtuLmRlbGV0ZShlKX0pO3JldHVybiBufWVsc2V7Y29uc3Qgcj1uZXcgRm9ybURhdGE7c2UodC5zcGxpdCgiLCIpLGZ1bmN0aW9uKHQpe3Q9dC50cmltKCk7aWYobi5oYXModCkpe24uZ2V0QWxsKHQpLmZvckVhY2goZnVuY3Rpb24oZSl7ci5hcHBlbmQodCxlKX0pfX0pO3JldHVybiByfX1lbHNle3JldHVybiBufX1mdW5jdGlvbiBkbihlKXtyZXR1cm4hIWVlKGUsImhyZWYiKSYmZWUoZSwiaHJlZiIpLmluZGV4T2YoIiMiKT49MH1mdW5jdGlvbiBnbihlLHQpe2NvbnN0IG49dHx8cmUoZSwiaHgtc3dhcCIpO2NvbnN0IHI9e3N3YXBTdHlsZTppZShlKS5ib29zdGVkPyJpbm5lckhUTUwiOlEuY29uZmlnLmRlZmF1bHRTd2FwU3R5bGUsc3dhcERlbGF5OlEuY29uZmlnLmRlZmF1bHRTd2FwRGVsYXksc2V0dGxlRGVsYXk6US5jb25maWcuZGVmYXVsdFNldHRsZURlbGF5fTtpZihRLmNvbmZpZy5zY3JvbGxJbnRvVmlld09uQm9vc3QmJmllKGUpLmJvb3N0ZWQmJiFkbihlKSl7ci5zaG93PSJ0b3AifWlmKG4pe2NvbnN0IHM9RihuKTtpZihzLmxlbmd0aD4wKXtmb3IobGV0IGU9MDtlPHMubGVuZ3RoO2UrKyl7Y29uc3QgbD1zW2VdO2lmKGwuaW5kZXhPZigic3dhcDoiKT09PTApe3Iuc3dhcERlbGF5PWQobC5zbGljZSg1KSl9ZWxzZSBpZihsLmluZGV4T2YoInNldHRsZToiKT09PTApe3Iuc2V0dGxlRGVsYXk9ZChsLnNsaWNlKDcpKX1lbHNlIGlmKGwuaW5kZXhPZigidHJhbnNpdGlvbjoiKT09PTApe3IudHJhbnNpdGlvbj1sLnNsaWNlKDExKT09PSJ0cnVlIn1lbHNlIGlmKGwuaW5kZXhPZigiaWdub3JlVGl0bGU6Iik9PT0wKXtyLmlnbm9yZVRpdGxlPWwuc2xpY2UoMTIpPT09InRydWUifWVsc2UgaWYobC5pbmRleE9mKCJzY3JvbGw6Iik9PT0wKXtjb25zdCBjPWwuc2xpY2UoNyk7dmFyIG89Yy5zcGxpdCgiOiIpO2NvbnN0IHU9by5wb3AoKTt2YXIgaT1vLmxlbmd0aD4wP28uam9pbigiOiIpOm51bGw7ci5zY3JvbGw9dTtyLnNjcm9sbFRhcmdldD1pfWVsc2UgaWYobC5pbmRleE9mKCJzaG93OiIpPT09MCl7Y29uc3QgYT1sLnNsaWNlKDUpO3ZhciBvPWEuc3BsaXQoIjoiKTtjb25zdCBmPW8ucG9wKCk7dmFyIGk9by5sZW5ndGg+MD9vLmpvaW4oIjoiKTpudWxsO3Iuc2hvdz1mO3Iuc2hvd1RhcmdldD1pfWVsc2UgaWYobC5pbmRleE9mKCJmb2N1cy1zY3JvbGw6Iik9PT0wKXtjb25zdCBoPWwuc2xpY2UoImZvY3VzLXNjcm9sbDoiLmxlbmd0aCk7ci5mb2N1c1Njcm9sbD1oPT0idHJ1ZSJ9ZWxzZSBpZihlPT0wKXtyLnN3YXBTdHlsZT1sfWVsc2V7TygiVW5rbm93biBtb2RpZmllciBpbiBoeC1zd2FwOiAiK2wpfX19fXJldHVybiByfWZ1bmN0aW9uIHBuKGUpe3JldHVybiByZShlLCJoeC1lbmNvZGluZyIpPT09Im11bHRpcGFydC9mb3JtLWRhdGEifHxoKGUsImZvcm0iKSYmZWUoZSwiZW5jdHlwZSIpPT09Im11bHRpcGFydC9mb3JtLWRhdGEifWZ1bmN0aW9uIG1uKHQsbixyKXtsZXQgbz1udWxsO0Z0KG4sZnVuY3Rpb24oZSl7aWYobz09bnVsbCl7bz1lLmVuY29kZVBhcmFtZXRlcnModCxyLG4pfX0pO2lmKG8hPW51bGwpe3JldHVybiBvfWVsc2V7aWYocG4obikpe3JldHVybiBsbihuZXcgRm9ybURhdGEscW4ocikpfWVsc2V7cmV0dXJuIGFuKHIpfX19ZnVuY3Rpb24geG4oZSl7cmV0dXJue3Rhc2tzOltdLGVsdHM6W2VdfX1mdW5jdGlvbiB5bihlLHQpe2NvbnN0IG49ZVswXTtjb25zdCByPWVbZS5sZW5ndGgtMV07aWYodC5zY3JvbGwpe3ZhciBvPW51bGw7aWYodC5zY3JvbGxUYXJnZXQpe289dWUoYWUobix0LnNjcm9sbFRhcmdldCkpfWlmKHQuc2Nyb2xsPT09InRvcCImJihufHxvKSl7bz1vfHxuO28uc2Nyb2xsVG9wPTB9aWYodC5zY3JvbGw9PT0iYm90dG9tIiYmKHJ8fG8pKXtvPW98fHI7by5zY3JvbGxUb3A9by5zY3JvbGxIZWlnaHR9fWlmKHQuc2hvdyl7dmFyIG89bnVsbDtpZih0LnNob3dUYXJnZXQpe2xldCBlPXQuc2hvd1RhcmdldDtpZih0LnNob3dUYXJnZXQ9PT0id2luZG93Iil7ZT0iYm9keSJ9bz11ZShhZShuLGUpKX1pZih0LnNob3c9PT0idG9wIiYmKG58fG8pKXtvPW98fG47by5zY3JvbGxJbnRvVmlldyh7YmxvY2s6InN0YXJ0IixiZWhhdmlvcjpRLmNvbmZpZy5zY3JvbGxCZWhhdmlvcn0pfWlmKHQuc2hvdz09PSJib3R0b20iJiYocnx8bykpe289b3x8cjtvLnNjcm9sbEludG9WaWV3KHtibG9jazoiZW5kIixiZWhhdmlvcjpRLmNvbmZpZy5zY3JvbGxCZWhhdmlvcn0pfX19ZnVuY3Rpb24gYm4ocixlLG8saSl7aWYoaT09bnVsbCl7aT17fX1pZihyPT1udWxsKXtyZXR1cm4gaX1jb25zdCBzPXRlKHIsZSk7aWYocyl7bGV0IGU9cy50cmltKCk7bGV0IHQ9bztpZihlPT09InVuc2V0Iil7cmV0dXJuIG51bGx9aWYoZS5pbmRleE9mKCJqYXZhc2NyaXB0OiIpPT09MCl7ZT1lLnNsaWNlKDExKTt0PXRydWV9ZWxzZSBpZihlLmluZGV4T2YoImpzOiIpPT09MCl7ZT1lLnNsaWNlKDMpO3Q9dHJ1ZX1pZihlLmluZGV4T2YoInsiKSE9PTApe2U9InsiK2UrIn0ifWxldCBuO2lmKHQpe249dm4ocixmdW5jdGlvbigpe3JldHVybiBGdW5jdGlvbigicmV0dXJuICgiK2UrIikiKSgpfSx7fSl9ZWxzZXtuPVMoZSl9Zm9yKGNvbnN0IGwgaW4gbil7aWYobi5oYXNPd25Qcm9wZXJ0eShsKSl7aWYoaVtsXT09bnVsbCl7aVtsXT1uW2xdfX19fXJldHVybiBibih1ZShjKHIpKSxlLG8saSl9ZnVuY3Rpb24gdm4oZSx0LG4pe2lmKFEuY29uZmlnLmFsbG93RXZhbCl7cmV0dXJuIHQoKX1lbHNle2ZlKGUsImh0bXg6ZXZhbERpc2FsbG93ZWRFcnJvciIpO3JldHVybiBufX1mdW5jdGlvbiB3bihlLHQpe3JldHVybiBibihlLCJoeC12YXJzIix0cnVlLHQpfWZ1bmN0aW9uIFNuKGUsdCl7cmV0dXJuIGJuKGUsImh4LXZhbHMiLGZhbHNlLHQpfWZ1bmN0aW9uIEVuKGUpe3JldHVybiBjZSh3bihlKSxTbihlKSl9ZnVuY3Rpb24gQ24odCxuLHIpe2lmKHIhPT1udWxsKXt0cnl7dC5zZXRSZXF1ZXN0SGVhZGVyKG4scil9Y2F0Y2goZSl7dC5zZXRSZXF1ZXN0SGVhZGVyKG4sZW5jb2RlVVJJQ29tcG9uZW50KHIpKTt0LnNldFJlcXVlc3RIZWFkZXIobisiLVVSSS1BdXRvRW5jb2RlZCIsInRydWUiKX19fWZ1bmN0aW9uIE9uKHQpe2lmKHQucmVzcG9uc2VVUkwmJnR5cGVvZiBVUkwhPT0idW5kZWZpbmVkIil7dHJ5e2NvbnN0IGU9bmV3IFVSTCh0LnJlc3BvbnNlVVJMKTtyZXR1cm4gZS5wYXRobmFtZStlLnNlYXJjaH1jYXRjaChlKXtmZShuZSgpLmJvZHksImh0bXg6YmFkUmVzcG9uc2VVcmwiLHt1cmw6dC5yZXNwb25zZVVSTH0pfX19ZnVuY3Rpb24gUihlLHQpe3JldHVybiB0LnRlc3QoZS5nZXRBbGxSZXNwb25zZUhlYWRlcnMoKSl9ZnVuY3Rpb24gUm4odCxuLHIpe3Q9dC50b0xvd2VyQ2FzZSgpO2lmKHIpe2lmKHIgaW5zdGFuY2VvZiBFbGVtZW50fHx0eXBlb2Ygcj09PSJzdHJpbmciKXtyZXR1cm4gZGUodCxuLG51bGwsbnVsbCx7dGFyZ2V0T3ZlcnJpZGU6eShyKXx8dmUscmV0dXJuUHJvbWlzZTp0cnVlfSl9ZWxzZXtsZXQgZT15KHIudGFyZ2V0KTtpZihyLnRhcmdldCYmIWV8fHIuc291cmNlJiYhZSYmIXkoci5zb3VyY2UpKXtlPXZlfXJldHVybiBkZSh0LG4seShyLnNvdXJjZSksci5ldmVudCx7aGFuZGxlcjpyLmhhbmRsZXIsaGVhZGVyczpyLmhlYWRlcnMsdmFsdWVzOnIudmFsdWVzLHRhcmdldE92ZXJyaWRlOmUsc3dhcE92ZXJyaWRlOnIuc3dhcCxzZWxlY3Q6ci5zZWxlY3QscmV0dXJuUHJvbWlzZTp0cnVlfSl9fWVsc2V7cmV0dXJuIGRlKHQsbixudWxsLG51bGwse3JldHVyblByb21pc2U6dHJ1ZX0pfX1mdW5jdGlvbiBIbihlKXtjb25zdCB0PVtdO3doaWxlKGUpe3QucHVzaChlKTtlPWUucGFyZW50RWxlbWVudH1yZXR1cm4gdH1mdW5jdGlvbiBUbihlLHQsbil7bGV0IHI7bGV0IG87aWYodHlwZW9mIFVSTD09PSJmdW5jdGlvbiIpe289bmV3IFVSTCh0LGRvY3VtZW50LmxvY2F0aW9uLmhyZWYpO2NvbnN0IGk9ZG9jdW1lbnQubG9jYXRpb24ub3JpZ2luO3I9aT09PW8ub3JpZ2lufWVsc2V7bz10O3I9bCh0LGRvY3VtZW50LmxvY2F0aW9uLm9yaWdpbil9aWYoUS5jb25maWcuc2VsZlJlcXVlc3RzT25seSl7aWYoIXIpe3JldHVybiBmYWxzZX19cmV0dXJuIGhlKGUsImh0bXg6dmFsaWRhdGVVcmwiLGNlKHt1cmw6byxzYW1lSG9zdDpyfSxuKSl9ZnVuY3Rpb24gcW4oZSl7aWYoZSBpbnN0YW5jZW9mIEZvcm1EYXRhKXJldHVybiBlO2NvbnN0IHQ9bmV3IEZvcm1EYXRhO2Zvcihjb25zdCBuIGluIGUpe2lmKGUuaGFzT3duUHJvcGVydHkobikpe2lmKGVbbl0mJnR5cGVvZiBlW25dLmZvckVhY2g9PT0iZnVuY3Rpb24iKXtlW25dLmZvckVhY2goZnVuY3Rpb24oZSl7dC5hcHBlbmQobixlKX0pfWVsc2UgaWYodHlwZW9mIGVbbl09PT0ib2JqZWN0IiYmIShlW25daW5zdGFuY2VvZiBCbG9iKSl7dC5hcHBlbmQobixKU09OLnN0cmluZ2lmeShlW25dKSl9ZWxzZXt0LmFwcGVuZChuLGVbbl0pfX19cmV0dXJuIHR9ZnVuY3Rpb24gTG4ocixvLGUpe3JldHVybiBuZXcgUHJveHkoZSx7Z2V0OmZ1bmN0aW9uKHQsZSl7aWYodHlwZW9mIGU9PT0ibnVtYmVyIilyZXR1cm4gdFtlXTtpZihlPT09Imxlbmd0aCIpcmV0dXJuIHQubGVuZ3RoO2lmKGU9PT0icHVzaCIpe3JldHVybiBmdW5jdGlvbihlKXt0LnB1c2goZSk7ci5hcHBlbmQobyxlKX19aWYodHlwZW9mIHRbZV09PT0iZnVuY3Rpb24iKXtyZXR1cm4gZnVuY3Rpb24oKXt0W2VdLmFwcGx5KHQsYXJndW1lbnRzKTtyLmRlbGV0ZShvKTt0LmZvckVhY2goZnVuY3Rpb24oZSl7ci5hcHBlbmQobyxlKX0pfX1pZih0W2VdJiZ0W2VdLmxlbmd0aD09PTEpe3JldHVybiB0W2VdWzBdfWVsc2V7cmV0dXJuIHRbZV19fSxzZXQ6ZnVuY3Rpb24oZSx0LG4pe2VbdF09bjtyLmRlbGV0ZShvKTtlLmZvckVhY2goZnVuY3Rpb24oZSl7ci5hcHBlbmQobyxlKX0pO3JldHVybiB0cnVlfX0pfWZ1bmN0aW9uIEFuKG8pe3JldHVybiBuZXcgUHJveHkobyx7Z2V0OmZ1bmN0aW9uKGUsdCl7aWYodHlwZW9mIHQ9PT0ic3ltYm9sIil7Y29uc3Qgcj1SZWZsZWN0LmdldChlLHQpO2lmKHR5cGVvZiByPT09ImZ1bmN0aW9uIil7cmV0dXJuIGZ1bmN0aW9uKCl7cmV0dXJuIHIuYXBwbHkobyxhcmd1bWVudHMpfX1lbHNle3JldHVybiByfX1pZih0PT09InRvSlNPTiIpe3JldHVybigpPT5PYmplY3QuZnJvbUVudHJpZXMobyl9aWYodCBpbiBlKXtpZih0eXBlb2YgZVt0XT09PSJmdW5jdGlvbiIpe3JldHVybiBmdW5jdGlvbigpe3JldHVybiBvW3RdLmFwcGx5KG8sYXJndW1lbnRzKX19ZWxzZXtyZXR1cm4gZVt0XX19Y29uc3Qgbj1vLmdldEFsbCh0KTtpZihuLmxlbmd0aD09PTApe3JldHVybiB1bmRlZmluZWR9ZWxzZSBpZihuLmxlbmd0aD09PTEpe3JldHVybiBuWzBdfWVsc2V7cmV0dXJuIExuKGUsdCxuKX19LHNldDpmdW5jdGlvbih0LG4sZSl7aWYodHlwZW9mIG4hPT0ic3RyaW5nIil7cmV0dXJuIGZhbHNlfXQuZGVsZXRlKG4pO2lmKGUmJnR5cGVvZiBlLmZvckVhY2g9PT0iZnVuY3Rpb24iKXtlLmZvckVhY2goZnVuY3Rpb24oZSl7dC5hcHBlbmQobixlKX0pfWVsc2UgaWYodHlwZW9mIGU9PT0ib2JqZWN0IiYmIShlIGluc3RhbmNlb2YgQmxvYikpe3QuYXBwZW5kKG4sSlNPTi5zdHJpbmdpZnkoZSkpfWVsc2V7dC5hcHBlbmQobixlKX1yZXR1cm4gdHJ1ZX0sZGVsZXRlUHJvcGVydHk6ZnVuY3Rpb24oZSx0KXtpZih0eXBlb2YgdD09PSJzdHJpbmciKXtlLmRlbGV0ZSh0KX1yZXR1cm4gdHJ1ZX0sb3duS2V5czpmdW5jdGlvbihlKXtyZXR1cm4gUmVmbGVjdC5vd25LZXlzKE9iamVjdC5mcm9tRW50cmllcyhlKSl9LGdldE93blByb3BlcnR5RGVzY3JpcHRvcjpmdW5jdGlvbihlLHQpe3JldHVybiBSZWZsZWN0LmdldE93blByb3BlcnR5RGVzY3JpcHRvcihPYmplY3QuZnJvbUVudHJpZXMoZSksdCl9fSl9ZnVuY3Rpb24gZGUodCxuLHIsbyxpLEQpe2xldCBzPW51bGw7bGV0IGw9bnVsbDtpPWkhPW51bGw/aTp7fTtpZihpLnJldHVyblByb21pc2UmJnR5cGVvZiBQcm9taXNlIT09InVuZGVmaW5lZCIpe3ZhciBlPW5ldyBQcm9taXNlKGZ1bmN0aW9uKGUsdCl7cz1lO2w9dH0pfWlmKHI9PW51bGwpe3I9bmUoKS5ib2R5fWNvbnN0IE09aS5oYW5kbGVyfHxEbjtjb25zdCBYPWkuc2VsZWN0fHxudWxsO2lmKCFsZShyKSl7b2Uocyk7cmV0dXJuIGV9Y29uc3QgYz1pLnRhcmdldE92ZXJyaWRlfHx1ZShFZShyKSk7aWYoYz09bnVsbHx8Yz09dmUpe2ZlKHIsImh0bXg6dGFyZ2V0RXJyb3IiLHt0YXJnZXQ6dGUociwiaHgtdGFyZ2V0Iil9KTtvZShsKTtyZXR1cm4gZX1sZXQgdT1pZShyKTtjb25zdCBhPXUubGFzdEJ1dHRvbkNsaWNrZWQ7aWYoYSl7Y29uc3QgTD1lZShhLCJmb3JtYWN0aW9uIik7aWYoTCE9bnVsbCl7bj1MfWNvbnN0IEE9ZWUoYSwiZm9ybW1ldGhvZCIpO2lmKEEhPW51bGwpe2lmKEEudG9Mb3dlckNhc2UoKSE9PSJkaWFsb2ciKXt0PUF9fX1jb25zdCBmPXJlKHIsImh4LWNvbmZpcm0iKTtpZihEPT09dW5kZWZpbmVkKXtjb25zdCBLPWZ1bmN0aW9uKGUpe3JldHVybiBkZSh0LG4scixvLGksISFlKX07Y29uc3QgRz17dGFyZ2V0OmMsZWx0OnIscGF0aDpuLHZlcmI6dCx0cmlnZ2VyaW5nRXZlbnQ6byxldGM6aSxpc3N1ZVJlcXVlc3Q6SyxxdWVzdGlvbjpmfTtpZihoZShyLCJodG14OmNvbmZpcm0iLEcpPT09ZmFsc2Upe29lKHMpO3JldHVybiBlfX1sZXQgaD1yO2xldCBkPXJlKHIsImh4LXN5bmMiKTtsZXQgZz1udWxsO2xldCBGPWZhbHNlO2lmKGQpe2NvbnN0IE49ZC5zcGxpdCgiOiIpO2NvbnN0IEk9TlswXS50cmltKCk7aWYoST09PSJ0aGlzIil7aD1TZShyLCJoeC1zeW5jIil9ZWxzZXtoPXVlKGFlKHIsSSkpfWQ9KE5bMV18fCJkcm9wIikudHJpbSgpO3U9aWUoaCk7aWYoZD09PSJkcm9wIiYmdS54aHImJnUuYWJvcnRhYmxlIT09dHJ1ZSl7b2Uocyk7cmV0dXJuIGV9ZWxzZSBpZihkPT09ImFib3J0Iil7aWYodS54aHIpe29lKHMpO3JldHVybiBlfWVsc2V7Rj10cnVlfX1lbHNlIGlmKGQ9PT0icmVwbGFjZSIpe2hlKGgsImh0bXg6YWJvcnQiKX1lbHNlIGlmKGQuaW5kZXhPZigicXVldWUiKT09PTApe2NvbnN0IFc9ZC5zcGxpdCgiICIpO2c9KFdbMV18fCJsYXN0IikudHJpbSgpfX1pZih1Lnhocil7aWYodS5hYm9ydGFibGUpe2hlKGgsImh0bXg6YWJvcnQiKX1lbHNle2lmKGc9PW51bGwpe2lmKG8pe2NvbnN0IFA9aWUobyk7aWYoUCYmUC50cmlnZ2VyU3BlYyYmUC50cmlnZ2VyU3BlYy5xdWV1ZSl7Zz1QLnRyaWdnZXJTcGVjLnF1ZXVlfX1pZihnPT1udWxsKXtnPSJsYXN0In19aWYodS5xdWV1ZWRSZXF1ZXN0cz09bnVsbCl7dS5xdWV1ZWRSZXF1ZXN0cz1bXX1pZihnPT09ImZpcnN0IiYmdS5xdWV1ZWRSZXF1ZXN0cy5sZW5ndGg9PT0wKXt1LnF1ZXVlZFJlcXVlc3RzLnB1c2goZnVuY3Rpb24oKXtkZSh0LG4scixvLGkpfSl9ZWxzZSBpZihnPT09ImFsbCIpe3UucXVldWVkUmVxdWVzdHMucHVzaChmdW5jdGlvbigpe2RlKHQsbixyLG8saSl9KX1lbHNlIGlmKGc9PT0ibGFzdCIpe3UucXVldWVkUmVxdWVzdHM9W107dS5xdWV1ZWRSZXF1ZXN0cy5wdXNoKGZ1bmN0aW9uKCl7ZGUodCxuLHIsbyxpKX0pfW9lKHMpO3JldHVybiBlfX1jb25zdCBwPW5ldyBYTUxIdHRwUmVxdWVzdDt1Lnhocj1wO3UuYWJvcnRhYmxlPUY7Y29uc3QgbT1mdW5jdGlvbigpe3UueGhyPW51bGw7dS5hYm9ydGFibGU9ZmFsc2U7aWYodS5xdWV1ZWRSZXF1ZXN0cyE9bnVsbCYmdS5xdWV1ZWRSZXF1ZXN0cy5sZW5ndGg+MCl7Y29uc3QgZT11LnF1ZXVlZFJlcXVlc3RzLnNoaWZ0KCk7ZSgpfX07Y29uc3QgQj1yZShyLCJoeC1wcm9tcHQiKTtpZihCKXt2YXIgeD1wcm9tcHQoQik7aWYoeD09PW51bGx8fCFoZShyLCJodG14OnByb21wdCIse3Byb21wdDp4LHRhcmdldDpjfSkpe29lKHMpO20oKTtyZXR1cm4gZX19aWYoZiYmIUQpe2lmKCFjb25maXJtKGYpKXtvZShzKTttKCk7cmV0dXJuIGV9fWxldCB5PWZuKHIsYyx4KTtpZih0IT09ImdldCImJiFwbihyKSl7eVsiQ29udGVudC1UeXBlIl09ImFwcGxpY2F0aW9uL3gtd3d3LWZvcm0tdXJsZW5jb2RlZCJ9aWYoaS5oZWFkZXJzKXt5PWNlKHksaS5oZWFkZXJzKX1jb25zdCBVPWNuKHIsdCk7bGV0IGI9VS5lcnJvcnM7Y29uc3Qgaj1VLmZvcm1EYXRhO2lmKGkudmFsdWVzKXtsbihqLHFuKGkudmFsdWVzKSl9Y29uc3QgVj1xbihFbihyKSk7Y29uc3Qgdj1sbihqLFYpO2xldCB3PWhuKHYscik7aWYoUS5jb25maWcuZ2V0Q2FjaGVCdXN0ZXJQYXJhbSYmdD09PSJnZXQiKXt3LnNldCgib3JnLmh0bXguY2FjaGUtYnVzdGVyIixlZShjLCJpZCIpfHwidHJ1ZSIpfWlmKG49PW51bGx8fG49PT0iIil7bj1uZSgpLmxvY2F0aW9uLmhyZWZ9Y29uc3QgUz1ibihyLCJoeC1yZXF1ZXN0Iik7Y29uc3QgXz1pZShyKS5ib29zdGVkO2xldCBFPVEuY29uZmlnLm1ldGhvZHNUaGF0VXNlVXJsUGFyYW1zLmluZGV4T2YodCk+PTA7Y29uc3QgQz17Ym9vc3RlZDpfLHVzZVVybFBhcmFtczpFLGZvcm1EYXRhOncscGFyYW1ldGVyczpBbih3KSx1bmZpbHRlcmVkRm9ybURhdGE6dix1bmZpbHRlcmVkUGFyYW1ldGVyczpBbih2KSxoZWFkZXJzOnksdGFyZ2V0OmMsdmVyYjp0LGVycm9yczpiLHdpdGhDcmVkZW50aWFsczppLmNyZWRlbnRpYWxzfHxTLmNyZWRlbnRpYWxzfHxRLmNvbmZpZy53aXRoQ3JlZGVudGlhbHMsdGltZW91dDppLnRpbWVvdXR8fFMudGltZW91dHx8US5jb25maWcudGltZW91dCxwYXRoOm4sdHJpZ2dlcmluZ0V2ZW50Om99O2lmKCFoZShyLCJodG14OmNvbmZpZ1JlcXVlc3QiLEMpKXtvZShzKTttKCk7cmV0dXJuIGV9bj1DLnBhdGg7dD1DLnZlcmI7eT1DLmhlYWRlcnM7dz1xbihDLnBhcmFtZXRlcnMpO2I9Qy5lcnJvcnM7RT1DLnVzZVVybFBhcmFtcztpZihiJiZiLmxlbmd0aD4wKXtoZShyLCJodG14OnZhbGlkYXRpb246aGFsdGVkIixDKTtvZShzKTttKCk7cmV0dXJuIGV9Y29uc3Qgej1uLnNwbGl0KCIjIik7Y29uc3QgJD16WzBdO2NvbnN0IE89elsxXTtsZXQgUj1uO2lmKEUpe1I9JDtjb25zdCBaPSF3LmtleXMoKS5uZXh0KCkuZG9uZTtpZihaKXtpZihSLmluZGV4T2YoIj8iKTwwKXtSKz0iPyJ9ZWxzZXtSKz0iJiJ9Uis9YW4odyk7aWYoTyl7Uis9IiMiK099fX1pZighVG4ocixSLEMpKXtmZShyLCJodG14OmludmFsaWRQYXRoIixDKTtvZShsKTtyZXR1cm4gZX1wLm9wZW4odC50b1VwcGVyQ2FzZSgpLFIsdHJ1ZSk7cC5vdmVycmlkZU1pbWVUeXBlKCJ0ZXh0L2h0bWwiKTtwLndpdGhDcmVkZW50aWFscz1DLndpdGhDcmVkZW50aWFscztwLnRpbWVvdXQ9Qy50aW1lb3V0O2lmKFMubm9IZWFkZXJzKXt9ZWxzZXtmb3IoY29uc3QgayBpbiB5KXtpZih5Lmhhc093blByb3BlcnR5KGspKXtjb25zdCBZPXlba107Q24ocCxrLFkpfX19Y29uc3QgSD17eGhyOnAsdGFyZ2V0OmMscmVxdWVzdENvbmZpZzpDLGV0YzppLGJvb3N0ZWQ6XyxzZWxlY3Q6WCxwYXRoSW5mbzp7cmVxdWVzdFBhdGg6bixmaW5hbFJlcXVlc3RQYXRoOlIscmVzcG9uc2VQYXRoOm51bGwsYW5jaG9yOk99fTtwLm9ubG9hZD1mdW5jdGlvbigpe3RyeXtjb25zdCB0PUhuKHIpO0gucGF0aEluZm8ucmVzcG9uc2VQYXRoPU9uKHApO00ocixIKTtpZihILmtlZXBJbmRpY2F0b3JzIT09dHJ1ZSl7UXQoVCxxKX1oZShyLCJodG14OmFmdGVyUmVxdWVzdCIsSCk7aGUociwiaHRteDphZnRlck9uTG9hZCIsSCk7aWYoIWxlKHIpKXtsZXQgZT1udWxsO3doaWxlKHQubGVuZ3RoPjAmJmU9PW51bGwpe2NvbnN0IG49dC5zaGlmdCgpO2lmKGxlKG4pKXtlPW59fWlmKGUpe2hlKGUsImh0bXg6YWZ0ZXJSZXF1ZXN0IixIKTtoZShlLCJodG14OmFmdGVyT25Mb2FkIixIKX19b2Uocyk7bSgpfWNhdGNoKGUpe2ZlKHIsImh0bXg6b25Mb2FkRXJyb3IiLGNlKHtlcnJvcjplfSxIKSk7dGhyb3cgZX19O3Aub25lcnJvcj1mdW5jdGlvbigpe1F0KFQscSk7ZmUociwiaHRteDphZnRlclJlcXVlc3QiLEgpO2ZlKHIsImh0bXg6c2VuZEVycm9yIixIKTtvZShsKTttKCl9O3Aub25hYm9ydD1mdW5jdGlvbigpe1F0KFQscSk7ZmUociwiaHRteDphZnRlclJlcXVlc3QiLEgpO2ZlKHIsImh0bXg6c2VuZEFib3J0IixIKTtvZShsKTttKCl9O3Aub250aW1lb3V0PWZ1bmN0aW9uKCl7UXQoVCxxKTtmZShyLCJodG14OmFmdGVyUmVxdWVzdCIsSCk7ZmUociwiaHRteDp0aW1lb3V0IixIKTtvZShsKTttKCl9O2lmKCFoZShyLCJodG14OmJlZm9yZVJlcXVlc3QiLEgpKXtvZShzKTttKCk7cmV0dXJuIGV9dmFyIFQ9WnQocik7dmFyIHE9WXQocik7c2UoWyJsb2Fkc3RhcnQiLCJsb2FkZW5kIiwicHJvZ3Jlc3MiLCJhYm9ydCJdLGZ1bmN0aW9uKHQpe3NlKFtwLHAudXBsb2FkXSxmdW5jdGlvbihlKXtlLmFkZEV2ZW50TGlzdGVuZXIodCxmdW5jdGlvbihlKXtoZShyLCJodG14OnhocjoiK3Qse2xlbmd0aENvbXB1dGFibGU6ZS5sZW5ndGhDb21wdXRhYmxlLGxvYWRlZDplLmxvYWRlZCx0b3RhbDplLnRvdGFsfSl9KX0pfSk7aGUociwiaHRteDpiZWZvcmVTZW5kIixIKTtjb25zdCBKPUU/bnVsbDptbihwLHIsdyk7cC5zZW5kKEopO3JldHVybiBlfWZ1bmN0aW9uIE5uKGUsdCl7Y29uc3Qgbj10LnhocjtsZXQgcj1udWxsO2xldCBvPW51bGw7aWYoUihuLC9IWC1QdXNoOi9pKSl7cj1uLmdldFJlc3BvbnNlSGVhZGVyKCJIWC1QdXNoIik7bz0icHVzaCJ9ZWxzZSBpZihSKG4sL0hYLVB1c2gtVXJsOi9pKSl7cj1uLmdldFJlc3BvbnNlSGVhZGVyKCJIWC1QdXNoLVVybCIpO289InB1c2gifWVsc2UgaWYoUihuLC9IWC1SZXBsYWNlLVVybDovaSkpe3I9bi5nZXRSZXNwb25zZUhlYWRlcigiSFgtUmVwbGFjZS1VcmwiKTtvPSJyZXBsYWNlIn1pZihyKXtpZihyPT09ImZhbHNlIil7cmV0dXJue319ZWxzZXtyZXR1cm57dHlwZTpvLHBhdGg6cn19fWNvbnN0IGk9dC5wYXRoSW5mby5maW5hbFJlcXVlc3RQYXRoO2NvbnN0IHM9dC5wYXRoSW5mby5yZXNwb25zZVBhdGg7Y29uc3QgbD1yZShlLCJoeC1wdXNoLXVybCIpO2NvbnN0IGM9cmUoZSwiaHgtcmVwbGFjZS11cmwiKTtjb25zdCB1PWllKGUpLmJvb3N0ZWQ7bGV0IGE9bnVsbDtsZXQgZj1udWxsO2lmKGwpe2E9InB1c2giO2Y9bH1lbHNlIGlmKGMpe2E9InJlcGxhY2UiO2Y9Y31lbHNlIGlmKHUpe2E9InB1c2giO2Y9c3x8aX1pZihmKXtpZihmPT09ImZhbHNlIil7cmV0dXJue319aWYoZj09PSJ0cnVlIil7Zj1zfHxpfWlmKHQucGF0aEluZm8uYW5jaG9yJiZmLmluZGV4T2YoIiMiKT09PS0xKXtmPWYrIiMiK3QucGF0aEluZm8uYW5jaG9yfXJldHVybnt0eXBlOmEscGF0aDpmfX1lbHNle3JldHVybnt9fX1mdW5jdGlvbiBJbihlLHQpe3ZhciBuPW5ldyBSZWdFeHAoZS5jb2RlKTtyZXR1cm4gbi50ZXN0KHQudG9TdHJpbmcoMTApKX1mdW5jdGlvbiBQbihlKXtmb3IodmFyIHQ9MDt0PFEuY29uZmlnLnJlc3BvbnNlSGFuZGxpbmcubGVuZ3RoO3QrKyl7dmFyIG49US5jb25maWcucmVzcG9uc2VIYW5kbGluZ1t0XTtpZihJbihuLGUuc3RhdHVzKSl7cmV0dXJuIG59fXJldHVybntzd2FwOmZhbHNlfX1mdW5jdGlvbiBrbihlKXtpZihlKXtjb25zdCB0PXUoInRpdGxlIik7aWYodCl7dC5pbm5lckhUTUw9ZX1lbHNle3dpbmRvdy5kb2N1bWVudC50aXRsZT1lfX19ZnVuY3Rpb24gRG4obyxpKXtjb25zdCBzPWkueGhyO2xldCBsPWkudGFyZ2V0O2NvbnN0IGU9aS5ldGM7Y29uc3QgYz1pLnNlbGVjdDtpZighaGUobywiaHRteDpiZWZvcmVPbkxvYWQiLGkpKXJldHVybjtpZihSKHMsL0hYLVRyaWdnZXI6L2kpKXtKZShzLCJIWC1UcmlnZ2VyIixvKX1pZihSKHMsL0hYLUxvY2F0aW9uOi9pKSl7enQoKTtsZXQgZT1zLmdldFJlc3BvbnNlSGVhZGVyKCJIWC1Mb2NhdGlvbiIpO3ZhciB0O2lmKGUuaW5kZXhPZigieyIpPT09MCl7dD1TKGUpO2U9dC5wYXRoO2RlbGV0ZSB0LnBhdGh9Um4oImdldCIsZSx0KS50aGVuKGZ1bmN0aW9uKCl7JHQoZSl9KTtyZXR1cm59Y29uc3Qgbj1SKHMsL0hYLVJlZnJlc2g6L2kpJiZzLmdldFJlc3BvbnNlSGVhZGVyKCJIWC1SZWZyZXNoIik9PT0idHJ1ZSI7aWYoUihzLC9IWC1SZWRpcmVjdDovaSkpe2kua2VlcEluZGljYXRvcnM9dHJ1ZTtsb2NhdGlvbi5ocmVmPXMuZ2V0UmVzcG9uc2VIZWFkZXIoIkhYLVJlZGlyZWN0Iik7biYmbG9jYXRpb24ucmVsb2FkKCk7cmV0dXJufWlmKG4pe2kua2VlcEluZGljYXRvcnM9dHJ1ZTtsb2NhdGlvbi5yZWxvYWQoKTtyZXR1cm59aWYoUihzLC9IWC1SZXRhcmdldDovaSkpe2lmKHMuZ2V0UmVzcG9uc2VIZWFkZXIoIkhYLVJldGFyZ2V0Iik9PT0idGhpcyIpe2kudGFyZ2V0PW99ZWxzZXtpLnRhcmdldD11ZShhZShvLHMuZ2V0UmVzcG9uc2VIZWFkZXIoIkhYLVJldGFyZ2V0IikpKX19Y29uc3QgdT1ObihvLGkpO2NvbnN0IHI9UG4ocyk7Y29uc3QgYT1yLnN3YXA7bGV0IGY9ISFyLmVycm9yO2xldCBoPVEuY29uZmlnLmlnbm9yZVRpdGxlfHxyLmlnbm9yZVRpdGxlO2xldCBkPXIuc2VsZWN0O2lmKHIudGFyZ2V0KXtpLnRhcmdldD11ZShhZShvLHIudGFyZ2V0KSl9dmFyIGc9ZS5zd2FwT3ZlcnJpZGU7aWYoZz09bnVsbCYmci5zd2FwT3ZlcnJpZGUpe2c9ci5zd2FwT3ZlcnJpZGV9aWYoUihzLC9IWC1SZXRhcmdldDovaSkpe2lmKHMuZ2V0UmVzcG9uc2VIZWFkZXIoIkhYLVJldGFyZ2V0Iik9PT0idGhpcyIpe2kudGFyZ2V0PW99ZWxzZXtpLnRhcmdldD11ZShhZShvLHMuZ2V0UmVzcG9uc2VIZWFkZXIoIkhYLVJldGFyZ2V0IikpKX19aWYoUihzLC9IWC1SZXN3YXA6L2kpKXtnPXMuZ2V0UmVzcG9uc2VIZWFkZXIoIkhYLVJlc3dhcCIpfXZhciBwPXMucmVzcG9uc2U7dmFyIG09Y2Uoe3Nob3VsZFN3YXA6YSxzZXJ2ZXJSZXNwb25zZTpwLGlzRXJyb3I6ZixpZ25vcmVUaXRsZTpoLHNlbGVjdE92ZXJyaWRlOmQsc3dhcE92ZXJyaWRlOmd9LGkpO2lmKHIuZXZlbnQmJiFoZShsLHIuZXZlbnQsbSkpcmV0dXJuO2lmKCFoZShsLCJodG14OmJlZm9yZVN3YXAiLG0pKXJldHVybjtsPW0udGFyZ2V0O3A9bS5zZXJ2ZXJSZXNwb25zZTtmPW0uaXNFcnJvcjtoPW0uaWdub3JlVGl0bGU7ZD1tLnNlbGVjdE92ZXJyaWRlO2c9bS5zd2FwT3ZlcnJpZGU7aS50YXJnZXQ9bDtpLmZhaWxlZD1mO2kuc3VjY2Vzc2Z1bD0hZjtpZihtLnNob3VsZFN3YXApe2lmKHMuc3RhdHVzPT09Mjg2KXtsdChvKX1GdChvLGZ1bmN0aW9uKGUpe3A9ZS50cmFuc2Zvcm1SZXNwb25zZShwLHMsbyl9KTtpZih1LnR5cGUpe3p0KCl9dmFyIHg9Z24obyxnKTtpZigheC5oYXNPd25Qcm9wZXJ0eSgiaWdub3JlVGl0bGUiKSl7eC5pZ25vcmVUaXRsZT1ofWwuY2xhc3NMaXN0LmFkZChRLmNvbmZpZy5zd2FwcGluZ0NsYXNzKTtsZXQgbj1udWxsO2xldCByPW51bGw7aWYoYyl7ZD1jfWlmKFIocywvSFgtUmVzZWxlY3Q6L2kpKXtkPXMuZ2V0UmVzcG9uc2VIZWFkZXIoIkhYLVJlc2VsZWN0Iil9Y29uc3QgeT1yZShvLCJoeC1zZWxlY3Qtb29iIik7Y29uc3QgYj1yZShvLCJoeC1zZWxlY3QiKTtsZXQgZT1mdW5jdGlvbigpe3RyeXtpZih1LnR5cGUpe2hlKG5lKCkuYm9keSwiaHRteDpiZWZvcmVIaXN0b3J5VXBkYXRlIixjZSh7aGlzdG9yeTp1fSxpKSk7aWYodS50eXBlPT09InB1c2giKXskdCh1LnBhdGgpO2hlKG5lKCkuYm9keSwiaHRteDpwdXNoZWRJbnRvSGlzdG9yeSIse3BhdGg6dS5wYXRofSl9ZWxzZXtKdCh1LnBhdGgpO2hlKG5lKCkuYm9keSwiaHRteDpyZXBsYWNlZEluSGlzdG9yeSIse3BhdGg6dS5wYXRofSl9fSRlKGwscCx4LHtzZWxlY3Q6ZHx8YixzZWxlY3RPT0I6eSxldmVudEluZm86aSxhbmNob3I6aS5wYXRoSW5mby5hbmNob3IsY29udGV4dEVsZW1lbnQ6byxhZnRlclN3YXBDYWxsYmFjazpmdW5jdGlvbigpe2lmKFIocywvSFgtVHJpZ2dlci1BZnRlci1Td2FwOi9pKSl7bGV0IGU9bztpZighbGUobykpe2U9bmUoKS5ib2R5fUplKHMsIkhYLVRyaWdnZXItQWZ0ZXItU3dhcCIsZSl9fSxhZnRlclNldHRsZUNhbGxiYWNrOmZ1bmN0aW9uKCl7aWYoUihzLC9IWC1UcmlnZ2VyLUFmdGVyLVNldHRsZTovaSkpe2xldCBlPW87aWYoIWxlKG8pKXtlPW5lKCkuYm9keX1KZShzLCJIWC1UcmlnZ2VyLUFmdGVyLVNldHRsZSIsZSl9b2Uobil9fSl9Y2F0Y2goZSl7ZmUobywiaHRteDpzd2FwRXJyb3IiLGkpO29lKHIpO3Rocm93IGV9fTtsZXQgdD1RLmNvbmZpZy5nbG9iYWxWaWV3VHJhbnNpdGlvbnM7aWYoeC5oYXNPd25Qcm9wZXJ0eSgidHJhbnNpdGlvbiIpKXt0PXgudHJhbnNpdGlvbn1pZih0JiZoZShvLCJodG14OmJlZm9yZVRyYW5zaXRpb24iLGkpJiZ0eXBlb2YgUHJvbWlzZSE9PSJ1bmRlZmluZWQiJiZkb2N1bWVudC5zdGFydFZpZXdUcmFuc2l0aW9uKXtjb25zdCB2PW5ldyBQcm9taXNlKGZ1bmN0aW9uKGUsdCl7bj1lO3I9dH0pO2NvbnN0IHc9ZTtlPWZ1bmN0aW9uKCl7ZG9jdW1lbnQuc3RhcnRWaWV3VHJhbnNpdGlvbihmdW5jdGlvbigpe3coKTtyZXR1cm4gdn0pfX1pZih4LnN3YXBEZWxheT4wKXtFKCkuc2V0VGltZW91dChlLHguc3dhcERlbGF5KX1lbHNle2UoKX19aWYoZil7ZmUobywiaHRteDpyZXNwb25zZUVycm9yIixjZSh7ZXJyb3I6IlJlc3BvbnNlIFN0YXR1cyBFcnJvciBDb2RlICIrcy5zdGF0dXMrIiBmcm9tICIraS5wYXRoSW5mby5yZXF1ZXN0UGF0aH0saSkpfX1jb25zdCBNbj17fTtmdW5jdGlvbiBYbigpe3JldHVybntpbml0OmZ1bmN0aW9uKGUpe3JldHVybiBudWxsfSxnZXRTZWxlY3RvcnM6ZnVuY3Rpb24oKXtyZXR1cm4gbnVsbH0sb25FdmVudDpmdW5jdGlvbihlLHQpe3JldHVybiB0cnVlfSx0cmFuc2Zvcm1SZXNwb25zZTpmdW5jdGlvbihlLHQsbil7cmV0dXJuIGV9LGlzSW5saW5lU3dhcDpmdW5jdGlvbihlKXtyZXR1cm4gZmFsc2V9LGhhbmRsZVN3YXA6ZnVuY3Rpb24oZSx0LG4scil7cmV0dXJuIGZhbHNlfSxlbmNvZGVQYXJhbWV0ZXJzOmZ1bmN0aW9uKGUsdCxuKXtyZXR1cm4gbnVsbH19fWZ1bmN0aW9uIEZuKGUsdCl7aWYodC5pbml0KXt0LmluaXQobil9TW5bZV09Y2UoWG4oKSx0KX1mdW5jdGlvbiBCbihlKXtkZWxldGUgTW5bZV19ZnVuY3Rpb24gVW4oZSxuLHIpe2lmKG49PXVuZGVmaW5lZCl7bj1bXX1pZihlPT11bmRlZmluZWQpe3JldHVybiBufWlmKHI9PXVuZGVmaW5lZCl7cj1bXX1jb25zdCB0PXRlKGUsImh4LWV4dCIpO2lmKHQpe3NlKHQuc3BsaXQoIiwiKSxmdW5jdGlvbihlKXtlPWUucmVwbGFjZSgvIC9nLCIiKTtpZihlLnNsaWNlKDAsNyk9PSJpZ25vcmU6Iil7ci5wdXNoKGUuc2xpY2UoNykpO3JldHVybn1pZihyLmluZGV4T2YoZSk8MCl7Y29uc3QgdD1NbltlXTtpZih0JiZuLmluZGV4T2YodCk8MCl7bi5wdXNoKHQpfX19KX1yZXR1cm4gVW4odWUoYyhlKSksbixyKX12YXIgam49ZmFsc2U7bmUoKS5hZGRFdmVudExpc3RlbmVyKCJET01Db250ZW50TG9hZGVkIixmdW5jdGlvbigpe2puPXRydWV9KTtmdW5jdGlvbiBWbihlKXtpZihqbnx8bmUoKS5yZWFkeVN0YXRlPT09ImNvbXBsZXRlIil7ZSgpfWVsc2V7bmUoKS5hZGRFdmVudExpc3RlbmVyKCJET01Db250ZW50TG9hZGVkIixlKX19ZnVuY3Rpb24gX24oKXtpZihRLmNvbmZpZy5pbmNsdWRlSW5kaWNhdG9yU3R5bGVzIT09ZmFsc2Upe2NvbnN0IGU9US5jb25maWcuaW5saW5lU3R5bGVOb25jZT9gIG5vbmNlPSIke1EuY29uZmlnLmlubGluZVN0eWxlTm9uY2V9ImA6IiI7bmUoKS5oZWFkLmluc2VydEFkamFjZW50SFRNTCgiYmVmb3JlZW5kIiwiPHN0eWxlIitlKyI+ICAgICAgLiIrUS5jb25maWcuaW5kaWNhdG9yQ2xhc3MrIntvcGFjaXR5OjB9ICAgICAgLiIrUS5jb25maWcucmVxdWVzdENsYXNzKyIgLiIrUS5jb25maWcuaW5kaWNhdG9yQ2xhc3MrIntvcGFjaXR5OjE7IHRyYW5zaXRpb246IG9wYWNpdHkgMjAwbXMgZWFzZS1pbjt9ICAgICAgLiIrUS5jb25maWcucmVxdWVzdENsYXNzKyIuIitRLmNvbmZpZy5pbmRpY2F0b3JDbGFzcysie29wYWNpdHk6MTsgdHJhbnNpdGlvbjogb3BhY2l0eSAyMDBtcyBlYXNlLWluO30gICAgICA8L3N0eWxlPiIpfX1mdW5jdGlvbiB6bigpe2NvbnN0IGU9bmUoKS5xdWVyeVNlbGVjdG9yKCdtZXRhW25hbWU9Imh0bXgtY29uZmlnIl0nKTtpZihlKXtyZXR1cm4gUyhlLmNvbnRlbnQpfWVsc2V7cmV0dXJuIG51bGx9fWZ1bmN0aW9uICRuKCl7Y29uc3QgZT16bigpO2lmKGUpe1EuY29uZmlnPWNlKFEuY29uZmlnLGUpfX1WbihmdW5jdGlvbigpeyRuKCk7X24oKTtsZXQgZT1uZSgpLmJvZHk7a3QoZSk7Y29uc3QgdD1uZSgpLnF1ZXJ5U2VsZWN0b3JBbGwoIltoeC10cmlnZ2VyPSdyZXN0b3JlZCddLFtkYXRhLWh4LXRyaWdnZXI9J3Jlc3RvcmVkJ10iKTtlLmFkZEV2ZW50TGlzdGVuZXIoImh0bXg6YWJvcnQiLGZ1bmN0aW9uKGUpe2NvbnN0IHQ9ZS50YXJnZXQ7Y29uc3Qgbj1pZSh0KTtpZihuJiZuLnhocil7bi54aHIuYWJvcnQoKX19KTtjb25zdCBuPXdpbmRvdy5vbnBvcHN0YXRlP3dpbmRvdy5vbnBvcHN0YXRlLmJpbmQod2luZG93KTpudWxsO3dpbmRvdy5vbnBvcHN0YXRlPWZ1bmN0aW9uKGUpe2lmKGUuc3RhdGUmJmUuc3RhdGUuaHRteCl7V3QoKTtzZSh0LGZ1bmN0aW9uKGUpe2hlKGUsImh0bXg6cmVzdG9yZWQiLHtkb2N1bWVudDpuZSgpLHRyaWdnZXJFdmVudDpoZX0pfSl9ZWxzZXtpZihuKXtuKGUpfX19O0UoKS5zZXRUaW1lb3V0KGZ1bmN0aW9uKCl7aGUoZSwiaHRteDpsb2FkIix7fSk7ZT1udWxsfSwwKX0pO3JldHVybiBRfSgpOw==' + +/** The decoded htmx@2.0.4 minified source, served verbatim at /admin/assets/htmx.min.js. */ +export const HTMX_JS: string = Buffer.from(HTMX_MIN_JS_B64, 'base64').toString('utf8') + +/** + * The pinned version, used as the asset URL's cache key (see shell.ts). The route + * serves these bytes with `immutable` + a one-year max-age, which is only honest + * while the URL changes with the bytes — bump this in the same edit as the base64 + * above and every cached copy is superseded at once. + */ +export const HTMX_VERSION = '2.0.4' diff --git a/packages/mcp-server/src/http/admin/middleware.ts b/packages/mcp-server/src/http/admin/middleware.ts new file mode 100644 index 0000000..6e857fb --- /dev/null +++ b/packages/mcp-server/src/http/admin/middleware.ts @@ -0,0 +1,188 @@ +// src/http/admin/middleware.ts +import { fromNodeHeaders } from 'better-auth/node' +import type { Request, RequestHandler, Response } from 'express' +import type { Auth } from '../../auth/better-auth.js' +import { hasAdminRole } from '../../store/repos.js' +import { flash } from './views.js' + +/** + * True when the request was issued by htmx (an AJAX fragment swap, not a full + * page load). The ONE definition every error path keys off: with the app shell's + * responseHandling override making 4xx/5xx bodies swappable, an htmx caller must + * receive the flash contract (OOB toast + HX-Reswap:none) or an HX-Redirect — + * a bare fragment would be swapped into the request's own target. + */ +export function isHtmx(req: Request): boolean { + return req.get('HX-Request') === 'true' +} + +/** The signed-in identity a gated handler acts as. */ +export interface GatedSession { + id: string + email: string + role: string | null + admin: boolean +} + +/** + * The shared session gate for BOTH server-rendered surfaces (/admin and + * /account). Resolves the better-auth browser session (cookie, not Bearer — that + * is the MCP machine path) and, when present, stashes the identity in + * `res.locals` (`actorId` for self-mutation guards, `navUser` for the nav account + * chip) and returns it. When absent it sends the unauthenticated response and + * returns null — the caller just `return`s: + * - htmx callers can't follow a 302 (the fetch would transparently follow it and + * swap the sign-in PAGE into the fragment target), so we send `HX-Redirect`, + * which htmx honors with a full navigation regardless of the 401 status. The + * `next` is the DOCUMENT url (HX-Current-URL), not the fragment url — resuming + * on e.g. /admin/health/table would render a bare dump. The sign-in page + * re-validates `next` as same-origin, so a crafted header can't open-redirect. + * - a full page load gets a plain 302 to sign-in with its own url as `next`. + * Keeping this in one place is what stops the two gates from drifting. + */ +export async function resolveSessionOr401(auth: Auth, req: Request, res: Response): Promise { + const result = await auth.api.getSession({ headers: fromNodeHeaders(req.headers) }) + if (!result) { + if (isHtmx(req)) { + // Fall back to THIS router's mount path (req.baseUrl: /admin or /account), + // not a hard-coded /admin — else an expired /account htmx request would + // resume a non-admin at /admin and 403 after sign-in. + const fallback = req.baseUrl || '/admin' + res + .status(401) + .setHeader('HX-Redirect', `/sign-in?next=${encodeURIComponent(docPath(req.get('HX-Current-URL'), fallback))}`) + .type('html') + .send('') // htmx navigates on HX-Redirect before any swap — body never renders + return null + } + res.redirect(`/sign-in?next=${encodeURIComponent(req.originalUrl)}`) + return null + } + const u = result.user as { id?: string; email?: string; role?: string | null } + const session: GatedSession = { + id: u.id ?? '', + email: u.email ?? '', + role: u.role ?? null, + admin: hasAdminRole(u.role), + } + res.locals.actorId = session.id + res.locals.navUser = { email: session.email, admin: session.admin } + return session +} + +/** + * Gate the admin panel on the better-auth browser session + `admin` role. + * Reads the session cookie (NOT a Bearer JWT — that is the MCP machine path) + * via `auth.api.getSession`, which the admin() plugin extends with `user.role`. + * `getSession` returns null (never throws) when unauthenticated → 401/redirect. + * Non-admin → 403. Mirrors better-auth's own gate: split `role` on `,`/space, + * membership-test against the default adminRoles=["admin"]. + * + * CSRF posture: this gate only proves *who* the caller is (an authenticated + * admin), not that a state-changing request was *intentional*. The browser + * session rides on a cookie, so a cross-site POST would carry it implicitly. + * better-auth's default `SameSite=Lax` cookie blocks the classic form-POST CSRF, + * but that guarantee evaporates under `crossSubDomainCookies` / `sameSite:'none'`. + * So we ALSO run {@link adminCsrf} (an explicit same-origin Origin/Referer check) + * on every unsafe method — defense in depth, independent of cookie config. + */ +export function adminGate(auth: Auth): RequestHandler { + return (req, res, next) => { + resolveSessionOr401(auth, req, res) + .then((session) => { + if (session === null) return // unauthenticated response already sent + if (!session.admin) { + // htmx callers get the flash contract — a bare

    would be swapped into + // the request's own target (e.g. the health poll's ) now that the + // shell config makes 4xx bodies swappable. + if (isHtmx(req)) { + flash(res, 403, 'Admin role required.') + return + } + res.status(403).type('html').send('

    403 — admin role required

    ') + return + } + next() + }) + .catch(next) + } +} + +/** Path+query of the document URL a client header reports, or `fallback` when absent/malformed. */ +function docPath(currentUrl: string | undefined, fallback = '/admin'): string { + try { + const u = new URL(currentUrl ?? '') + return u.pathname + u.search + } catch { + return fallback // header absent or malformed — resume on the requesting surface + } +} + +/** Methods that never change server state — exempt from the CSRF origin check. */ +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']) + +/** + * Explicit same-origin CSRF guard for the admin panel's state-changing routes. + * For any non-safe method (POST/PUT/DELETE/PATCH), the request's `Origin` (or, + * when absent, `Referer`) origin MUST equal our canonical public origin — else + * 403. This does not depend on the session cookie's SameSite setting, so it + * holds even under `crossSubDomainCookies` / `sameSite:'none'`. + * + * htmx sends `Origin` on every AJAX request, so legitimate panel traffic is + * unaffected. A request with neither header on an unsafe method is rejected + * (a real browser always sends one of them for a cross-context write). + */ +export function adminCsrf(publicUrl: string): RequestHandler { + let expected: string + try { + expected = new URL(publicUrl).origin + } catch { + // A malformed publicUrl must fail closed, not open: reject every unsafe write. + expected = '\0invalid' + } + return (req, res, next) => { + if (SAFE_METHODS.has(req.method)) { + next() + return + } + const stated = req.get('Origin') ?? req.get('Referer') + let ok = false + if (stated !== undefined && stated !== '') { + try { + ok = new URL(stated).origin === expected + } catch { + ok = false + } + } + if (!ok) { + // Same flash-vs-fragment split as adminGate's 403: under the swappable-4xx + // config a bare

    would replace the request's target (e.g. outerHTML-delete + // a row whose DELETE was in fact rejected). + if (isHtmx(req)) { + flash(res, 403, 'Cross-origin request rejected.') + return + } + res.status(403).type('html').send('

    Cross-origin request rejected.

    ') + return + } + next() + } +} + +/** + * CSP for every admin response. `script-src 'self'` (no `unsafe-inline`) — htmx + * is vendored and served same-origin, so no inline `\n`)} +
    + +
    +
    ${opts.body}
    +
    +

    +
    ` +} + +export interface AuthShellOptions { + title: string + /** Trusted, already-escaped card contents (heading, copy, form). */ + body: string + /** + * Optional inline client script. ONLY the sign-in/consent pages pass this — their + * CSP allows `script-src 'unsafe-inline'`; the setup wizard (admin CSP) passes none. + */ + scripts?: string +} + +/** + * A pre-auth page (sign-in / consent / first-run setup): one centered card on the + * shared canvas, no admin nav. `body` fills the card after the brand mark. + */ +export function authShell(opts: AuthShellOptions): string { + const script = opts.scripts !== undefined ? `\n` : '' + return `${head(opts.title)} +
    +
    ${BRAND}
    +${opts.body} +
    ${script}` +} diff --git a/packages/mcp-server/src/version.ts b/packages/mcp-server/src/version.ts new file mode 100644 index 0000000..af508b7 --- /dev/null +++ b/packages/mcp-server/src/version.ts @@ -0,0 +1,22 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +/** + * This package's version, read from its `package.json` relative to the running + * module: the compiled bundle lives at `dist/*.js` (package.json one level up), + * the source at `src/*.ts` (two levels up). A missing/unreadable file must never + * crash startup, so it falls back to `0.0.0`. + */ +export function readPackageVersion(): string { + const here = dirname(fileURLToPath(import.meta.url)) + for (const candidate of [join(here, '..', 'package.json'), join(here, '..', '..', 'package.json')]) { + try { + const pkg = JSON.parse(readFileSync(candidate, 'utf8')) as { version?: string } + if (typeof pkg.version === 'string' && pkg.version.length > 0) return pkg.version + } catch { + // Not found at this depth — try the next candidate. + } + } + return '0.0.0' +} diff --git a/packages/mcp-server/test/e2e/_harness.ts b/packages/mcp-server/test/e2e/_harness.ts new file mode 100644 index 0000000..a722cec --- /dev/null +++ b/packages/mcp-server/test/e2e/_harness.ts @@ -0,0 +1,374 @@ +// test/e2e/_harness.ts +import { createHash, randomBytes } from 'node:crypto' +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { ConnectionPool, type ConnectionSource } from '@1c-odata/mcp/internal' +import { buildAuth } from '../../src/auth/better-auth.js' +import { resolveCanonicalUrls } from '../../src/auth/config.js' +import { createApp } from '../../src/http/app.js' +import { createAuthMount } from '../../src/http/auth-mount.js' +import { buildMcpServer } from '../../src/server-factory.js' +import { createDb } from '../../src/store/db.js' +import { runAuthMigrations } from '../../src/store/migrate.js' + +const REDIRECT_URI = 'http://127.0.0.1:9999/callback' // never dereferenced (headless) + +const b64url = (buf: Buffer): string => buf.toString('base64url') + +export interface AuthHarness { + /** `${publicUrl}/api/auth` — the OAuth issuer. */ + base: string + publicUrl: string + /** `${publicUrl}/mcp` — the JWT `aud` when `resource` is sent. */ + mcpUrl: string + /** The live better-auth instance (shared with the app so keys + issuer match). */ + // biome-ignore lint/suspicious/noExplicitAny: the inferred betterAuth() type is not portable; only structural use here. + auth: any + /** + * Run createUser(admin)→signIn→DCR→authorize(+consent)→token. Sends `resource` + * on BOTH authorize and token when provided → a signed JWT; omit it → an opaque + * token. + */ + mintToken(o?: { resource?: string; scope?: string }): Promise + /** + * Close ONLY the AS's HTTP socket, leaving its better-auth instance (and the + * key material behind it) alive. Simulates a deploy where the server cannot + * reach its own public origin — see the JWKS-offline e2e. + */ + closeHttp(): Promise + close(): Promise +} + +/** + * Provision a user through the admin() plugin's trusted server path (header-less, + * so the create-check is skipped — the same path `admin-create` uses). Public + * self-service sign-up is disabled in production (`disableSignUp: true`), so tests + * must create users this way rather than POSTing /sign-up/email. + */ +export async function createTestUser( + // biome-ignore lint/suspicious/noExplicitAny: the inferred betterAuth() type is not portable; only structural use here. + auth: any, + o: { email: string; password: string; name?: string }, +): Promise { + await auth.api.createUser({ + body: { email: o.email, password: o.password, name: o.name ?? 'E2E', role: 'user' }, + }) +} + +/** + * All the state-changing OAuth POSTs a browser client would make, carrying Origin + * for CSRF. The user is provisioned through the admin plugin (`auth`) — public + * sign-up is disabled — then signed IN over HTTP to obtain the session cookie. + */ +export async function runFlow( + base: string, + publicUrl: string, + // biome-ignore lint/suspicious/noExplicitAny: the inferred betterAuth() type is not portable; only structural use here. + auth: any, + opts: { resource?: string; scope?: string }, +): Promise { + const scope = opts.scope ?? 'openid mcp:read offline_access' + const email = `user-${b64url(randomBytes(6))}@example.com` + const password = 'Password123!' + const codeVerifier = b64url(randomBytes(32)) + const codeChallenge = b64url(createHash('sha256').update(codeVerifier).digest()) + + // Provision the user via the admin plugin (self-service sign-up is disabled), + // then sign in over HTTP to obtain the session cookie. + await createTestUser(auth, { email, password }) + const signIn = await fetch(`${base}/sign-in/email`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: publicUrl }, + body: JSON.stringify({ email, password }), + }) + const cookie = signIn.headers + .getSetCookie() + .map((c) => c.split(';', 1)[0]) + .join('; ') + + // Dynamic Client Registration (what an MCP connector does). + const reg = await fetch(`${base}/oauth2/register`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: publicUrl }, + body: JSON.stringify({ + client_name: 'e2e-mcp-client', + redirect_uris: [REDIRECT_URI], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + scope, + }), + }) + if (reg.status !== 200) throw new Error(`DCR failed: ${reg.status} ${await reg.text()}`) + const clientId: string = (await reg.json()).client_id + + const authorizeUrl = new URL(`${base}/oauth2/authorize`) + const authorizeParams: Record = { + response_type: 'code', + client_id: clientId, + redirect_uri: REDIRECT_URI, + scope, + state: b64url(randomBytes(8)), + code_challenge: codeChallenge, + code_challenge_method: 'S256', + } + if (opts.resource !== undefined) authorizeParams.resource = opts.resource + authorizeUrl.search = new URLSearchParams(authorizeParams).toString() + + // better-auth signals redirects either as a 3xx (Location) or a 200 JSON + // `{ redirect: true, url }`; also accept `redirect_uri`/`redirectURI`. + const redirectTarget = async (res: Response): Promise => { + if (res.status >= 300 && res.status < 400) return res.headers.get('location') + if (res.headers.get('content-type')?.includes('application/json')) { + const j = (await res + .clone() + .json() + .catch(() => null)) as Record | null + if (j && j.redirect === true && typeof j.url === 'string') return j.url + if (j && typeof j.redirect_uri === 'string') return j.redirect_uri + if (j && typeof j.redirectURI === 'string') return j.redirectURI + } + return null + } + + // First authorize → /consent?. The consent endpoint reads + // the pending request from the signed query echoed back in `oauth_query` (a + // before-hook re-verifies the sig), then completes the authorization ITSELF + // and returns the client redirect (with the code). + const redirected = await fetch(authorizeUrl, { headers: { cookie }, redirect: 'manual' }) + let target = await redirectTarget(redirected) + if (target?.includes('/consent')) { + const oauthQuery = target.split('?', 2)[1] ?? '' + const consentRes = await fetch(`${base}/oauth2/consent`, { + method: 'POST', + headers: { cookie, 'content-type': 'application/json', origin: publicUrl }, + redirect: 'manual', + body: JSON.stringify({ accept: true, oauth_query: oauthQuery }), + }) + target = await redirectTarget(consentRes) + if (target === null) throw new Error(`consent did not redirect: ${consentRes.status} ${await consentRes.text()}`) + } + if (target === null) throw new Error(`authorize did not redirect: ${redirected.status} ${await redirected.text()}`) + const code = new URL(target, base).searchParams.get('code') + if (code === null) throw new Error(`no authorization code in redirect: ${target}`) + + const tokenBody: Record = { + grant_type: 'authorization_code', + code, + redirect_uri: REDIRECT_URI, + client_id: clientId, + code_verifier: codeVerifier, + } + if (opts.resource !== undefined) tokenBody.resource = opts.resource // flips JWT signing on + const tokenRes = await fetch(`${base}/oauth2/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', origin: publicUrl }, + body: new URLSearchParams(tokenBody).toString(), + }) + if (tokenRes.status !== 200) throw new Error(`token exchange failed: ${tokenRes.status} ${await tokenRes.text()}`) + const accessToken: string | undefined = (await tokenRes.json()).access_token + if (accessToken === undefined) throw new Error('no access_token in token response') + return accessToken +} + +/** + * Serve a MUTABLE better-auth handler over a real loopback socket. Binds ONCE on + * an ephemeral port and returns immediately (handler starts as a 503 stub, set + * later via `setHandler`). + * + * This is what avoids the probe→close→rebind race: the AS's `iss`/`aud` default + * to its `baseURL`, which must equal the listening origin — but instead of + * grabbing a port from a throwaway socket, closing it, and racing to rebind the + * real AS on that same port (a window another parallel worker can steal, → + * EADDRINUSE flake), we bind once, learn the port, build the AS for that port, + * then install its handler. No window exists. + */ +function serveSwappable(): Promise<{ + server: Server + publicUrl: string + setHandler(h: (request: Request) => Promise): void +}> { + let active: ((request: Request) => Promise) | undefined + const server = createServer(async (req, res) => { + if (active === undefined) { + res.writeHead(503) + res.end() + return + } + const url = `http://${req.headers.host}${req.url}` + const method = req.method ?? 'GET' + const chunks: Buffer[] = [] + for await (const c of req) chunks.push(c as Buffer) + const body = chunks.length > 0 ? Buffer.concat(chunks) : undefined + const request = new Request(url, { + method, + headers: req.headers as Record, + ...(body !== undefined && method !== 'GET' && method !== 'HEAD' ? { body } : {}), + }) + const response = await active(request) + res.writeHead(response.status, Object.fromEntries(response.headers)) + res.end(response.body !== null ? Buffer.from(await response.arrayBuffer()) : undefined) + }) + return new Promise((resolve) => + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() as AddressInfo + resolve({ + server, + publicUrl: `http://127.0.0.1:${port}`, + setHandler(h) { + active = h + }, + }) + }), + ) +} + +/** + * Boot an ephemeral AS on a loopback port (race-free — see {@link serveSwappable}). + * `extraResourcePaths` (e.g. `['/other']`, resolved against `base` once the port + * is known) widen `validAudiences` so the wrong-aud test can mint a token for a + * different, but AS-permitted, resource. + */ +export async function startAuthServer(extraResourcePaths: string[] = []): Promise { + const srv = await serveSwappable() + const publicUrl = srv.publicUrl + const mcpUrl = `${publicUrl}/mcp` + const base = `${publicUrl}/api/auth` + const extraAudiences = extraResourcePaths.map((p) => `${base}${p}`) + // Use the REAL production wiring (createDb + runAuthMigrations + buildAuth, not a + // hand-copied plugin set or a separate schema push) so this e2e doubles as a + // migration-drift guard: it applies the committed ./drizzle SQL, and if a plugin + // is added to buildAuth without regenerating auth-schema.ts + the SQL, the + // authorize→consent→token flow touches a missing table and the test fails. + const handle = createDb({ kind: 'pglite' }) + const auth = buildAuth({ + urls: resolveCanonicalUrls(publicUrl), + db: handle.db, + secret: 'e2e-secret-not-for-prod-0123456789', + extraAudiences, + }) + await runAuthMigrations(handle) + srv.setHandler((req) => auth.handler(req)) + + return { + base, + publicUrl, + mcpUrl, + auth, + mintToken: (o = {}) => runFlow(base, publicUrl, auth, o), + // `close()` is idempotent w.r.t. this: closing an already-closed server just + // hands the callback an error, which we ignore. + closeHttp: () => new Promise((r) => srv.server.close(() => r())), + async close() { + await new Promise((r) => srv.server.close(() => r())) + await handle.close() + }, + } +} + +const MINI_EDMX = ` + + + + + + + + + + + + + +` + +/** A minimal REAL 1С OData stub on a loopback port (mirrors mcp-endpoint.test.ts). */ +function startUpstream(): Promise<{ server: Server; baseUrl: string }> { + const server = createServer((req, res) => { + const path = (req.url ?? '').split('?', 1)[0] + if (path === '/odata/$metadata') { + res.writeHead(200, { 'Content-Type': 'application/xml' }) + res.end(MINI_EDMX) + return + } + if (path === '/odata/Catalog_X') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ 'odata.metadata': 'm', value: [{ Ref_Key: 'a', Code: 'RUB' }] })) + return + } + res.writeHead(404) + res.end() + }) + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() as AddressInfo + resolve({ server, baseUrl: `http://127.0.0.1:${port}/odata` }) + }) + }) +} + +export interface AppWithAuth { + appBase: string // http://127.0.0.1: + mcpUrl: string // `${appBase}/mcp` + close(): Promise +} + +/** + * Boot the full Express app WITH auth, gated by a bearer verifier whose issuer is + * the harness AS. The app SHARES the harness's better-auth instance (same signing + * keys, same issuer, same JWKS) so tokens the harness mints verify against the + * app's gate. Two-phase bound to a fixed port so `publicUrl` matches the socket. + * + * Host allowlist is disabled (no `allowedHosts`) so the DNS-rebinding guard does + * not pre-empt the auth gate we are testing. + */ +export async function startAppWithAuth(as: AuthHarness): Promise { + const upstream = await startUpstream() + const source: ConnectionSource = { + async getBase(name) { + return name === 'demo' ? { baseUrl: upstream.baseUrl, login: 'u', serverTimezone: 'UTC' } : undefined + }, + async listBases() { + return [{ name: 'demo', baseUrl: upstream.baseUrl, login: 'u', serverTimezone: 'UTC' }] + }, + async getSecret() { + return 'p' + }, + async secretSource() { + return 'env' + }, + } + const pool = new ConnectionPool(source) + + // Grab a free port, then bind the app there so its publicUrl matches the socket. + const probe = createServer() + const appPort = await new Promise((resolve) => + probe.listen(0, '127.0.0.1', () => resolve((probe.address() as AddressInfo).port)), + ) + await new Promise((r) => probe.close(() => r())) + + const appPublicUrl = `http://127.0.0.1:${appPort}` + const urls = resolveCanonicalUrls(appPublicUrl) + // The token's `aud` is the HARNESS resource id (`as.mcpUrl`), and its `iss` is + // the harness AS (`as.base`, which owns the keys + JWKS). Pin the gate to those + // so a token the harness mints for `as.mcpUrl` verifies against the app. + const gateUrls = { ...urls, issuer: as.base, mcpResourceUrl: as.mcpUrl } + const { authRouter, bearerMiddleware } = createAuthMount({ auth: as.auth, urls: gateUrls }) + + const { app, sessions } = createApp({ + buildServer: () => buildMcpServer(pool, { version: '9.9.9', dataDir: '/synthetic' }), + auth: { auth: as.auth, urls: gateUrls, authRouter, bearerMiddleware }, + }) + const server = app.listen(appPort, '127.0.0.1') + await new Promise((r) => server.once('listening', r)) + + return { + appBase: appPublicUrl, + mcpUrl: as.mcpUrl, + async close() { + sessions.stop() + await new Promise((r) => server.close(() => r())) + await new Promise((r) => upstream.server.close(() => r())) + }, + } +} diff --git a/packages/mcp-server/test/e2e/account.test.ts b/packages/mcp-server/test/e2e/account.test.ts new file mode 100644 index 0000000..f22e135 --- /dev/null +++ b/packages/mcp-server/test/e2e/account.test.ts @@ -0,0 +1,145 @@ +// test/e2e/account.test.ts +// +// Drives the /account router over real HTTP: the any-role session gate, the +// change-password flow (flash contract both ways), sign-out cookie forwarding, +// and the CSRF guard. better-auth is stubbed — the wiring is under test. +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import express from 'express' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Auth } from '../../src/auth/better-auth.js' +import { createAccountRouter } from '../../src/http/account/router.js' + +let server: Server +let origin: string +const session = { value: null as unknown } +// revokeOtherSessions rotates the current session → better-auth returns a fresh +// Set-Cookie the handler must forward. Model that with returnHeaders. +const changePassword = vi.fn(async (_arg?: unknown) => { + const headers = new Headers() + headers.append('set-cookie', 'better-auth.session_token=rotated; Path=/; HttpOnly') + return { headers, response: {} } +}) +const signOut = vi.fn(async (_arg?: unknown) => { + const headers = new Headers() + headers.append('set-cookie', 'better-auth.session_token=; Max-Age=0; Path=/') + return { headers, response: { success: true } } +}) + +beforeEach(async () => { + changePassword.mockClear() + signOut.mockClear() + const auth = { + api: { + getSession: vi.fn().mockImplementation(async () => session.value), + changePassword, + signOut, + }, + } as unknown as Auth + const app = express() + app.use('/account', createAccountRouter({ auth, publicUrl: 'http://127.0.0.1' })) + server = createServer(app) + await new Promise((r) => server.listen(0, '127.0.0.1', r)) + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}` +}) + +afterEach(async () => { + await new Promise((r) => server.close(() => r())) +}) + +describe('/account over HTTP', () => { + it('redirects an anonymous browser to sign-in with next=/account', async () => { + session.value = null + const res = await fetch(`${origin}/account`, { redirect: 'manual' }) + expect(res.status).toBe(302) + expect(res.headers.get('location')).toBe('/sign-in?next=%2Faccount') + }) + + it('renders the page for a PLAIN user: email chip + change-password form, NO admin nav sections', async () => { + session.value = { user: { id: 'u1', email: 'user@x.dev', role: 'user' }, session: {} } + const res = await fetch(`${origin}/account`) + expect(res.status).toBe(200) + const html = await res.text() + expect(html).toContain('Change password') + expect(html).toContain('user@x.dev') + expect(html).toContain('data-gen-password') // generator + copy affordances present + expect(html).toContain('action="/account/sign-out"') + expect(html).not.toContain('href="/admin/bases"') // admin sections hidden for a plain user + }) + + it('keeps the full admin nav for an admin visiting /account', async () => { + session.value = { user: { id: 'a1', email: 'admin@x.dev', role: 'admin' }, session: {} } + const html = await (await fetch(`${origin}/account`)).text() + expect(html).toContain('href="/admin/bases"') + }) + + it('changes the password with revokeOtherSessions and flashes ok', async () => { + session.value = { user: { id: 'u1', email: 'user@x.dev', role: 'user' }, session: {} } + const res = await fetch(`${origin}/account/password`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'http://127.0.0.1' }, + body: 'current=old-pass-1&password=new-pass-123', + }) + expect(res.status).toBe(200) + expect(await res.text()).toContain('flash-msg ok') + expect(changePassword).toHaveBeenCalledWith( + expect.objectContaining({ + body: { currentPassword: 'old-pass-1', newPassword: 'new-pass-123', revokeOtherSessions: true }, + returnHeaders: true, + }), + ) + // The rotated session cookie must be forwarded, or the browser keeps the dead + // token and is bounced to sign-in on its next request. + expect(res.headers.get('set-cookie')).toContain('rotated') + }) + + it('a wrong current password flashes a redacted 400', async () => { + session.value = { user: { id: 'u1', email: 'user@x.dev', role: 'user' }, session: {} } + changePassword.mockRejectedValueOnce(new Error('INVALID_PASSWORD: nope')) + const res = await fetch(`${origin}/account/password`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'http://127.0.0.1' }, + body: 'current=wrong&password=new-pass-123', + }) + expect(res.status).toBe(400) + const body = await res.text() + expect(body).toContain('hx-swap-oob') + expect(body).not.toContain('INVALID_PASSWORD') // raw plugin error never leaks + }) + + it('sign-out forwards the cookie-clearing headers and lands on sign-in', async () => { + session.value = { user: { id: 'u1', email: 'user@x.dev', role: 'user' }, session: {} } + const res = await fetch(`${origin}/account/sign-out`, { + method: 'POST', + headers: { origin: 'http://127.0.0.1' }, + redirect: 'manual', + }) + expect(res.status).toBe(303) + expect(res.headers.get('location')).toBe('/sign-in') + expect(res.headers.get('set-cookie')).toContain('Max-Age=0') + expect(signOut).toHaveBeenCalledTimes(1) + }) + + it('sign-out without a session still lands on sign-in (no 500)', async () => { + session.value = null + signOut.mockRejectedValueOnce(new Error('no session')) + const res = await fetch(`${origin}/account/sign-out`, { + method: 'POST', + headers: { origin: 'http://127.0.0.1' }, + redirect: 'manual', + }) + expect(res.status).toBe(303) + expect(res.headers.get('location')).toBe('/sign-in') + }) + + it('rejects a cross-origin password POST (CSRF) before the handler', async () => { + session.value = { user: { id: 'u1', email: 'user@x.dev', role: 'user' }, session: {} } + const res = await fetch(`${origin}/account/password`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'https://evil.example.com' }, + body: 'current=a&password=new-pass-123', + }) + expect(res.status).toBe(403) + expect(changePassword).not.toHaveBeenCalled() + }) +}) diff --git a/packages/mcp-server/test/e2e/admin-create.test.ts b/packages/mcp-server/test/e2e/admin-create.test.ts new file mode 100644 index 0000000..dba9c98 --- /dev/null +++ b/packages/mcp-server/test/e2e/admin-create.test.ts @@ -0,0 +1,77 @@ +// test/e2e/admin-create.test.ts +// +// `admin-create` is the CLI equivalent of the one-time /setup wizard — and the +// wizard self-closes once an admin exists. This drives the command against a +// PERSISTENT pglite store to pin that same bootstrap-only contract: the first run +// seeds an admin, a repeat run is refused (it bypasses every session check, so +// without the gate it would silently mint extra admins), and --force is the +// deliberate escape hatch. +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { buildProgram } from '../../src/cli.js' +import { createDb } from '../../src/store/db.js' +import { runAuthMigrations } from '../../src/store/migrate.js' +import { countAdmins } from '../../src/store/repos.js' + +const PUBLIC_URL = 'http://127.0.0.1:9998' +const SECRET = 'e2e-secret-not-for-prod-0123456789' + +let dataDir: string +let prevSecret: string | undefined +let prevPublicUrl: string | undefined + +beforeEach(() => { + dataDir = mkdtempSync(join(tmpdir(), 'mcp-admincreate-')) + prevSecret = process.env.BETTER_AUTH_SECRET + prevPublicUrl = process.env.ONEC_MCP_PUBLIC_URL + process.env.BETTER_AUTH_SECRET = SECRET + process.env.ONEC_MCP_PUBLIC_URL = PUBLIC_URL +}) + +afterEach(() => { + if (prevSecret === undefined) delete process.env.BETTER_AUTH_SECRET + else process.env.BETTER_AUTH_SECRET = prevSecret + if (prevPublicUrl === undefined) delete process.env.ONEC_MCP_PUBLIC_URL + else process.env.ONEC_MCP_PUBLIC_URL = prevPublicUrl + rmSync(dataDir, { recursive: true, force: true }) +}) + +/** Run one CLI subcommand to completion against the shared temp store. */ +async function runCli(args: string[]): Promise { + await buildProgram().parseAsync(['node', 'cli', ...args, '--auth-data-dir', dataDir]) +} + +/** Count admins by reopening the SAME persistent store the CLI wrote to. */ +async function admins(): Promise { + const handle = createDb({ kind: 'pglite', dataDir }) + try { + await runAuthMigrations(handle) + return await countAdmins(handle.db) + } finally { + await handle.close() + } +} + +describe('admin-create CLI (bootstrap-only)', () => { + it('seeds the first admin', async () => { + await runCli(['admin-create', '--email', 'first@example.com', '--password', 'FirstPass1!']) + expect(await admins()).toBe(1) + }) + + it('refuses a second admin and names the sanctioned path', async () => { + await runCli(['admin-create', '--email', 'first@example.com', '--password', 'FirstPass1!']) + await expect( + runCli(['admin-create', '--email', 'second@example.com', '--password', 'SecondPass2!']), + ).rejects.toThrow(/already exists/) + // The refusal must land BEFORE createUser — no extra admin, no stray user row. + expect(await admins()).toBe(1) + }) + + it('--force is the deliberate escape hatch for the ops case', async () => { + await runCli(['admin-create', '--email', 'first@example.com', '--password', 'FirstPass1!']) + await runCli(['admin-create', '--email', 'second@example.com', '--password', 'SecondPass2!', '--force']) + expect(await admins()).toBe(2) + }) +}) diff --git a/packages/mcp-server/test/e2e/admin-panel.test.ts b/packages/mcp-server/test/e2e/admin-panel.test.ts new file mode 100644 index 0000000..4c91fa2 --- /dev/null +++ b/packages/mcp-server/test/e2e/admin-panel.test.ts @@ -0,0 +1,515 @@ +// test/e2e/admin-panel.test.ts +// +// Drives the admin router over real HTTP: the CSP header on every response, the +// pre-gate htmx asset, the session gate (403 non-admin / redirect anonymous), +// the same-origin CSRF guard, the error middleware (a rejecting handler → 500, +// not a crash/hang), and — for an admin session — the dashboard + bases pages +// rendering DB state. The better-auth session is stubbed (a controllable +// `getSession`) so the test stays hermetic; the full OAuth login flow is covered +// by the other e2e specs. +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import type { ReadPool } from '@1c-odata/mcp/internal' +import express from 'express' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Auth } from '../../src/auth/better-auth.js' +import { createAdminRouter } from '../../src/http/admin/router.js' +import { loadKeyring } from '../../src/store/crypto.js' +import { createDb, type DbHandle } from '../../src/store/db.js' +import { runAuthMigrations } from '../../src/store/migrate.js' +import { BaseRepo } from '../../src/store/repos.js' + +const KEY = Buffer.alloc(32, 5).toString('base64') + +let handle: DbHandle +let server: Server +let origin: string +const session = { value: null as unknown } +// Swappable stubs for the better-auth admin API so a test can force a rejection. +// Typed with an explicit arg bag so `.mock.calls[0][0].headers` is inspectable. +type ApiArg = { headers?: unknown; body?: unknown; query?: unknown } +const listUsers = vi.fn(async (_arg?: ApiArg) => ({ users: [] as unknown[] })) +const createUserApi = vi.fn(async (_arg?: ApiArg) => ({ user: { id: 'x', email: 'x@x', name: 'X', role: 'user' } })) + +beforeEach(async () => { + handle = createDb({ kind: 'pglite' }) + await runAuthMigrations(handle) + await new BaseRepo(handle.db).upsert('trade', { + baseUrl: 'http://1c/odata', + login: 'u', + serverTimezone: 'Europe/Moscow', + }) + const keyring = loadKeyring({ ONEC_MCP_ENC_KEY: KEY } as NodeJS.ProcessEnv) + const sharedPool: ReadPool = { get: vi.fn(), list: vi.fn(), refresh: vi.fn() } + listUsers.mockReset().mockResolvedValue({ users: [] }) + createUserApi.mockReset().mockResolvedValue({ user: { id: 'x', email: 'x@x', name: 'X', role: 'user' } }) + const auth = { + api: { + getSession: vi.fn().mockImplementation(async () => session.value), + listUsers, + createUser: createUserApi, + }, + } as unknown as Auth + + const app = express() + app.use(express.json()) + const publicUrl = 'http://127.0.0.1' + app.use('/admin', createAdminRouter({ auth, db: handle.db, keyring, sharedPool, version: '9.9.9', publicUrl })) + server = createServer(app) + await new Promise((r) => server.listen(0, '127.0.0.1', r)) + const addr = server.address() as AddressInfo + origin = `http://127.0.0.1:${addr.port}` +}) + +afterEach(async () => { + await new Promise((r) => server.close(() => r())) + await handle.close() +}) + +describe('admin panel over HTTP', () => { + it('serves vendored htmx before the gate, with a long cache + CSP', async () => { + session.value = null // anonymous — asset must still load + const res = await fetch(`${origin}/admin/assets/htmx.min.js`) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toContain('javascript') + expect(res.headers.get('cache-control')).toContain('immutable') + const body = await res.text() + expect(body).toContain('htmx') // real minified source, not a placeholder + expect(body.length).toBeGreaterThan(10_000) + }) + + it('pins a script-locked CSP (script-src self, no unsafe-inline)', async () => { + session.value = { user: { role: 'admin' }, session: {} } + const res = await fetch(`${origin}/admin`) + const csp = res.headers.get('content-security-policy') ?? '' + expect(csp).toContain("script-src 'self'") + // The script vector must NOT allow inline execution (stored-XSS containment). + expect(csp).not.toMatch(/script-src[^;]*unsafe-inline/) + }) + + it('redirects an anonymous browser to sign-in', async () => { + session.value = null + const res = await fetch(`${origin}/admin`, { redirect: 'manual' }) + expect(res.status).toBe(302) + expect(res.headers.get('location')).toContain('/sign-in') + }) + + it('403s an authenticated non-admin', async () => { + session.value = { user: { role: 'user' }, session: {} } + const res = await fetch(`${origin}/admin/bases`) + expect(res.status).toBe(403) + }) + + it('renders the dashboard for an admin, with the CSP header', async () => { + session.value = { user: { role: 'admin' }, session: {} } + const res = await fetch(`${origin}/admin`) + expect(res.status).toBe(200) + expect(res.headers.get('content-security-policy')).toContain("default-src 'none'") + const html = await res.text() + expect(html).toContain('Dashboard') + expect(html).toContain('DB-backed') + expect(html).toContain('/admin/assets/htmx.min.js') + expect(html).toContain('/admin/health/check') // on-demand "check connections now" button + }) + + it('Check connections now kicks a background sweep and shows per-base "checking" spinners immediately', async () => { + session.value = { user: { role: 'admin' }, session: {} } + // The response returns IMMEDIATELY (the sweep runs in the background), so every + // base shows a "checking" spinner and a hidden fast-poll row drives the follow-up. + const res = await fetch(`${origin}/admin/health/check`, { + method: 'POST', + headers: { origin: 'http://127.0.0.1' }, + }) + expect(res.status).toBe(200) + const html = await res.text() + expect(html).toContain('trade') // the base is listed… + expect(html).toContain('badge checking') // …with a spinner (being re-probed) + expect(html).toContain('class="hpoll"') // fast poll active while any base checks + }) + + it('the health table lists every base (even never-probed) with no spinner when idle', async () => { + session.value = { user: { role: 'admin' }, session: {} } + const html = await (await fetch(`${origin}/admin/health/table`)).text() + expect(html).toContain('trade') // appears even with no health row yet (unknown status) + expect(html).not.toContain('badge checking') // no sweep started → no spinner + expect(html).not.toContain('class="hpoll"') // …and no fast poll + }) + + it('the grants matrix shows each user full name above their email', async () => { + session.value = { user: { role: 'admin' }, session: {} } + listUsers.mockResolvedValueOnce({ users: [{ id: 'u1', email: 'jane@x', name: 'Jane Doe', role: 'user' }] }) + const html = await (await fetch(`${origin}/admin/grants`)).text() + expect(html).toContain('class="uident"') + expect(html).toContain('Jane Doe') // full name + expect(html).toContain('jane@x') // email underneath + }) + + it('renders the bases list from DB state for an admin', async () => { + session.value = { user: { role: 'admin' }, session: {} } + const res = await fetch(`${origin}/admin/bases`) + expect(res.status).toBe(200) + const html = await res.text() + expect(html).toContain('trade') + expect(html).toContain('http://1c/odata') + expect(html).toContain('badge') // health badge column + expect(html).toContain('rel="icon"') // inline SVG favicon in the head + expect(html).toContain('data:image/svg+xml') + }) + + it('the base form timezone is a searchable datalist combobox, with a drawer Cancel', async () => { + session.value = { user: { role: 'admin' }, session: {} } + const html = await (await fetch(`${origin}/admin/bases/new`)).text() + expect(html).toContain('') + expect(html).toContain('