diff --git a/.changeset/config.json b/.changeset/config.json index f3999fd..de7150c 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", "@1c-odata/mcp-server"]], + "fixed": [["@1c-odata/client", "@1c-odata/metadata", "@1c-odata/cli", "@1c-odata/mcp"]], "linked": [], "access": "public", "baseBranch": "master", diff --git a/.changeset/mcp-connection-source-seam.md b/.changeset/mcp-connection-source-seam.md index bcd6b67..e36da8f 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 `@1c-odata/mcp-server`). +Refactor `@1c-odata/mcp` for multi-tenant reuse (prep for a remote multi-tenant host). - 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 fd10ca1..8c8a04a 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 (`@1c-odata/mcp-server`). 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. 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 new file mode 100644 index 0000000..3656f0a --- /dev/null +++ b/.changeset/mcp-internal-reexports.md @@ -0,0 +1,12 @@ +--- +"@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 deleted file mode 100644 index 77c59fa..0000000 --- a/.changeset/mcp-server-admin-panel.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@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 deleted file mode 100644 index c3a0791..0000000 --- a/.changeset/mcp-server-auth-layer.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@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 deleted file mode 100644 index d74a4e4..0000000 --- a/.changeset/mcp-server-deploy-hardening.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@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 deleted file mode 100644 index d5246fd..0000000 --- a/.changeset/mcp-server-ghcr-image.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@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 deleted file mode 100644 index a3c4ff3..0000000 --- a/.changeset/mcp-server-http-transport.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@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 deleted file mode 100644 index a67389b..0000000 --- a/.changeset/mcp-server-in-process-jwks.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@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 deleted file mode 100644 index 8efd3d2..0000000 --- a/.changeset/mcp-server-jwks-read-timeout.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@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 deleted file mode 100644 index b2c753e..0000000 --- a/.changeset/mcp-server-multi-tenancy.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -'@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 1523d0a..33834ba 100644 --- a/.changeset/node-24-lts-floor.md +++ b/.changeset/node-24-lts-floor.md @@ -3,7 +3,6 @@ '@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). @@ -13,9 +12,8 @@ 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, 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 +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 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 deleted file mode 100644 index 890eaa6..0000000 --- a/.dockerignore +++ /dev/null @@ -1,25 +0,0 @@ -# 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 cb0ca27..c93125a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,20 +2,10 @@ # # 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, the pnpm workspace, and the -# mcp-server deploy tree's container images. +# enumerated below: workflows + composite actions, and the pnpm workspace. # -# 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 +# Cooldown rationale (repeated per ecosystem below): this repo PUBLISHES four +# npm packages, 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 @@ -104,105 +94,23 @@ updates: - dependency-name: '@types/node' update-types: [version-update:semver-major] groups: - # 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. + # 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. 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, Docker -# Hub, GHCR public image). +# - `registries`: nothing to declare — every source is public (npmjs). # - `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 f967202..07682cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,119 +120,6 @@ 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 }} @@ -253,22 +140,6 @@ 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 @@ -279,72 +150,6 @@ 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 deleted file mode 100644 index 893ff39..0000000 --- a/.github/workflows/deploy-compose.yml +++ /dev/null @@ -1,105 +0,0 @@ -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 deleted file mode 100644 index 3377a8a..0000000 --- a/.github/workflows/release-image.yml +++ /dev/null @@ -1,138 +0,0 @@ -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 1d6802a..bc5665e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,12 +21,6 @@ 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: @@ -38,14 +32,6 @@ 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. @@ -151,18 +137,3 @@ 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 691c798..4d44926 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. Five-package pnpm + turbo monorepo: +TypeScript library for the 1С:Enterprise REST/OData V3 interface. Four-package pnpm + turbo monorepo: | Package | Role | |---|---| @@ -12,7 +12,6 @@ TypeScript library for the 1С:Enterprise REST/OData V3 interface. Five-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. @@ -25,15 +24,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. `@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). +**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. **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) 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. +- `@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. -**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. +**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. ## Commands @@ -42,7 +41,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 + mcp-server e2e (real loopback server; MSW-stubbed 1С upstreams) +pnpm turbo test:e2e # CLI e2e (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 @@ -53,9 +52,7 @@ 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`, `@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. +**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. **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. @@ -67,8 +64,6 @@ 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`. @@ -83,5 +78,3 @@ 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 31daded..fe02ccf 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,8 @@ 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) and `@1c-odata/mcp-server` (remote HTTP / Claude custom connector) expose read-only schema introspection + queries against any base — see their package READMEs. +**AI agents:** `@1c-odata/mcp` (local, stdio) exposes read-only schema introspection + queries against any base — see its package README. 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 30a8f3e..a152481 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 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. +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. 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 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. +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. ## Error contract @@ -94,24 +94,7 @@ 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 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). +- 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). - 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 6130b46..6703e5b 100644 --- a/biome.json +++ b/biome.json @@ -71,14 +71,6 @@ "complexity": { "useLiteralKeys": "off" } } } - }, - { - "includes": ["**/deploy/ci/**"], - "linter": { - "rules": { - "suspicious": { "noConsole": "off" } - } - } } ] } diff --git a/packages/mcp-server/LICENSE b/packages/mcp-server/LICENSE deleted file mode 100644 index cac5665..0000000 --- a/packages/mcp-server/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index cd7c39e..0000000 --- a/packages/mcp-server/README.md +++ /dev/null @@ -1,207 +0,0 @@ -# @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 deleted file mode 100644 index b96d2f2..0000000 --- a/packages/mcp-server/auth-schema.ts +++ /dev/null @@ -1,277 +0,0 @@ -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 deleted file mode 100644 index 492cb70..0000000 --- a/packages/mcp-server/auth.config.ts +++ /dev/null @@ -1,29 +0,0 @@ -// 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 deleted file mode 100644 index da4e797..0000000 --- a/packages/mcp-server/deploy/.env.example +++ /dev/null @@ -1,57 +0,0 @@ -# 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 deleted file mode 100644 index 04b98a3..0000000 --- a/packages/mcp-server/deploy/Caddyfile +++ /dev/null @@ -1,17 +0,0 @@ -{$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 deleted file mode 100644 index 8fb3bab..0000000 --- a/packages/mcp-server/deploy/Dockerfile +++ /dev/null @@ -1,42 +0,0 @@ -# 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 deleted file mode 100644 index 724e36e..0000000 --- a/packages/mcp-server/deploy/README.md +++ /dev/null @@ -1,309 +0,0 @@ -# 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 deleted file mode 100644 index ce3efa2..0000000 --- a/packages/mcp-server/deploy/ci/Caddyfile.ci +++ /dev/null @@ -1,16 +0,0 @@ -# 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 deleted file mode 100755 index ca8a97f..0000000 --- a/packages/mcp-server/deploy/ci/compose-smoke.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/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 deleted file mode 100644 index ee56c36..0000000 --- a/packages/mcp-server/deploy/ci/compose.ci.yml +++ /dev/null @@ -1,29 +0,0 @@ -# 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 deleted file mode 100644 index 7ad6f80..0000000 --- a/packages/mcp-server/deploy/ci/mcp-flow.mjs +++ /dev/null @@ -1,223 +0,0 @@ -#!/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 deleted file mode 100755 index 6aa2818..0000000 --- a/packages/mcp-server/deploy/ci/package-smoke.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/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 deleted file mode 100755 index 14a29db..0000000 --- a/packages/mcp-server/deploy/ci/smoke.sh +++ /dev/null @@ -1,105 +0,0 @@ -#!/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 deleted file mode 100644 index beff318..0000000 --- a/packages/mcp-server/deploy/compose.prod.yml +++ /dev/null @@ -1,19 +0,0 @@ -# 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 deleted file mode 100644 index 8080424..0000000 --- a/packages/mcp-server/deploy/compose.yml +++ /dev/null @@ -1,94 +0,0 @@ -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 deleted file mode 100644 index 598ab8e..0000000 --- a/packages/mcp-server/drizzle.config.ts +++ /dev/null @@ -1,20 +0,0 @@ -// 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 deleted file mode 100644 index 94e7310..0000000 --- a/packages/mcp-server/drizzle/0000_goofy_rhodey.sql +++ /dev/null @@ -1,159 +0,0 @@ -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 deleted file mode 100644 index 81f2f2d..0000000 --- a/packages/mcp-server/drizzle/0001_right_captain_marvel.sql +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index 3b1d403..0000000 --- a/packages/mcp-server/drizzle/0002_huge_supreme_intelligence.sql +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 5a15da5..0000000 --- a/packages/mcp-server/drizzle/meta/0000_snapshot.json +++ /dev/null @@ -1,1103 +0,0 @@ -{ - "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 deleted file mode 100644 index a9a630f..0000000 --- a/packages/mcp-server/drizzle/meta/0001_snapshot.json +++ /dev/null @@ -1,1368 +0,0 @@ -{ - "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 deleted file mode 100644 index 52e1ee9..0000000 --- a/packages/mcp-server/drizzle/meta/0002_snapshot.json +++ /dev/null @@ -1,1400 +0,0 @@ -{ - "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 deleted file mode 100644 index 68d0fc5..0000000 --- a/packages/mcp-server/drizzle/meta/_journal.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "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 deleted file mode 100644 index 2c0dbfa..0000000 --- a/packages/mcp-server/package.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "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 deleted file mode 100644 index a18a0a6..0000000 --- a/packages/mcp-server/src/auth/better-auth.ts +++ /dev/null @@ -1,143 +0,0 @@ -// 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 deleted file mode 100644 index f6841b9..0000000 --- a/packages/mcp-server/src/auth/config.ts +++ /dev/null @@ -1,73 +0,0 @@ -// 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 deleted file mode 100644 index 9779c91..0000000 --- a/packages/mcp-server/src/auth/pages/consent.ts +++ /dev/null @@ -1,60 +0,0 @@ -// 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 deleted file mode 100644 index 8865fc4..0000000 --- a/packages/mcp-server/src/auth/pages/sign-in.ts +++ /dev/null @@ -1,122 +0,0 @@ -// 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 deleted file mode 100644 index fc6d2f0..0000000 --- a/packages/mcp-server/src/auth/resource-metadata.ts +++ /dev/null @@ -1,51 +0,0 @@ -// 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 deleted file mode 100644 index 30a92eb..0000000 --- a/packages/mcp-server/src/auth/verifier.ts +++ /dev/null @@ -1,267 +0,0 @@ -// 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 deleted file mode 100644 index 7f1f175..0000000 --- a/packages/mcp-server/src/cli.ts +++ /dev/null @@ -1,427 +0,0 @@ -#!/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 deleted file mode 100644 index 8ecefe0..0000000 --- a/packages/mcp-server/src/http/account/router.ts +++ /dev/null @@ -1,119 +0,0 @@ -// 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 deleted file mode 100644 index f4a3b62..0000000 --- a/packages/mcp-server/src/http/admin/admin-js-asset.ts +++ /dev/null @@ -1,170 +0,0 @@ -// 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 deleted file mode 100644 index 66a151f..0000000 --- a/packages/mcp-server/src/http/admin/bases.ts +++ /dev/null @@ -1,399 +0,0 @@ -// 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 deleted file mode 100644 index 34c95ec..0000000 --- a/packages/mcp-server/src/http/admin/dashboard.ts +++ /dev/null @@ -1,62 +0,0 @@ -// 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 deleted file mode 100644 index 176e29d..0000000 --- a/packages/mcp-server/src/http/admin/grants.ts +++ /dev/null @@ -1,87 +0,0 @@ -// 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 deleted file mode 100644 index 7b936b2..0000000 --- a/packages/mcp-server/src/http/admin/health-job.ts +++ /dev/null @@ -1,163 +0,0 @@ -// 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 deleted file mode 100644 index 0f583c0..0000000 --- a/packages/mcp-server/src/http/admin/htmx-asset.ts +++ /dev/null @@ -1,24 +0,0 @@ -// 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 deleted file mode 100644 index 6e857fb..0000000 --- a/packages/mcp-server/src/http/admin/middleware.ts +++ /dev/null @@ -1,188 +0,0 @@ -// 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 deleted file mode 100644 index af508b7..0000000 --- a/packages/mcp-server/src/version.ts +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index a722cec..0000000 --- a/packages/mcp-server/test/e2e/_harness.ts +++ /dev/null @@ -1,374 +0,0 @@ -// 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 deleted file mode 100644 index f22e135..0000000 --- a/packages/mcp-server/test/e2e/account.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -// 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 deleted file mode 100644 index dba9c98..0000000 --- a/packages/mcp-server/test/e2e/admin-create.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -// 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 deleted file mode 100644 index 4c91fa2..0000000 --- a/packages/mcp-server/test/e2e/admin-panel.test.ts +++ /dev/null @@ -1,515 +0,0 @@ -// 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('