From 15955df3126eff1b58c213563b7a35b9cf8d8998 Mon Sep 17 00:00:00 2001 From: Stefan Ernst Date: Wed, 22 Jul 2026 23:34:00 +0200 Subject: [PATCH 1/3] feat: add Windshift OAuth and MCP connector --- .env.example | 12 +- .github/workflows/ci.yml | 17 + connectors/windshift/Dockerfile | 17 + connectors/windshift/README.md | 45 + connectors/windshift/package-lock.json | 1699 +++++++++++++++++ connectors/windshift/package.json | 27 + connectors/windshift/src/client.test.ts | 122 ++ connectors/windshift/src/client.ts | 215 +++ connectors/windshift/src/connector.test.ts | 160 ++ connectors/windshift/src/connector.ts | 318 +++ connectors/windshift/src/main.ts | 4 + connectors/windshift/src/mappers.test.ts | 62 + connectors/windshift/src/mappers.ts | 130 ++ connectors/windshift/src/types.ts | 99 + connectors/windshift/tsconfig.json | 19 + docker/docker-compose.dev.yml | 6 + docker/docker-compose.yml | 27 + infra/aws/terraform/main.tf | 1 + .../aws/terraform/modules/compute/outputs.tf | 5 + .../modules/compute/service_discovery.tf | 19 + .../aws/terraform/modules/compute/services.tf | 27 + .../modules/compute/task_definitions.tf | 45 + .../terraform/modules/compute/variables.tf | 6 + infra/aws/terraform/variables.tf | 6 + infra/gcp/terraform/main.tf | 1 + .../gcp/terraform/modules/compute/services.tf | 2 + .../terraform/modules/compute/variables.tf | 6 + infra/gcp/terraform/variables.tf | 6 + .../106_add_windshift_source_type.sql | 52 + .../107_make_windshift_sources_personal.sql | 61 + shared/src/models.rs | 4 + .../windshift-connector-setup.svelte | 58 + web/src/lib/images/icons/windshift.png | Bin 0 -> 45622 bytes .../lib/server/oauth/connectorOAuth.test.ts | 43 +- web/src/lib/server/oauth/connectorOAuth.ts | 62 +- web/src/lib/types.ts | 12 + web/src/lib/utils/icons.ts | 3 + .../settings/integrations/+page.server.ts | 24 +- .../admin/settings/integrations/+page.svelte | 6 +- .../settings/integrations/+page.server.ts | 15 +- .../(app)/settings/integrations/+page.svelte | 124 +- 41 files changed, 3507 insertions(+), 60 deletions(-) create mode 100644 connectors/windshift/Dockerfile create mode 100644 connectors/windshift/README.md create mode 100644 connectors/windshift/package-lock.json create mode 100644 connectors/windshift/package.json create mode 100644 connectors/windshift/src/client.test.ts create mode 100644 connectors/windshift/src/client.ts create mode 100644 connectors/windshift/src/connector.test.ts create mode 100644 connectors/windshift/src/connector.ts create mode 100644 connectors/windshift/src/main.ts create mode 100644 connectors/windshift/src/mappers.test.ts create mode 100644 connectors/windshift/src/mappers.ts create mode 100644 connectors/windshift/src/types.ts create mode 100644 connectors/windshift/tsconfig.json create mode 100644 services/migrations/106_add_windshift_source_type.sql create mode 100644 services/migrations/107_make_windshift_sources_personal.sql create mode 100644 web/src/lib/components/windshift-connector-setup.svelte create mode 100644 web/src/lib/images/icons/windshift.png diff --git a/.env.example b/.env.example index 6b18d943b..fdd7353e8 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,16 @@ NEXTCLOUD_CONNECTOR_PORT=4014 PAPERLESS_CONNECTOR_PORT=4015 GOOGLE_ADS_CONNECTOR_PORT=4016 DARWINBOX_CONNECTOR_PORT=4017 +WINDSHIFT_CONNECTOR_PORT=4018 + +# Public URL of the Windshift instance this Omni install talks to. +# Reachable from the user's browser for OAuth consent. One Omni install = one +# Windshift instance in v1. Set WINDSHIFT_INTERNAL_BASE_URL below when the +# connector container needs a different route to that instance. +WINDSHIFT_BASE_URL=https://windshift.example.com +# Optional connector-only URL for local/private networking. OAuth URLs and +# token audience still use WINDSHIFT_BASE_URL. +WINDSHIFT_INTERNAL_BASE_URL= # Sandbox Port SANDBOX_PORT=8090 @@ -92,7 +102,7 @@ DOCLING_MEMORY=2g # # Enable connectors you want to run by adding their profile to ENABLED_CONNECTORS (comma-separated). # Available connector names: -# google, google_ads, slack, atlassian, web, github, notion, hubspot, fireflies, microsoft, filesystem, imap, linear, clickup, nextcloud, paperless, darwinbox +# google, google_ads, slack, atlassian, web, github, notion, hubspot, fireflies, microsoft, filesystem, imap, linear, clickup, nextcloud, paperless, darwinbox, windshift # # Example: ENABLED_CONNECTORS=google,slack # diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb82a6af9..590e370d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,7 @@ jobs: linear-connector: ${{ steps.filter.outputs.linear-connector }} nextcloud-connector: ${{ steps.filter.outputs.nextcloud-connector }} paperless-connector: ${{ steps.filter.outputs.paperless-connector }} + windshift-connector: ${{ steps.filter.outputs.windshift-connector }} docling: ${{ steps.filter.outputs.docling }} deployment: ${{ steps.filter.outputs.deployment }} cli: ${{ steps.filter.outputs.cli }} @@ -220,6 +221,11 @@ jobs: - 'sdk/python/**' - '.github/workflows/ci.yml' - '.github/workflows/build-connector.yml' + windshift-connector: + - 'connectors/windshift/**' + - 'sdk/typescript/**' + - '.github/workflows/ci.yml' + - '.github/workflows/build-connector.yml' deployment: - 'docker/docker-compose.yml' - 'docker/docker-compose.gpu.yml' @@ -533,6 +539,15 @@ jobs: connector-type: typescript secrets: inherit + build-windshift-connector: + needs: detect-changes + if: needs.detect-changes.outputs.is-tag != 'true' && needs.detect-changes.outputs.windshift-connector == 'true' + uses: ./.github/workflows/build-connector.yml + with: + connector-name: windshift + connector-type: typescript + secrets: inherit + # --------------------------------------------------------------------------- # Release: build all images from scratch on tag push # --------------------------------------------------------------------------- @@ -633,6 +648,8 @@ jobs: connector-type: python - connector-name: linear connector-type: typescript + - connector-name: windshift + connector-type: typescript uses: ./.github/workflows/build-connector.yml with: connector-name: ${{ matrix.connector-name }} diff --git a/connectors/windshift/Dockerfile b/connectors/windshift/Dockerfile new file mode 100644 index 000000000..c17e6c31c --- /dev/null +++ b/connectors/windshift/Dockerfile @@ -0,0 +1,17 @@ +FROM node:24-slim AS builder +WORKDIR /build + +COPY sdk/typescript/ /sdk/typescript/ +RUN cd /sdk/typescript && npm ci && npm run build + +COPY connectors/windshift/package.json connectors/windshift/package-lock.json connectors/windshift/tsconfig.json /build/ +RUN npm ci --install-links +COPY connectors/windshift/src/ /build/src/ +RUN npm run build + +FROM node:24-slim +WORKDIR /app +COPY --from=builder /build/dist /app/dist +COPY --from=builder /build/node_modules /app/node_modules +ENV NODE_ENV=production +CMD ["node", "dist/main.js"] diff --git a/connectors/windshift/README.md b/connectors/windshift/README.md new file mode 100644 index 000000000..724d2422a --- /dev/null +++ b/connectors/windshift/README.md @@ -0,0 +1,45 @@ +# Windshift Connector + +Indexes Windshift work items, descriptions, and comments into Omni and exposes +Windshift's MCP tools as Omni actions. + +## Configuration + +Set `WINDSHIFT_BASE_URL` to the externally reachable Windshift base URL. If +Windshift uses a context path, include it in the URL. Enable Windshift's MCP +server with `MCP_ENABLED=true`. + +For local or private networking, `WINDSHIFT_INTERNAL_BASE_URL` may point the +connector container at the same Windshift instance through a different route. +Browser authorization, resource binding, and document links still use +`WINDSHIFT_BASE_URL`; server-side client registration, token exchange, user-info, +sync, and MCP requests use the internal route. + +No OAuth client ID or secret is configured manually. Omni dynamically registers +as a public client, uses S256 PKCE, and requests tokens bound to +`${WINDSHIFT_BASE_URL}/mcp`. Windshift 0.8.3 or newer is required. + +Each user connects Windshift from **My Integrations**. The initial authorization +grants read access for that user's sync and read-only MCP tools. Write and +destructive tools request expanded authorization when first used. Access tokens +are refreshed automatically; rotated refresh tokens are persisted under the same +per-credential database lock. + +## Data model + +| Windshift | Omni document | +| -------------------------- | ------------------------------------------ | +| Item ID | `external_id = windshift:item:` | +| Title | Document title | +| Description and comments | Markdown content | +| Workspace | `attributes.workspace` | +| Status | `attributes.status` | +| Priority | `attributes.priority` | +| Assignee | `attributes.assignee` and `assignee_email` | +| Created/updated timestamps | Document metadata | + +Full sync walks visible workspaces and items. Incremental sync stops once items +are older than the last checkpoint. Optional `workspace_keys` restricts sync to +specific Windshift workspaces. Windshift is a personal source in Omni: every +user has an independent sync backed by their own OAuth credential, and indexed +items are visible only to that Omni user. diff --git a/connectors/windshift/package-lock.json b/connectors/windshift/package-lock.json new file mode 100644 index 000000000..dc4e0ab41 --- /dev/null +++ b/connectors/windshift/package-lock.json @@ -0,0 +1,1699 @@ +{ + "name": "@getomnico/windshift-connector", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@getomnico/windshift-connector", + "version": "1.0.0", + "license": "Apache-2.0", + "dependencies": { + "@getomnico/connector": "file:../../sdk/typescript", + "@modelcontextprotocol/sdk": "^1.29.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + }, + "engines": { + "node": ">=24.0.0" + } + }, + "../../sdk/typescript": { + "name": "@getomnico/connector", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "express": "^4.21.0", + "pino": "^10.3.1", + "zod": "^3.23.0" + }, + "devDependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "@types/express": "^5.0.0", + "@types/node": "^22.19.15", + "@types/supertest": "^6.0.0", + "msw": "^2.6.0", + "pino-pretty": "^13.1.3", + "supertest": "^7.0.0", + "typescript": "^5.6.0", + "vitest": "^3.2.7" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.29.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@getomnico/connector": { + "resolved": "../../sdk/typescript", + "link": true + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT", + "peer": true + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT", + "peer": true + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", + "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-rate-limit/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/express-rate-limit/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.31", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "peer": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "peer": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT", + "peer": true + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT", + "peer": true + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/router/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "peer": true + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "peer": true, + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "peer": true, + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/connectors/windshift/package.json b/connectors/windshift/package.json new file mode 100644 index 000000000..5fe633e6c --- /dev/null +++ b/connectors/windshift/package.json @@ -0,0 +1,27 @@ +{ + "name": "@getomnico/windshift-connector", + "version": "1.0.0", + "description": "Windshift connector for Omni", + "type": "module", + "main": "./dist/main.js", + "scripts": { + "build": "tsc", + "test": "npm run build && NODE_ENV=production node --test dist/*.test.js", + "start": "node dist/main.js", + "dev": "tsc --watch", + "lint": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "engines": { + "node": ">=24.0.0" + }, + "dependencies": { + "@getomnico/connector": "file:../../sdk/typescript", + "@modelcontextprotocol/sdk": "^1.29.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + }, + "license": "Apache-2.0" +} diff --git a/connectors/windshift/src/client.test.ts b/connectors/windshift/src/client.test.ts new file mode 100644 index 000000000..657c76130 --- /dev/null +++ b/connectors/windshift/src/client.test.ts @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { WindshiftApiClient } from "./client.js"; + +test("item pagination uses REST v1 and normalizes nested fields", async () => { + const originalFetch = globalThis.fetch; + const requestedPages: string[] = []; + const requestedPaths: string[] = []; + + globalThis.fetch = async (input, init) => { + const url = new URL(String(input)); + const page = url.searchParams.get("page") ?? "1"; + requestedPages.push(page); + requestedPaths.push(url.pathname); + assert.equal( + init?.headers && (init.headers as Record).Authorization, + "Bearer token", + ); + assert.equal(url.searchParams.get("sort"), "updated_at"); + assert.equal(url.searchParams.get("order"), "desc"); + assert.equal(url.searchParams.get("workspace_id"), "1"); + return new Response( + JSON.stringify({ + data: [ + { + id: Number(page), + workspace_id: 1, + workspace_key: "ENG", + workspace_item_number: Number(page), + title: `Item ${page}`, + status: { id: 2, name: "In Progress" }, + workspace: { id: 1, key: "ENG", name: "Engineering" }, + milestones: [{ id: 3, name: "0.8.3" }], + created_at: "2026-07-21T12:00:00Z", + updated_at: "2026-07-21T12:00:00Z", + }, + ], + pagination: { + page: Number(page), + limit: 100, + total: 2, + total_pages: 2, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }; + + try { + const client = new WindshiftApiClient("https://windshift.example", "token"); + const itemIds: number[] = []; + for await (const item of client.fetchItems(1)) { + itemIds.push(item.id); + } + + assert.deepEqual(itemIds, [1, 2]); + assert.deepEqual(requestedPages, ["1", "2"]); + assert.deepEqual(requestedPaths, [ + "/rest/api/v1/items", + "/rest/api/v1/items", + ]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("workspace pagination and comments use REST v1 response shapes", async () => { + const originalFetch = globalThis.fetch; + const paths: string[] = []; + + globalThis.fetch = async (input) => { + const url = new URL(String(input)); + paths.push(url.pathname); + if (url.pathname.endsWith("/workspaces")) { + return Response.json({ + data: [{ id: 1, key: "ENG", name: "Engineering" }], + pagination: { + page: 1, + limit: 100, + total: 1, + total_pages: 1, + has_more: false, + }, + }); + } + return Response.json([ + { + id: 9, + item_id: 7, + content: "OAuth now works", + author: { id: 2, full_name: "Ada Lovelace" }, + created_at: "2026-07-21T12:00:00Z", + updated_at: "2026-07-21T12:00:00Z", + }, + ]); + }; + + try { + const client = new WindshiftApiClient("https://windshift.example/", "token"); + assert.deepEqual(await client.fetchWorkspaces(), [ + { id: 1, key: "ENG", name: "Engineering" }, + ]); + assert.deepEqual(await client.fetchItemComments(7), [ + { + id: 9, + item_id: 7, + user_id: 2, + user_name: "Ada Lovelace", + body: "OAuth now works", + created_at: "2026-07-21T12:00:00Z", + updated_at: "2026-07-21T12:00:00Z", + }, + ]); + assert.deepEqual(paths, [ + "/rest/api/v1/workspaces", + "/rest/api/v1/items/7/comments", + ]); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/connectors/windshift/src/client.ts b/connectors/windshift/src/client.ts new file mode 100644 index 000000000..919f149aa --- /dev/null +++ b/connectors/windshift/src/client.ts @@ -0,0 +1,215 @@ +import type { + WindshiftItem, + WindshiftPaginatedResponse, + WindshiftWorkspace, + WindshiftComment, +} from "./types.js"; + +const PAGE_SIZE = 100; +const MAX_COMMENTS_PER_ITEM = 50; + +function joinUrl(baseUrl: string, path: string): string { + const trimmed = baseUrl.replace(/\/+$/, ""); + return `${trimmed}/rest/api/v1${path}`; +} + +type WindshiftUserResponse = { + id: number; + email?: string; + username?: string; + full_name?: string; +}; + +type WindshiftItemResponse = { + id: number; + workspace_id: number; + workspace_key?: string; + key?: string; + workspace_item_number?: number; + title: string; + description?: string | null; + status?: { id: number; name: string } | null; + priority?: { id: number; name: string } | null; + assignee?: WindshiftUserResponse | null; + creator?: WindshiftUserResponse | null; + workspace?: { id: number; name: string; key: string } | null; + milestones?: Array<{ id: number; name: string }>; + iteration?: { id: number; name: string } | null; + created_at: string; + updated_at: string; + completed_at?: string | null; +}; + +type WindshiftCommentResponse = { + id: number; + item_id: number; + content: string; + author?: WindshiftUserResponse | null; + created_at: string; + updated_at: string; +}; + +function userName(user: WindshiftUserResponse | null | undefined): string | null { + return user?.full_name || user?.username || user?.email || null; +} + +function mapItem(item: WindshiftItemResponse): WindshiftItem { + return { + id: item.id, + workspace_id: item.workspace_id, + workspace_name: item.workspace?.name ?? null, + workspace_key: item.workspace_key ?? item.workspace?.key ?? null, + workspace_item_number: item.workspace_item_number ?? null, + title: item.title, + description: item.description ?? null, + status_id: item.status?.id ?? null, + status_name: item.status?.name ?? null, + priority_id: item.priority?.id ?? null, + priority_name: item.priority?.name ?? null, + assignee_id: item.assignee?.id ?? null, + assignee_name: userName(item.assignee), + assignee_email: item.assignee?.email ?? null, + creator_id: item.creator?.id ?? null, + creator_name: userName(item.creator), + milestones: item.milestones ?? [], + iteration: item.iteration ?? null, + created_at: item.created_at, + updated_at: item.updated_at, + completed_at: item.completed_at ?? null, + }; +} + +function mapComment(comment: WindshiftCommentResponse): WindshiftComment { + return { + id: comment.id, + item_id: comment.item_id, + user_id: comment.author?.id ?? null, + user_name: userName(comment.author), + body: comment.content, + created_at: comment.created_at, + updated_at: comment.updated_at, + }; +} + +export class WindshiftApiClient { + constructor( + private readonly baseUrl: string, + private readonly apiToken: string, + ) {} + + private async request(path: string): Promise { + const res = await fetch(joinUrl(this.baseUrl, path), { + headers: { + Authorization: `Bearer ${this.apiToken}`, + Accept: "application/json", + }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error( + `Windshift API ${res.status} for ${path}: ${body.slice(0, 200)}`, + ); + } + return (await res.json()) as T; + } + + async fetchWorkspaces(): Promise { + const workspaces: WindshiftWorkspace[] = []; + let page = 1; + while (true) { + const params = new URLSearchParams({ + page: String(page), + limit: String(PAGE_SIZE), + }); + const response = await this.request< + WindshiftPaginatedResponse + >(`/workspaces?${params}`); + workspaces.push(...response.data); + if (!response.pagination.has_more) return workspaces; + page = response.pagination.page + 1; + } + } + + async *fetchItems(workspaceId?: number): AsyncGenerator { + let page = 1; + while (true) { + const params = new URLSearchParams({ + page: String(page), + limit: String(PAGE_SIZE), + sort: "updated_at", + order: "desc", + }); + if (workspaceId !== undefined) { + params.set("workspace_id", String(workspaceId)); + } + const res = await this.request< + WindshiftPaginatedResponse + >( + `/items?${params}`, + ); + for (const item of res.data) { + yield mapItem(item); + } + if ( + res.data.length === 0 || + res.pagination.page >= res.pagination.total_pages + ) { + return; + } + page = res.pagination.page + 1; + } + } + + async fetchItemComments(itemId: number): Promise { + const data = await this.request( + `/items/${itemId}/comments`, + ); + return data.slice(0, MAX_COMMENTS_PER_ITEM).map(mapComment); + } + + async getItem(itemId: number): Promise { + const item = await this.request(`/items/${itemId}`); + return mapItem(item); + } + + async transitionItem(itemId: number, toStatusId: number): Promise { + return this.write("POST", `/items/${itemId}/transition`, { + to_status_id: toStatusId, + }); + } + + async updateItem( + itemId: number, + fields: Record, + ): Promise { + return this.write("PUT", `/items/${itemId}`, fields); + } + + async createItem(fields: Record): Promise { + return this.write("POST", `/items`, fields); + } + + private async write( + method: "POST" | "PUT", + path: string, + body: Record, + ): Promise { + const res = await fetch(joinUrl(this.baseUrl, path), { + method, + headers: { + Authorization: `Bearer ${this.apiToken}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error( + `Windshift API ${res.status} for ${method} ${path}: ${text.slice(0, 300)}`, + ); + } + if (res.status === 204) return null; + return res.json(); + } +} diff --git a/connectors/windshift/src/connector.test.ts b/connectors/windshift/src/connector.test.ts new file mode 100644 index 000000000..aeab9963a --- /dev/null +++ b/connectors/windshift/src/connector.test.ts @@ -0,0 +1,160 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { SyncMode, type SyncContext } from "@getomnico/connector"; +import { WindshiftConnector } from "./connector.js"; + +test("uses the public issuer for browser OAuth and internal server routes", () => { + const previousPublicUrl = process.env.WINDSHIFT_BASE_URL; + const previousInternalUrl = process.env.WINDSHIFT_INTERNAL_BASE_URL; + process.env.WINDSHIFT_BASE_URL = "http://localhost:8080/"; + process.env.WINDSHIFT_INTERNAL_BASE_URL = "http://host.docker.internal:8080/"; + + try { + const connector = new WindshiftConnector(); + assert.deepEqual(connector.mcpServer, { + transport: "http", + url: "http://host.docker.internal:8080/mcp", + }); + assert.equal( + connector.oauthConfig?.auth_endpoint, + "http://localhost:8080/oauth/authorize", + ); + assert.equal( + connector.oauthConfig?.registration_endpoint, + "http://host.docker.internal:8080/api/oauth/register", + ); + assert.equal( + connector.oauthConfig?.token_endpoint, + "http://host.docker.internal:8080/api/oauth/token", + ); + assert.equal( + connector.oauthConfig?.userinfo_endpoint, + "http://host.docker.internal:8080/api/oauth/userinfo", + ); + assert.equal(connector.oauthConfig?.resource, "http://localhost:8080/mcp"); + } finally { + if (previousPublicUrl === undefined) delete process.env.WINDSHIFT_BASE_URL; + else process.env.WINDSHIFT_BASE_URL = previousPublicUrl; + if (previousInternalUrl === undefined) + delete process.env.WINDSHIFT_INTERNAL_BASE_URL; + else process.env.WINDSHIFT_INTERNAL_BASE_URL = previousInternalUrl; + } +}); + +test("builds MCP authorization from sync and action credential shapes", () => { + const connector = new WindshiftConnector(); + + assert.deepEqual(connector.prepareMcpHeaders({ access_token: "sync-token" }), { + Authorization: "Bearer sync-token", + }); + assert.deepEqual( + connector.prepareMcpHeaders({ + credentials: { access_token: "action-token" }, + }), + { Authorization: "Bearer action-token" }, + ); + assert.deepEqual(connector.prepareMcpHeaders({}), {}); +}); + +test("full sync ignores an existing incremental checkpoint", async () => { + const previousPublicUrl = process.env.WINDSHIFT_BASE_URL; + const previousInternalUrl = process.env.WINDSHIFT_INTERNAL_BASE_URL; + const previousFetch = globalThis.fetch; + process.env.WINDSHIFT_BASE_URL = "http://localhost:5111"; + process.env.WINDSHIFT_INTERNAL_BASE_URL = "http://windshift:8080"; + + let emitted = 0; + let updated = 0; + let scanned = 0; + let completed = false; + let emittedPermissions: unknown; + + globalThis.fetch = async (input) => { + const url = String(input); + if (url.includes("/workspaces?")) { + return Response.json({ + data: [{ id: 1, key: "W1", name: "Workspace 1" }], + pagination: { page: 1, total_pages: 1, has_more: false }, + }); + } + if (url.includes("/items?")) { + return Response.json({ + data: [ + { + id: 2, + workspace_id: 1, + workspace_key: "W1", + workspace_item_number: 2, + title: "Milestone item", + milestones: [{ id: 1, name: "0.8.3" }], + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-02T00:00:00.000Z", + }, + ], + pagination: { page: 1, total_pages: 1, has_more: false }, + }); + } + if (url.includes("/items/2/comments")) return Response.json([]); + throw new Error(`Unexpected request: ${url}`); + }; + + const ctx = { + syncMode: SyncMode.FULL, + isCancelled: () => false, + incrementScanned: async () => { + scanned++; + }, + contentStorage: { save: async () => "content-1" }, + emit: async (document: { permissions?: unknown }) => { + emitted++; + emittedPermissions = document.permissions; + }, + emitUpdated: async () => { + updated++; + }, + saveState: async () => {}, + complete: async () => { + completed = true; + }, + fail: async (message: string) => { + throw new Error(message); + }, + emitError: () => {}, + getUserEmailForSource: async () => "owner@example.com", + get documentsScanned() { + return scanned; + }, + get documentsEmitted() { + return emitted + updated; + }, + } as unknown as SyncContext; + + try { + const connector = new WindshiftConnector(); + connector.bootstrapMcp = async () => {}; + await connector.sync( + {}, + { access_token: "token" }, + { last_sync_at: "2026-07-01T00:00:00.000Z" }, + ctx, + ); + + assert.equal(scanned, 1); + assert.equal(emitted, 1); + assert.equal(updated, 0); + assert.equal(completed, true); + assert.deepEqual(emittedPermissions, { + public: false, + users: ["owner@example.com"], + groups: [], + }); + } finally { + globalThis.fetch = previousFetch; + if (previousPublicUrl === undefined) delete process.env.WINDSHIFT_BASE_URL; + else process.env.WINDSHIFT_BASE_URL = previousPublicUrl; + if (previousInternalUrl === undefined) + delete process.env.WINDSHIFT_INTERNAL_BASE_URL; + else process.env.WINDSHIFT_INTERNAL_BASE_URL = previousInternalUrl; + } +}); diff --git a/connectors/windshift/src/connector.ts b/connectors/windshift/src/connector.ts new file mode 100644 index 000000000..510157733 --- /dev/null +++ b/connectors/windshift/src/connector.ts @@ -0,0 +1,318 @@ +import { + Connector, + SyncMode, + type SyncContext, + type SearchOperator, + type ActionDefinition, + getLogger, +} from "@getomnico/connector"; +import type { McpServer } from "@getomnico/connector"; +import { WindshiftApiClient } from "./client.js"; +import { generateItemContent, mapItemToDocument } from "./mappers.js"; +import type { + WindshiftCredentials, + WindshiftSourceConfig, + WindshiftSyncState, +} from "./types.js"; + +const logger = getLogger("windshift"); +const CHECKPOINT_INTERVAL = 100; + +const READ_SCOPES = [ + "mcp:access", + "items:read", + // Item updates and comments are core Windshift actions. Omni still requires + // explicit approval for every write tool invocation. + "items:write", + "workspaces:read", + "custom-fields:read", + "users:read", + "milestones:read", + "iterations:read", + "actions:read", + "pages:read", + "tests:read", + "time:read", +]; + +const WRITE_SCOPES = [ + ...READ_SCOPES, + "items:delete", + "actions:write", + "pages:write", + "pages:delete", + "tests:write", + "time:write", +]; + +function normalizedEnvUrl(name: string): string | undefined { + const url = process.env[name]; + if (!url) return undefined; + return url.replace(/\/+$/, ""); +} + +function windshiftPublicBaseUrl(): string | undefined { + return normalizedEnvUrl("WINDSHIFT_BASE_URL"); +} + +function windshiftTransportBaseUrl(): string | undefined { + return ( + normalizedEnvUrl("WINDSHIFT_INTERNAL_BASE_URL") ?? windshiftPublicBaseUrl() + ); +} + +function windshiftAccessToken( + credentials: WindshiftCredentials, +): string | undefined { + return credentials?.access_token ?? credentials?.credentials?.access_token; +} + +export class WindshiftConnector extends Connector< + WindshiftSourceConfig, + WindshiftCredentials, + WindshiftSyncState +> { + readonly name = "windshift"; + readonly version = "1.0.0"; + readonly sourceTypes = ["windshift"]; + + get description(): string { + return "Connect to Windshift items across your workspaces"; + } + + get displayName(): string { + return "Windshift"; + } + + readonly syncModes = ["full", "incremental"]; + + // No static actions — the action surface comes from Windshift's `/mcp` + // server via the HTTP MCP transport below. The Omni connector-manager + // discovers the tools after first sync (per the SDK's bootstrapMcp flow) + // and surfaces them as connector actions automatically. + readonly actions: ActionDefinition[] = []; + + readonly searchOperators: SearchOperator[] = [ + { operator: "status", attribute_key: "status", value_type: "text" }, + { operator: "priority", attribute_key: "priority", value_type: "text" }, + { operator: "assignee", attribute_key: "assignee", value_type: "person" }, + { operator: "workspace", attribute_key: "workspace", value_type: "text" }, + { operator: "milestone", attribute_key: "milestone", value_type: "text" }, + { operator: "iteration", attribute_key: "iteration", value_type: "text" }, + ]; + + readonly attributesSchema = { + type: "object", + properties: { + status: { type: "string" }, + priority: { type: "string" }, + assignee: { type: "string" }, + assignee_email: { type: "string", format: "email" }, + workspace: { type: "string" }, + identifier: { type: "string" }, + milestone: { type: "string" }, + iteration: { type: "string" }, + }, + }; + + readonly extraSchema = { + type: "object", + properties: { + workspace_keys: { + type: "array", + items: { type: "string" }, + description: "Restrict sync to these workspace keys (omit for all)", + }, + }, + }; + + // Wrap Windshift's existing /mcp server (Streamable HTTP, bearer auth) + // so every Windshift MCP tool — list_items, transition_item, add_comment, + // etc. — becomes an Omni connector action without per-tool wiring here. + // Returns undefined when WINDSHIFT_BASE_URL isn't set; the SDK then + // skips MCP discovery and the connector falls back to read-only sync. + get mcpServer(): McpServer | undefined { + const url = windshiftTransportBaseUrl(); + if (!url) return undefined; + return { transport: "http", url: `${url}/mcp` }; + } + + // Bridges OAuth credentials to the Authorization header the remote MCP + // server expects. Omni's web layer wrote the token after the user + // completed the per-user OAuth flow. Sync dispatches the token directly; + // action dispatch wraps it in Omni's ServiceCredential envelope. + prepareMcpHeaders(credentials: WindshiftCredentials): Record { + const accessToken = windshiftAccessToken(credentials); + if (!accessToken) return {}; + return { + Authorization: `Bearer ${accessToken}`, + }; + } + + // Windshift 0.8.3 exposes a public-client DCR endpoint. Omni registers + // itself automatically, uses S256 PKCE, and binds every issued token to + // this exact MCP resource. No administrator-managed client secret is needed. + override get oauthConfig() { + const publicBaseUrl = windshiftPublicBaseUrl(); + const transportBaseUrl = windshiftTransportBaseUrl(); + if (!publicBaseUrl || !transportBaseUrl) return undefined; + return { + provider: "windshift", + // The browser must use the public issuer. Registration, token exchange, + // and user-info requests are server-to-server and may need the private + // route when Omni and Windshift run in separate containers. + auth_endpoint: `${publicBaseUrl}/oauth/authorize`, + token_endpoint: `${transportBaseUrl}/api/oauth/token`, + registration_endpoint: `${transportBaseUrl}/api/oauth/register`, + userinfo_endpoint: `${transportBaseUrl}/api/oauth/userinfo`, + userinfo_email_field: "email", + identity_scopes: [], + scopes: { + windshift: { + read: READ_SCOPES, + write: WRITE_SCOPES, + }, + }, + extra_auth_params: { resource: `${publicBaseUrl}/mcp` }, + scope_separator: " ", + token_endpoint_auth_method: "none" as const, + resource: `${publicBaseUrl}/mcp`, + }; + } + + async sync( + config: WindshiftSourceConfig, + credentials: WindshiftCredentials, + state: WindshiftSyncState | null, + ctx: SyncContext, + ): Promise { + const publicBaseUrl = windshiftPublicBaseUrl(); + const transportBaseUrl = windshiftTransportBaseUrl(); + if (!publicBaseUrl || !transportBaseUrl) { + await ctx.fail( + "Connector container is missing WINDSHIFT_BASE_URL env var", + ); + return; + } + const accessToken = windshiftAccessToken(credentials); + if (!accessToken) { + await ctx.fail("Missing 'access_token' in credentials"); + return; + } + + let sourceOwnerEmail: string; + try { + sourceOwnerEmail = await ctx.getUserEmailForSource(); + } catch (e) { + logger.error({ err: e }, "Source owner lookup failed"); + await ctx.fail(`Failed to resolve source owner: ${e}`); + return; + } + + // The SDK exposes MCP discovery but does not invoke it automatically. + // Bootstrap once credentials are available; failures are logged by the + // SDK and do not block document sync. + await this.bootstrapMcp(credentials); + + const client = new WindshiftApiClient( + transportBaseUrl, + accessToken, + ); + + const isIncremental = ctx.syncMode === SyncMode.INCREMENTAL; + const lastSyncAt = isIncremental ? state?.last_sync_at : undefined; + const cutoff = lastSyncAt ? new Date(lastSyncAt).getTime() : null; + let docsSinceCheckpoint = 0; + + let allWorkspaces; + try { + allWorkspaces = await client.fetchWorkspaces(); + logger.info( + `Starting Windshift sync (${allWorkspaces.length} workspaces visible)`, + ); + } catch (e) { + logger.error({ err: e }, "Authentication / workspace fetch failed"); + await ctx.fail(`Authentication failed: ${e}`); + return; + } + + try { + const workspaceFilter = config.workspace_keys; + const workspaces = workspaceFilter + ? allWorkspaces.filter((w: { key: string }) => + workspaceFilter.includes(w.key), + ) + : allWorkspaces; + + for (const workspace of workspaces) { + if (ctx.isCancelled()) { + await ctx.fail("Cancelled by user"); + return; + } + logger.info( + `Syncing items for workspace: ${workspace.name} (${workspace.key})`, + ); + + let stoppedEarly = false; + for await (const item of client.fetchItems(workspace.id)) { + if (ctx.isCancelled()) { + await ctx.fail("Cancelled by user"); + return; + } + + // Server has no updated_since filter — sort=updated_at&order=desc lets us stop when + // we cross the cutoff. See plan: a Windshift-side updated_since param is the long-term fix. + if (cutoff !== null && new Date(item.updated_at).getTime() < cutoff) { + stoppedEarly = true; + break; + } + + await ctx.incrementScanned(); + try { + const comments = await client.fetchItemComments(item.id); + const content = generateItemContent(item, comments); + const contentId = await ctx.contentStorage.save( + content, + "text/markdown", + ); + const doc = mapItemToDocument( + item, + comments, + contentId, + publicBaseUrl, + sourceOwnerEmail, + ); + if (isIncremental) { + await ctx.emitUpdated(doc); + } else { + await ctx.emit(doc); + } + docsSinceCheckpoint++; + if (docsSinceCheckpoint >= CHECKPOINT_INTERVAL) { + await ctx.saveState({ last_sync_at: new Date().toISOString() }); + docsSinceCheckpoint = 0; + } + } catch (e) { + const eid = `windshift:item:${item.id}`; + logger.warn(`Error processing ${eid}: ${e}`); + ctx.emitError(eid, String(e)); + } + } + + if (stoppedEarly) { + logger.info( + `Reached incremental cutoff for workspace ${workspace.key}, moving on`, + ); + } + } + + await ctx.complete({ last_sync_at: new Date().toISOString() }); + logger.info( + `Sync completed: ${ctx.documentsScanned} scanned, ${ctx.documentsEmitted} emitted`, + ); + } catch (e) { + logger.error({ err: e }, "Sync failed with unexpected error"); + await ctx.fail(String(e)); + } + } +} diff --git a/connectors/windshift/src/main.ts b/connectors/windshift/src/main.ts new file mode 100644 index 000000000..907fa507d --- /dev/null +++ b/connectors/windshift/src/main.ts @@ -0,0 +1,4 @@ +import { WindshiftConnector } from './connector.js'; + +const connector = new WindshiftConnector(); +connector.serve(); diff --git a/connectors/windshift/src/mappers.test.ts b/connectors/windshift/src/mappers.test.ts new file mode 100644 index 000000000..9fca29dc6 --- /dev/null +++ b/connectors/windshift/src/mappers.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { generateItemContent, mapItemToDocument } from "./mappers.js"; +import type { WindshiftItem } from "./types.js"; + +test("maps item links to Windshift's stable workspace-key route", () => { + const item: WindshiftItem = { + id: 42, + workspace_id: 7, + workspace_key: "ENG", + workspace_item_number: 12, + title: "OAuth conformance", + created_at: "2026-07-21T12:00:00Z", + updated_at: "2026-07-21T12:00:00Z", + }; + + const document = mapItemToDocument( + item, + [], + "content-1", + "https://windshift.example/base/", + "Owner@Example.COM", + ); + + assert.equal( + document.metadata?.url, + "https://windshift.example/base/workspace/ENG/item/12", + ); + assert.deepEqual(document.permissions, { + public: false, + users: ["owner@example.com"], + groups: [], + }); +}); + +test("indexes milestone and iteration context", () => { + const item: WindshiftItem = { + id: 42, + workspace_id: 7, + workspace_name: "Engineering", + workspace_key: "ENG", + workspace_item_number: 12, + title: "OAuth conformance", + milestones: [{ id: 3, name: "0.8.3" }], + iteration: { id: 5, name: "Sprint 14" }, + created_at: "2026-07-21T12:00:00Z", + updated_at: "2026-07-21T12:00:00Z", + }; + + const document = mapItemToDocument( + item, + [], + "content-1", + "https://windshift.example", + "owner@example.com", + ); + assert.equal(document.attributes?.milestone, "0.8.3"); + assert.equal(document.attributes?.iteration, "Sprint 14"); + assert.match(generateItemContent(item, []), /Milestones: 0\.8\.3/); + assert.match(generateItemContent(item, []), /Iteration: Sprint 14/); +}); diff --git a/connectors/windshift/src/mappers.ts b/connectors/windshift/src/mappers.ts new file mode 100644 index 000000000..0189c9232 --- /dev/null +++ b/connectors/windshift/src/mappers.ts @@ -0,0 +1,130 @@ +import type { + Document, + DocumentMetadata, + DocumentPermissions, +} from "@getomnico/connector"; +import type { + WindshiftAttributes, + WindshiftComment, + WindshiftItem, +} from "./types.js"; + +const MAX_CONTENT_LENGTH = 100_000; + +function truncate(content: string): string { + if (content.length > MAX_CONTENT_LENGTH) { + return content.slice(0, MAX_CONTENT_LENGTH) + "\n... (truncated)"; + } + return content; +} + +function itemUrl(baseUrl: string, item: WindshiftItem): string | undefined { + const root = baseUrl.replace(/\/+$/, ""); + if (item.workspace_key && item.workspace_item_number != null) { + return `${root}/workspace/${encodeURIComponent(item.workspace_key)}/item/${item.workspace_item_number}`; + } + return `${root}/workspaces/${item.workspace_id}/items/${item.id}`; +} + +function itemIdentifier(item: WindshiftItem): string | undefined { + if (!item.workspace_key || item.workspace_item_number == null) + return undefined; + return `${item.workspace_key}-${item.workspace_item_number}`; +} + +function itemPermissions(sourceOwnerEmail: string): DocumentPermissions { + return { + public: false, + users: [sourceOwnerEmail.trim().toLowerCase()], + groups: [], + }; +} + +export function mapItemToDocument( + item: WindshiftItem, + comments: WindshiftComment[], + contentId: string, + baseUrl: string, + sourceOwnerEmail: string, +): Document { + const identifier = itemIdentifier(item); + const attributes: WindshiftAttributes = { + status: item.status_name ?? null, + priority: item.priority_name ?? null, + assignee: item.assignee_name ?? null, + assignee_email: item.assignee_email ?? null, + workspace: item.workspace_name ?? null, + identifier, + milestone: + item.milestones && item.milestones.length > 0 + ? item.milestones.map((milestone) => milestone.name).join(", ") + : null, + iteration: item.iteration?.name ?? null, + }; + + const pathParts = [item.workspace_name, identifier].filter( + Boolean, + ) as string[]; + + const metadata: DocumentMetadata = { + author: item.creator_name ?? undefined, + created_at: item.created_at, + updated_at: item.updated_at, + content_type: "item", + url: itemUrl(baseUrl, item), + mime_type: "text/markdown", + path: pathParts.join(" / ") || undefined, + }; + + const title = identifier ? `${identifier} - ${item.title}` : item.title; + + return { + external_id: `windshift:item:${item.id}`, + title, + content_id: contentId, + metadata, + permissions: itemPermissions(sourceOwnerEmail), + attributes, + }; +} + +export function generateItemContent( + item: WindshiftItem, + comments: WindshiftComment[], +): string { + const lines: string[] = []; + const identifier = itemIdentifier(item); + lines.push(identifier ? `${identifier}: ${item.title}` : item.title); + + const headerParts: string[] = []; + if (item.status_name) headerParts.push(`Status: ${item.status_name}`); + if (item.priority_name) headerParts.push(`Priority: ${item.priority_name}`); + if (item.workspace_name) + headerParts.push(`Workspace: ${item.workspace_name}`); + if (headerParts.length > 0) lines.push(headerParts.join(" | ")); + + if (item.assignee_name) lines.push(`Assignee: ${item.assignee_name}`); + if (item.creator_name) lines.push(`Created by: ${item.creator_name}`); + if (item.milestones && item.milestones.length > 0) { + lines.push( + `Milestones: ${item.milestones.map((milestone) => milestone.name).join(", ")}`, + ); + } + if (item.iteration) lines.push(`Iteration: ${item.iteration.name}`); + + lines.push(""); + if (item.description) lines.push(item.description); + + if (comments.length > 0) { + lines.push(""); + lines.push("--- Comments ---"); + for (const comment of comments) { + const dateStr = comment.created_at.split("T")[0]; + lines.push(`${comment.user_name ?? "Unknown"} (${dateStr}):`); + if (comment.body) lines.push(comment.body); + lines.push(""); + } + } + + return truncate(lines.join("\n")); +} diff --git a/connectors/windshift/src/types.ts b/connectors/windshift/src/types.ts new file mode 100644 index 000000000..2994305a4 --- /dev/null +++ b/connectors/windshift/src/types.ts @@ -0,0 +1,99 @@ +export type WindshiftSyncState = { + last_sync_at: string; +}; + +export type WindshiftSourceConfig = { + workspace_keys?: string[]; +}; + +// Credentials are written by Omni's generic OAuth dispatcher +// (`web/src/lib/server/oauth/connectorOAuth.ts`) after the user completes +// the authorization-code flow against Windshift's `/oauth/authorize` + +// `/api/oauth/token`. The connector container reaches Windshift through +// WINDSHIFT_BASE_URL (process env), not through credentials — one Omni +// install pointing at one Windshift instance. +export type WindshiftTokenCredentials = { + access_token?: string; + refresh_token?: string; + token_type?: string; // 'Bearer' + expires_at?: string; // ISO8601 +}; + +// Sync requests contain the token fields directly. Action requests contain +// Omni's ServiceCredential envelope, whose provider payload is nested under +// `credentials`. +export type WindshiftCredentials = WindshiftTokenCredentials & { + credentials?: WindshiftTokenCredentials; +}; + +export type WindshiftAttributes = { + status?: string | null; + priority?: string | null; + assignee?: string | null; + assignee_email?: string | null; + workspace?: string | null; + identifier?: string; + milestone?: string | null; + iteration?: string | null; +}; + +export type WindshiftMilestone = { + id: number; + name: string; +}; + +export type WindshiftIteration = { + id: number; + name: string; +}; + +export type WindshiftItem = { + id: number; + workspace_id: number; + workspace_name?: string | null; + workspace_key?: string | null; + workspace_item_number?: number | null; + title: string; + description?: string | null; + status_id?: number | null; + status_name?: string | null; + priority_id?: number | null; + priority_name?: string | null; + assignee_id?: number | null; + assignee_name?: string | null; + assignee_email?: string | null; + creator_id?: number | null; + creator_name?: string | null; + milestones?: WindshiftMilestone[]; + iteration?: WindshiftIteration | null; + created_at: string; + updated_at: string; + completed_at?: string | null; +}; + +export type WindshiftPaginatedResponse = { + data: T[]; + pagination: { + page: number; + limit: number; + total: number; + total_pages: number; + has_more: boolean; + }; +}; + +export type WindshiftWorkspace = { + id: number; + key: string; + name: string; +}; + +export type WindshiftComment = { + id: number; + item_id: number; + user_id?: number | null; + user_name?: string | null; + body: string; + created_at: string; + updated_at: string; +}; diff --git a/connectors/windshift/tsconfig.json b/connectors/windshift/tsconfig.json new file mode 100644 index 000000000..aef3e691a --- /dev/null +++ b/connectors/windshift/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "sourceMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index e1a0341db..ab8ce1b9a 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -214,6 +214,12 @@ services: context: .. dockerfile: connectors/linear/Dockerfile + windshift-connector: + image: omni-windshift-connector:dev + build: + context: .. + dockerfile: connectors/windshift/Dockerfile + microsoft-connector: image: omni-microsoft-connector:dev build: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 0d0d9ee05..33d173e2e 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -700,6 +700,33 @@ services: restart: unless-stopped logging: *default-logging + windshift-connector: + image: ghcr.io/getomnico/omni/omni-windshift-connector:${OMNI_VERSION:-latest} + <<: *resources-connector + cpus: ${OMNI_CONNECTOR_CPUS:-0.2} + mem_limit: ${OMNI_CONNECTOR_MEMORY:-384m} + container_name: omni-windshift-connector + profiles: + - windshift + expose: + - "${WINDSHIFT_CONNECTOR_PORT}" + environment: + <<: *otel-config + PORT: ${WINDSHIFT_CONNECTOR_PORT} + CONNECTOR_MANAGER_URL: ${CONNECTOR_MANAGER_URL} + CONNECTOR_HOST_NAME: windshift-connector + # Public OAuth issuer; must be reachable from the user's browser. + WINDSHIFT_BASE_URL: ${WINDSHIFT_BASE_URL} + # Optional connector-only route to the same Windshift instance. + WINDSHIFT_INTERNAL_BASE_URL: ${WINDSHIFT_INTERNAL_BASE_URL:-} + networks: + - omni-network + depends_on: + connector-manager: + condition: service_started + restart: unless-stopped + logging: *default-logging + microsoft-connector: image: ghcr.io/getomnico/omni/omni-microsoft-connector:${OMNI_VERSION:-latest} <<: *resources-connector diff --git a/infra/aws/terraform/main.tf b/infra/aws/terraform/main.tf index d0e3bf726..af277b4a6 100644 --- a/infra/aws/terraform/main.tf +++ b/infra/aws/terraform/main.tf @@ -200,6 +200,7 @@ module "compute" { agents_enabled = var.agents_enabled enabled_connectors = var.enabled_connectors + windshift_base_url = var.windshift_base_url sandbox_url = var.sandbox_url agent_max_iterations = var.agent_max_iterations approval_timeout_seconds = var.approval_timeout_seconds diff --git a/infra/aws/terraform/modules/compute/outputs.tf b/infra/aws/terraform/modules/compute/outputs.tf index 234120112..3dab9ffe9 100644 --- a/infra/aws/terraform/modules/compute/outputs.tf +++ b/infra/aws/terraform/modules/compute/outputs.tf @@ -118,6 +118,11 @@ output "linear_connector_service_name" { value = try(aws_ecs_service.linear_connector[0].name, null) } +output "windshift_connector_service_name" { + description = "Windshift connector service name" + value = try(aws_ecs_service.windshift_connector[0].name, null) +} + output "clickup_connector_service_name" { description = "ClickUp connector service name" value = try(aws_ecs_service.clickup_connector[0].name, null) diff --git a/infra/aws/terraform/modules/compute/service_discovery.tf b/infra/aws/terraform/modules/compute/service_discovery.tf index 3ce292129..2069bbc1c 100644 --- a/infra/aws/terraform/modules/compute/service_discovery.tf +++ b/infra/aws/terraform/modules/compute/service_discovery.tf @@ -330,6 +330,25 @@ resource "aws_service_discovery_service" "linear_connector" { } } +resource "aws_service_discovery_service" "windshift_connector" { + count = contains(var.enabled_connectors, "windshift") ? 1 : 0 + + name = "windshift-connector" + + dns_config { + namespace_id = var.service_discovery_namespace_id + + dns_records { + ttl = 300 + type = "A" + } + } + + health_check_custom_config { + failure_threshold = 1 + } +} + resource "aws_service_discovery_service" "clickup_connector" { count = contains(var.enabled_connectors, "clickup") ? 1 : 0 diff --git a/infra/aws/terraform/modules/compute/services.tf b/infra/aws/terraform/modules/compute/services.tf index 5b39e3499..80e9aa2cc 100644 --- a/infra/aws/terraform/modules/compute/services.tf +++ b/infra/aws/terraform/modules/compute/services.tf @@ -480,6 +480,33 @@ resource "aws_ecs_service" "linear_connector" { }) } +# Windshift Connector Service +resource "aws_ecs_service" "windshift_connector" { + count = contains(var.enabled_connectors, "windshift") ? 1 : 0 + + name = "omni-${var.customer_name}-windshift-connector" + cluster = var.cluster_arn + task_definition = aws_ecs_task_definition.windshift_connector[0].arn + launch_type = "FARGATE" + desired_count = var.desired_count + + enable_execute_command = true + + network_configuration { + security_groups = [var.security_group_id] + subnets = var.subnet_ids + assign_public_ip = false + } + + service_registries { + registry_arn = aws_service_discovery_service.windshift_connector[0].arn + } + + tags = merge(local.common_tags, { + Name = "omni-${var.customer_name}-windshift-connector" + }) +} + # ClickUp Connector Service resource "aws_ecs_service" "clickup_connector" { count = contains(var.enabled_connectors, "clickup") ? 1 : 0 diff --git a/infra/aws/terraform/modules/compute/task_definitions.tf b/infra/aws/terraform/modules/compute/task_definitions.tf index b6ff8ce63..c8f01b67d 100644 --- a/infra/aws/terraform/modules/compute/task_definitions.tf +++ b/infra/aws/terraform/modules/compute/task_definitions.tf @@ -919,6 +919,51 @@ resource "aws_ecs_task_definition" "linear_connector" { }) } +# Windshift Connector Task Definition +resource "aws_ecs_task_definition" "windshift_connector" { + count = contains(var.enabled_connectors, "windshift") ? 1 : 0 + + family = "omni-${var.customer_name}-windshift-connector" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = var.task_cpu + memory = var.task_memory + execution_role_arn = aws_iam_role.ecs_task_execution.arn + task_role_arn = aws_iam_role.ecs_task.arn + + container_definitions = jsonencode([{ + name = "omni-windshift-connector" + image = "ghcr.io/${var.github_org}/omni/omni-windshift-connector:latest" + essential = true + + portMappings = [{ + containerPort = 4018 + protocol = "tcp" + }] + + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = var.log_group_name + "awslogs-region" = var.region + "awslogs-stream-prefix" = "windshift-connector" + } + } + + environment = concat(local.connector_base_environment, [ + { name = "PORT", value = "4018" }, + { name = "CONNECTOR_HOST_NAME", value = "windshift-connector" }, + { name = "WINDSHIFT_BASE_URL", value = var.windshift_base_url } + ]) + + secrets = [] + }]) + + tags = merge(local.common_tags, { + Name = "omni-${var.customer_name}-windshift-connector" + }) +} + # ClickUp Connector Task Definition resource "aws_ecs_task_definition" "clickup_connector" { count = contains(var.enabled_connectors, "clickup") ? 1 : 0 diff --git a/infra/aws/terraform/modules/compute/variables.tf b/infra/aws/terraform/modules/compute/variables.tf index 152ba19b2..0baf45439 100644 --- a/infra/aws/terraform/modules/compute/variables.tf +++ b/infra/aws/terraform/modules/compute/variables.tf @@ -327,6 +327,12 @@ variable "enabled_connectors" { default = ["web"] } +variable "windshift_base_url" { + description = "Public Windshift base URL used by the Windshift connector" + type = string + default = "" +} + variable "sandbox_url" { description = "URL of the sandbox service for code execution" type = string diff --git a/infra/aws/terraform/variables.tf b/infra/aws/terraform/variables.tf index 9bf846e7c..54bb5c2a3 100644 --- a/infra/aws/terraform/variables.tf +++ b/infra/aws/terraform/variables.tf @@ -290,6 +290,12 @@ variable "enabled_connectors" { default = ["web"] } +variable "windshift_base_url" { + description = "Public Windshift base URL used by the Windshift connector" + type = string + default = "" +} + variable "sandbox_url" { description = "URL of the sandbox service for code execution (leave empty to disable)" type = string diff --git a/infra/gcp/terraform/main.tf b/infra/gcp/terraform/main.tf index bd0d208bb..234a0edaa 100644 --- a/infra/gcp/terraform/main.tf +++ b/infra/gcp/terraform/main.tf @@ -164,6 +164,7 @@ module "compute" { ai_answer_enabled = var.ai_answer_enabled agents_enabled = var.agents_enabled enabled_connectors = var.enabled_connectors + windshift_base_url = var.windshift_base_url sandbox_url = var.sandbox_url agent_max_iterations = var.agent_max_iterations approval_timeout_seconds = var.approval_timeout_seconds diff --git a/infra/gcp/terraform/modules/compute/services.tf b/infra/gcp/terraform/modules/compute/services.tf index 18e3d0d38..34b54f09a 100644 --- a/infra/gcp/terraform/modules/compute/services.tf +++ b/infra/gcp/terraform/modules/compute/services.tf @@ -10,6 +10,7 @@ locals { "google-conn", "slack-conn", "atlassian-conn", "web-conn", "github-conn", "hubspot-conn", "google-ads-conn", "microsoft-conn", "notion-conn", "fireflies-conn", "imap-conn", "clickup-conn", "linear-conn", "filesystem-conn", "nextcloud-conn", "paperless-conn", + "windshift-conn", ] : name => "https://omni-${var.customer_name}-${name}-${local.project_number}.${var.region}.run.app" } db_env = { @@ -62,6 +63,7 @@ locals { filesystem = { port = 4013, image = "omni-filesystem-connector", extra_env = {} } nextcloud = { port = 4014, image = "omni-nextcloud-connector", extra_env = {} } paperless = { port = 4015, image = "omni-paperless-connector", extra_env = {} } + windshift = { port = 4018, image = "omni-windshift-connector", extra_env = { WINDSHIFT_BASE_URL = var.windshift_base_url } } } simple_connectors = { for k, v in local.all_simple_connectors : k => v if contains(var.enabled_connectors, k) } diff --git a/infra/gcp/terraform/modules/compute/variables.tf b/infra/gcp/terraform/modules/compute/variables.tf index df5520174..619a330b7 100644 --- a/infra/gcp/terraform/modules/compute/variables.tf +++ b/infra/gcp/terraform/modules/compute/variables.tf @@ -291,6 +291,12 @@ variable "enabled_connectors" { default = ["web"] } +variable "windshift_base_url" { + description = "Public Windshift base URL used by the Windshift connector" + type = string + default = "" +} + variable "sandbox_url" { description = "URL of the sandbox service for code execution" type = string diff --git a/infra/gcp/terraform/variables.tf b/infra/gcp/terraform/variables.tf index 77a480527..980bfafd8 100644 --- a/infra/gcp/terraform/variables.tf +++ b/infra/gcp/terraform/variables.tf @@ -282,6 +282,12 @@ variable "enabled_connectors" { default = ["web"] } +variable "windshift_base_url" { + description = "Public Windshift base URL used by the Windshift connector" + type = string + default = "" +} + variable "sandbox_url" { description = "URL of the sandbox service for code execution (leave empty to disable)" type = string diff --git a/services/migrations/106_add_windshift_source_type.sql b/services/migrations/106_add_windshift_source_type.sql new file mode 100644 index 000000000..1d48c782a --- /dev/null +++ b/services/migrations/106_add_windshift_source_type.sql @@ -0,0 +1,52 @@ +-- Add Windshift as a valid source_type and service_credentials provider. +ALTER TABLE sources DROP CONSTRAINT IF EXISTS sources_source_type_check; +ALTER TABLE sources ADD CONSTRAINT sources_source_type_check +CHECK (source_type IN ( + 'google_drive', + 'gmail', + 'google_chat', + 'confluence', + 'jira', + 'slack', + 'notion', + 'web', + 'github', + 'local_files', + 'file_system', + 'fireflies', + 'hubspot', + 'one_drive', + 'share_point', + 'outlook', + 'outlook_calendar', + 'imap', + 'clickup', + 'linear', + 'ms_teams', + 'paperless_ngx', + 'nextcloud', + 'google_ads', + 'darwinbox', + 'windshift' +)); + +ALTER TABLE service_credentials DROP CONSTRAINT IF EXISTS service_credentials_provider_check; +ALTER TABLE service_credentials ADD CONSTRAINT service_credentials_provider_check +CHECK (provider IN ( + 'google', + 'slack', + 'atlassian', + 'github', + 'notion', + 'fireflies', + 'hubspot', + 'microsoft', + 'imap', + 'clickup', + 'linear', + 'paperless_ngx', + 'nextcloud', + 'google_ads', + 'darwinbox', + 'windshift' +)); diff --git a/services/migrations/107_make_windshift_sources_personal.sql b/services/migrations/107_make_windshift_sources_personal.sql new file mode 100644 index 000000000..ebb6b082b --- /dev/null +++ b/services/migrations/107_make_windshift_sources_personal.sql @@ -0,0 +1,61 @@ +-- Windshift uses delegated per-user OAuth. Convert the original organization +-- source model to one personal source per Omni user. + +-- If the source owner already completed a per-user action authorization, keep +-- that credential (it includes the read scopes needed by sync) and remove the +-- older org credential. +DELETE FROM service_credentials org_credential +USING sources source +WHERE org_credential.source_id = source.id + AND org_credential.user_id IS NULL + AND source.source_type = 'windshift' + AND source.scope = 'org' + AND EXISTS ( + SELECT 1 + FROM service_credentials owner_credential + WHERE owner_credential.source_id = source.id + AND owner_credential.user_id = source.created_by + ); + +-- Otherwise move the original sync credential to the source owner. +UPDATE service_credentials credential +SET user_id = source.created_by, + updated_at = NOW() +FROM sources source +WHERE credential.source_id = source.id + AND credential.user_id IS NULL + AND source.source_type = 'windshift' + AND source.scope = 'org'; + +-- Credentials other users attached to the former org source must not remain +-- usable after it becomes the owner's personal source. +DELETE FROM service_credentials credential +USING sources source +WHERE credential.source_id = source.id + AND credential.user_id IS NOT NULL + AND credential.user_id <> source.created_by + AND source.source_type = 'windshift' + AND source.scope = 'org'; + +UPDATE sources +SET scope = 'user', + updated_at = NOW() +WHERE source_type = 'windshift' + AND scope = 'org'; + +COMMENT ON COLUMN service_credentials.user_id IS + 'For personal sources, identifies the source owner credential; for org sources, identifies a user action credential; NULL identifies an org credential.'; + +-- Existing indexed documents must become private immediately; a later sync +-- will continue emitting the same owner-only ACL. +UPDATE documents document +SET permissions = jsonb_build_object( + 'public', false, + 'users', jsonb_build_array(lower(owner_user.email)), + 'groups', '[]'::jsonb + ), + updated_at = NOW() +FROM sources source +JOIN users owner_user ON owner_user.id = source.created_by +WHERE document.source_id = source.id + AND source.source_type = 'windshift'; diff --git a/shared/src/models.rs b/shared/src/models.rs index b689118ef..bdba3022e 100644 --- a/shared/src/models.rs +++ b/shared/src/models.rs @@ -205,6 +205,7 @@ pub enum SourceType { Nextcloud, GoogleAds, Darwinbox, + Windshift, } impl SourceType { @@ -235,6 +236,7 @@ impl SourceType { SourceType::Nextcloud => "nextcloud", SourceType::GoogleAds => "google_ads", SourceType::Darwinbox => "darwinbox", + SourceType::Windshift => "windshift", } } } @@ -283,6 +285,7 @@ impl TryFrom<&str> for SourceType { "nextcloud" => Ok(SourceType::Nextcloud), "google_ads" => Ok(SourceType::GoogleAds), "darwinbox" => Ok(SourceType::Darwinbox), + "windshift" => Ok(SourceType::Windshift), other => Err(format!("unknown source type: {other}")), } } @@ -328,6 +331,7 @@ pub enum ServiceProvider { #[sqlx(rename = "remote_mcp")] #[serde(rename = "remote_mcp")] RemoteMcp, + Windshift, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, sqlx::Type, PartialEq)] diff --git a/web/src/lib/components/windshift-connector-setup.svelte b/web/src/lib/components/windshift-connector-setup.svelte new file mode 100644 index 000000000..6a37d7cab --- /dev/null +++ b/web/src/lib/components/windshift-connector-setup.svelte @@ -0,0 +1,58 @@ + + + !value && onCancel?.()}> + + + Connect Windshift + + Connect your Windshift account and index the workspaces you can access. + + + +
+ {#if baseUrl} +
+
Windshift URL
+ + {baseUrl} + +
+ {/if} +

+ Windshift will show the exact read permissions Omni needs. The OAuth client is + registered automatically and uses PKCE; there is no client secret to configure. +

+

+ Indexed content is private to your Omni account. Write actions request expanded + authorization when they are first used. +

+
+ + + + + +
+
diff --git a/web/src/lib/images/icons/windshift.png b/web/src/lib/images/icons/windshift.png new file mode 100644 index 0000000000000000000000000000000000000000..d067d3424060f425e57242ce20049ee4ca9e608a GIT binary patch literal 45622 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≫@+3}gu}4JYjGSJ^YAQ;IiXVzp^!BR42YE^ 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%0DD;VE1_M91)k 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 literal 0 HcmV?d00001 diff --git a/web/src/lib/server/oauth/connectorOAuth.test.ts b/web/src/lib/server/oauth/connectorOAuth.test.ts index b570d3714..84b53df9c 100644 --- a/web/src/lib/server/oauth/connectorOAuth.test.ts +++ b/web/src/lib/server/oauth/connectorOAuth.test.ts @@ -1,8 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { OAuthStateManager } from './state' import { + dynamicRegistrationPayload, isAutoManagedOAuthProvider, isClientConfigComplete, + oauthServiceBaseUrl, scopesForExistingSourceUserFlow, tokenEndpointAuthMethodForConfig, type OAuthManifestConfig, @@ -66,6 +68,34 @@ describe('OAuth connector helpers', () => { ).toBe(false) }) + it('builds provider-specific public-client registration metadata', () => { + expect( + dynamicRegistrationPayload( + 'windshift', + 'https://omni.example/api/oauth/callback', + 'mcp:access', + ), + ).toEqual({ + client_name: 'Omni Windshift MCP', + redirect_uris: ['https://omni.example/api/oauth/callback'], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + scope: 'mcp:access', + }) + + expect( + dynamicRegistrationPayload( + 'clickup', + 'https://omni.example/api/oauth/callback', + 'tasks:read', + ), + ).toMatchObject({ + client_name: 'Omni ClickUp MCP', + grant_types: ['authorization_code'], + }) + }) + it('checks configured state based on token endpoint auth method', () => { expect(isClientConfigComplete({ oauth_client_id: 'public-client' }, 'none')).toBe(true) expect( @@ -98,11 +128,20 @@ describe('OAuth connector helpers', () => { expect(tokenEndpointAuthMethodForConfig(undefined, undefined)).toBe('client_secret_post') }) + it('derives a deployment base URL from its OAuth authorization endpoint', () => { + expect(oauthServiceBaseUrl('https://windshift.example/oauth/authorize')).toBe( + 'https://windshift.example', + ) + expect( + oauthServiceBaseUrl('https://example.com/windshift/oauth/authorize?prompt=login'), + ).toBe('https://example.com/windshift') + }) + it('limits write elevation to the requested connector scopes', () => { const config: OAuthManifestConfig = { ...baseManifest, scopes: { - example: { + windshift: { read: ['mcp:access', 'items:read'], write: ['mcp:access', 'items:read', 'items:write', 'items:delete'], }, @@ -110,7 +149,7 @@ describe('OAuth connector helpers', () => { } expect( - scopesForExistingSourceUserFlow(config, 'example', 'write', ['items:write']), + scopesForExistingSourceUserFlow(config, 'windshift', 'write', ['items:write']), ).toEqual(['mcp:access', 'items:read', 'items:write']) }) diff --git a/web/src/lib/server/oauth/connectorOAuth.ts b/web/src/lib/server/oauth/connectorOAuth.ts index adae18466..7bcd6aced 100644 --- a/web/src/lib/server/oauth/connectorOAuth.ts +++ b/web/src/lib/server/oauth/connectorOAuth.ts @@ -84,6 +84,22 @@ export function callbackUrl(): string { return `${app.publicUrl}/api/oauth/callback` } +/** Return the public service URL represented by an OAuth authorization endpoint. */ +export function oauthServiceBaseUrl(authEndpoint: string): string { + const authorizationPath = '/oauth/authorize' + try { + const url = new URL(authEndpoint) + if (url.pathname.endsWith(authorizationPath)) { + url.pathname = url.pathname.slice(0, -authorizationPath.length) || '/' + } + url.search = '' + url.hash = '' + return url.toString().replace(/\/$/, '') + } catch { + return authEndpoint.replace(/\/oauth\/authorize\/?$/, '').replace(/\/$/, '') + } +} + /// Fetch a connector manifest from connector-manager by source_type. Returns /// the manifest's oauth block, or null if the connector either isn't /// registered or doesn't declare an OAuth config. @@ -143,7 +159,18 @@ async function loadClientCreds( const clientId = storedConfig.oauth_client_id const clientSecret = storedConfig.oauth_client_secret - if (clientId && isClientConfigComplete(storedConfig, tokenEndpointAuthMethod)) { + const dynamicRegistrationIsCurrent = + !manifestConfig || + !isAutoManagedOAuthProvider(manifestConfig) || + storedConfig.oauth_dynamic_client_registration !== 'true' || + (storedConfig.oauth_registration_endpoint === manifestConfig.registration_endpoint && + storedConfig.oauth_redirect_uri === callbackUrl()) + + if ( + clientId && + isClientConfigComplete(storedConfig, tokenEndpointAuthMethod) && + dynamicRegistrationIsCurrent + ) { return { clientId, clientSecret: clientSecret || undefined, @@ -241,14 +268,7 @@ async function dynamicallyRegisterClient( 'Content-Type': 'application/json', Accept: 'application/json', }, - body: JSON.stringify({ - client_name: 'Omni ClickUp MCP', - redirect_uris: [redirectUri], - grant_types: ['authorization_code'], - response_types: ['code'], - token_endpoint_auth_method: 'none', - scope, - }), + body: JSON.stringify(dynamicRegistrationPayload(provider, redirectUri, scope)), }) } catch { return null @@ -263,6 +283,8 @@ async function dynamicallyRegisterClient( oauth_client_id: data.client_id, oauth_token_endpoint_auth_method: 'none', oauth_dynamic_client_registration: 'true', + oauth_registration_endpoint: config.registration_endpoint!, + oauth_redirect_uri: redirectUri, } await upsertConnectorConfig(provider, stored, null) @@ -274,6 +296,28 @@ async function dynamicallyRegisterClient( } } +export function dynamicRegistrationPayload(provider: string, redirectUri: string, scope: string) { + const providerName = + provider === 'clickup' + ? 'ClickUp' + : provider + .split(/[-_\s]+/) + .filter(Boolean) + .map((part) => part[0].toUpperCase() + part.slice(1)) + .join(' ') + return { + client_name: `Omni ${providerName} MCP`, + redirect_uris: [redirectUri], + grant_types: + provider === 'windshift' + ? ['authorization_code', 'refresh_token'] + : ['authorization_code'], + response_types: ['code'], + token_endpoint_auth_method: 'none' as const, + scope, + } +} + export function tokenEndpointAuthMethodForConfig( config: Record | undefined, manifestConfig?: OAuthManifestConfig, diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 013c3ce2b..aa3c42e6e 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -32,6 +32,7 @@ export enum SourceType { NEXTCLOUD = 'nextcloud', GOOGLE_ADS = 'google_ads', DARWINBOX = 'darwinbox', + WINDSHIFT = 'windshift', } export enum ServiceProvider { @@ -51,6 +52,7 @@ export enum ServiceProvider { GOOGLE_ADS = 'google_ads', DARWINBOX = 'darwinbox', REMOTE_MCP = 'remote_mcp', + WINDSHIFT = 'windshift', } export enum AuthType { @@ -231,6 +233,15 @@ export interface DarwinboxSourceConfig { > } +export interface WindshiftSourceConfig { + workspace_keys?: string[] +} + +export interface WindshiftCredentials { + access_token: string + refresh_token?: string +} + export const DEFAULT_SYNC_INTERVAL_SECONDS: Record = { [SourceType.GOOGLE_DRIVE]: 1800, [SourceType.GMAIL]: 1800, @@ -256,6 +267,7 @@ export const DEFAULT_SYNC_INTERVAL_SECONDS: Record = { [SourceType.NEXTCLOUD]: 3600, [SourceType.GOOGLE_ADS]: 3600, [SourceType.DARWINBOX]: 3600, + [SourceType.WINDSHIFT]: 1800, } export const EMBEDDING_PROVIDER_TYPES = ['local', 'jina', 'openai', 'cohere', 'bedrock'] as const diff --git a/web/src/lib/utils/icons.ts b/web/src/lib/utils/icons.ts index 304dd6a04..3c840f37a 100644 --- a/web/src/lib/utils/icons.ts +++ b/web/src/lib/utils/icons.ts @@ -28,6 +28,7 @@ import githubIcon from '$lib/images/icons/github.svg' import nextcloudIcon from '$lib/images/icons/nextcloud.svg' import paperlessIcon from '$lib/images/icons/paperless.svg' import imapIcon from '$lib/images/icons/imap.svg' +import windshiftIcon from '$lib/images/icons/windshift.png' // Google Workspace MIME types const GOOGLE_DOCS_MIMETYPES = [ @@ -74,6 +75,7 @@ const SOURCE_TYPE_ICONS: Record = { [SourceType.IMAP]: imapIcon, [SourceType.GOOGLE_ADS]: googleAdsIcon, [SourceType.DARWINBOX]: darwinboxIcon, + [SourceType.WINDSHIFT]: windshiftIcon, } // Get icon based on source type and content type. @@ -242,6 +244,7 @@ export function getSourceDisplayName(sourceType: SourceType) { [SourceType.PAPERLESS_NGX]: 'Paperless-ngx', [SourceType.GOOGLE_ADS]: 'Google Ads', [SourceType.DARWINBOX]: 'Darwinbox', + [SourceType.WINDSHIFT]: 'Windshift', } return sourceDisplayNames[sourceType] diff --git a/web/src/routes/(admin)/admin/settings/integrations/+page.server.ts b/web/src/routes/(admin)/admin/settings/integrations/+page.server.ts index bfd969bd2..310fe568f 100644 --- a/web/src/routes/(admin)/admin/settings/integrations/+page.server.ts +++ b/web/src/routes/(admin)/admin/settings/integrations/+page.server.ts @@ -148,7 +148,12 @@ export const load: PageServerLoad = async ({ locals, fetch }) => { // Group by connector_id to build integration list const integrationMap = new Map< string, - { id: string; name: string; description: string; connected: boolean } + { + id: string + name: string + description: string + connected: boolean + } >() const sourceTypesByOAuthProvider = new Map>() const oauthManifestByProvider = new Map() @@ -212,13 +217,16 @@ export const load: PageServerLoad = async ({ locals, fetch }) => { }) .sort((a, b) => a.displayName.localeCompare(b.displayName)) - availableIntegrations = Array.from(integrationMap.values()).sort((a, b) => { - const idxA = CONNECTOR_DISPLAY_ORDER.indexOf(a.id) - const idxB = CONNECTOR_DISPLAY_ORDER.indexOf(b.id) - const orderA = idxA === -1 ? CONNECTOR_DISPLAY_ORDER.length : idxA - const orderB = idxB === -1 ? CONNECTOR_DISPLAY_ORDER.length : idxB - return orderA !== orderB ? orderA - orderB : a.id.localeCompare(b.id) - }) + availableIntegrations = Array.from(integrationMap.values()) + // Windshift is a personal OAuth source. Users connect it under My Integrations. + .filter((integration) => integration.id !== 'windshift') + .sort((a, b) => { + const idxA = CONNECTOR_DISPLAY_ORDER.indexOf(a.id) + const idxB = CONNECTOR_DISPLAY_ORDER.indexOf(b.id) + const orderA = idxA === -1 ? CONNECTOR_DISPLAY_ORDER.length : idxA + const orderB = idxB === -1 ? CONNECTOR_DISPLAY_ORDER.length : idxB + return orderA !== orderB ? orderA - orderB : a.id.localeCompare(b.id) + }) } } catch (error) { locals.logger.error('Failed to fetch connector manager data', error) diff --git a/web/src/routes/(admin)/admin/settings/integrations/+page.svelte b/web/src/routes/(admin)/admin/settings/integrations/+page.svelte index 9e8e05773..15c96df61 100644 --- a/web/src/routes/(admin)/admin/settings/integrations/+page.svelte +++ b/web/src/routes/(admin)/admin/settings/integrations/+page.svelte @@ -309,7 +309,7 @@ {#if data.connectedSources.length > 0}
- {#each data.connectedSources as source} + {#each data.connectedSources as source (source.id)} {@const noun = getSourceNoun(source.sourceType as SourceType)} {@const sync = latestSyncRuns.get(source.id)} {@const health = sourceHealth.get(source.id)} @@ -517,7 +517,7 @@
- {#each data.availableIntegrations as integration} + {#each data.availableIntegrations as integration (integration.id)} @@ -594,7 +594,7 @@
Last updated
- {#each data.oauthProviders as provider} + {#each data.oauthProviders as provider (provider.provider)}
diff --git a/web/src/routes/(app)/settings/integrations/+page.server.ts b/web/src/routes/(app)/settings/integrations/+page.server.ts index e1527eb28..ba498c185 100644 --- a/web/src/routes/(app)/settings/integrations/+page.server.ts +++ b/web/src/routes/(app)/settings/integrations/+page.server.ts @@ -5,7 +5,11 @@ import { sources, documents } from '$lib/server/db/schema' import { eq, and, count, inArray } from 'drizzle-orm' import { updateSourceById } from '$lib/server/db/sources' import { sourcesRepository } from '$lib/server/repositories/sources' -import { supportsDataSync } from '$lib/types' +import { + getOAuthManifestForSourceType, + oauthServiceBaseUrl, +} from '$lib/server/oauth/connectorOAuth' +import { SourceType, supportsDataSync } from '$lib/types' import type { PageServerLoad, Actions } from './$types' export const load: PageServerLoad = async ({ locals }) => { @@ -15,6 +19,14 @@ export const load: PageServerLoad = async ({ locals }) => { const googleConnectorConfig = await getConnectorConfigPublic('google') + let windshiftBaseUrl: string | null = null + try { + const oauth = await getOAuthManifestForSourceType(SourceType.WINDSHIFT) + if (oauth?.auth_endpoint) windshiftBaseUrl = oauthServiceBaseUrl(oauth.auth_endpoint) + } catch (err) { + locals.logger.warn('Failed to load the Windshift connector URL', err) + } + const userSources = (await sourcesRepository.getByUserId(locals.user.id)).filter((source) => supportsDataSync(source.integrationType), ) @@ -42,6 +54,7 @@ export const load: PageServerLoad = async ({ locals }) => { googleOAuthConfigured: !!( googleConnectorConfig && googleConnectorConfig.config.oauth_client_id ), + windshiftBaseUrl, userSources, latestSyncRuns, documentCounts, diff --git a/web/src/routes/(app)/settings/integrations/+page.svelte b/web/src/routes/(app)/settings/integrations/+page.svelte index 52ba6ec6d..7950b4db7 100644 --- a/web/src/routes/(app)/settings/integrations/+page.svelte +++ b/web/src/routes/(app)/settings/integrations/+page.svelte @@ -12,8 +12,10 @@ import * as AlertDialog from '$lib/components/ui/alert-dialog' import type { PageProps } from './$types' import googleLogo from '$lib/images/icons/google.svg' + import windshiftLogo from '$lib/images/icons/windshift.png' import { Globe, HardDrive, Mail, Trash2 } from '@lucide/svelte' import GoogleOAuthSetup from '$lib/components/google-oauth-setup.svelte' + import WindshiftConnectorSetup from '$lib/components/windshift-connector-setup.svelte' import { getSourceIconPath } from '$lib/utils/icons' import { formatDate, getSourceNoun } from '$lib/utils/sources' import { SourceType } from '$lib/types' @@ -117,10 +119,12 @@ } let showGoogleOAuthSetup = $state(false) + let showWindshiftSetup = $state(false) let hasGoogleDrive = $derived(data.userSources.some((s) => s.sourceType === 'google_drive')) let hasGmail = $derived(data.userSources.some((s) => s.sourceType === 'gmail')) let hasAllGoogleSources = $derived(hasGoogleDrive && hasGmail) + let hasWindshift = $derived(data.userSources.some((s) => s.sourceType === 'windshift')) function handleGoogleOAuthSetupSuccess() { showGoogleOAuthSetup = false @@ -258,7 +262,7 @@ - {#if data.googleOAuthConfigured} + {#if data.googleOAuthConfigured || data.windshiftBaseUrl}

Available Integrations

@@ -266,41 +270,84 @@
- - - -
- Google -
- Google - {#if hasAllGoogleSources} - - Connected - - {/if} -
-
- -

- Connect your own Google Drive and Gmail with read-only access. -

-
- {#if !hasAllGoogleSources} - - - - {/if} -
+ {#if data.googleOAuthConfigured} + + + +
+ Google +
+ Google + {#if hasAllGoogleSources} + + Connected + + {/if} +
+
+ +

+ Connect your own Google Drive and Gmail with read-only access. +

+
+ {#if !hasAllGoogleSources} + + + + {/if} +
+ {/if} + + {#if data.windshiftBaseUrl} + + + +
+ Windshift +
+ Windshift + {#if hasWindshift} + + Connected + + {/if} +
+
+ +

+ Index work items from the Windshift workspaces you can access. +

+ + {data.windshiftBaseUrl} + +
+ {#if !hasWindshift} + + + + {/if} +
+ {/if}
{:else if data.userSources.length === 0} @@ -319,6 +366,11 @@ onSuccess={handleGoogleOAuthSetupSuccess} onCancel={() => (showGoogleOAuthSetup = false)} /> + (showWindshiftSetup = false)} /> + { From d0ff8915b66b1d8402e1fcd6891bfb17de7faaad Mon Sep 17 00:00:00 2001 From: Stefan Ernst Date: Fri, 24 Jul 2026 22:57:28 +0200 Subject: [PATCH 2/3] fix: address Windshift connector review feedback --- .../107_make_windshift_sources_personal.sql | 61 ------------------- ....sql => 108_add_windshift_source_type.sql} | 61 ++++++++++--------- 2 files changed, 33 insertions(+), 89 deletions(-) delete mode 100644 services/migrations/107_make_windshift_sources_personal.sql rename services/migrations/{106_add_windshift_source_type.sql => 108_add_windshift_source_type.sql} (51%) diff --git a/services/migrations/107_make_windshift_sources_personal.sql b/services/migrations/107_make_windshift_sources_personal.sql deleted file mode 100644 index ebb6b082b..000000000 --- a/services/migrations/107_make_windshift_sources_personal.sql +++ /dev/null @@ -1,61 +0,0 @@ --- Windshift uses delegated per-user OAuth. Convert the original organization --- source model to one personal source per Omni user. - --- If the source owner already completed a per-user action authorization, keep --- that credential (it includes the read scopes needed by sync) and remove the --- older org credential. -DELETE FROM service_credentials org_credential -USING sources source -WHERE org_credential.source_id = source.id - AND org_credential.user_id IS NULL - AND source.source_type = 'windshift' - AND source.scope = 'org' - AND EXISTS ( - SELECT 1 - FROM service_credentials owner_credential - WHERE owner_credential.source_id = source.id - AND owner_credential.user_id = source.created_by - ); - --- Otherwise move the original sync credential to the source owner. -UPDATE service_credentials credential -SET user_id = source.created_by, - updated_at = NOW() -FROM sources source -WHERE credential.source_id = source.id - AND credential.user_id IS NULL - AND source.source_type = 'windshift' - AND source.scope = 'org'; - --- Credentials other users attached to the former org source must not remain --- usable after it becomes the owner's personal source. -DELETE FROM service_credentials credential -USING sources source -WHERE credential.source_id = source.id - AND credential.user_id IS NOT NULL - AND credential.user_id <> source.created_by - AND source.source_type = 'windshift' - AND source.scope = 'org'; - -UPDATE sources -SET scope = 'user', - updated_at = NOW() -WHERE source_type = 'windshift' - AND scope = 'org'; - -COMMENT ON COLUMN service_credentials.user_id IS - 'For personal sources, identifies the source owner credential; for org sources, identifies a user action credential; NULL identifies an org credential.'; - --- Existing indexed documents must become private immediately; a later sync --- will continue emitting the same owner-only ACL. -UPDATE documents document -SET permissions = jsonb_build_object( - 'public', false, - 'users', jsonb_build_array(lower(owner_user.email)), - 'groups', '[]'::jsonb - ), - updated_at = NOW() -FROM sources source -JOIN users owner_user ON owner_user.id = source.created_by -WHERE document.source_id = source.id - AND source.source_type = 'windshift'; diff --git a/services/migrations/106_add_windshift_source_type.sql b/services/migrations/108_add_windshift_source_type.sql similarity index 51% rename from services/migrations/106_add_windshift_source_type.sql rename to services/migrations/108_add_windshift_source_type.sql index 1d48c782a..4f5422724 100644 --- a/services/migrations/106_add_windshift_source_type.sql +++ b/services/migrations/108_add_windshift_source_type.sql @@ -1,34 +1,38 @@ -- Add Windshift as a valid source_type and service_credentials provider. ALTER TABLE sources DROP CONSTRAINT IF EXISTS sources_source_type_check; ALTER TABLE sources ADD CONSTRAINT sources_source_type_check -CHECK (source_type IN ( - 'google_drive', - 'gmail', - 'google_chat', - 'confluence', - 'jira', - 'slack', - 'notion', - 'web', - 'github', - 'local_files', - 'file_system', - 'fireflies', - 'hubspot', - 'one_drive', - 'share_point', - 'outlook', - 'outlook_calendar', - 'imap', - 'clickup', - 'linear', - 'ms_teams', - 'paperless_ngx', - 'nextcloud', - 'google_ads', - 'darwinbox', - 'windshift' -)); +CHECK ( + (integration_type = 'connector' AND source_type IN ( + 'google_drive', + 'gmail', + 'google_chat', + 'confluence', + 'jira', + 'slack', + 'notion', + 'web', + 'github', + 'local_files', + 'file_system', + 'fireflies', + 'hubspot', + 'one_drive', + 'share_point', + 'outlook', + 'outlook_calendar', + 'imap', + 'clickup', + 'linear', + 'ms_teams', + 'paperless_ngx', + 'nextcloud', + 'google_ads', + 'darwinbox', + 'windshift' + )) + OR + (integration_type = 'remote_mcp' AND source_type ~ '^[a-z][a-z0-9_-]{1,49}$') +); ALTER TABLE service_credentials DROP CONSTRAINT IF EXISTS service_credentials_provider_check; ALTER TABLE service_credentials ADD CONSTRAINT service_credentials_provider_check @@ -48,5 +52,6 @@ CHECK (provider IN ( 'nextcloud', 'google_ads', 'darwinbox', + 'remote_mcp', 'windshift' )); From 9cf6b11ed5f96c43db8b8194c3e0e18ded9d1218 Mon Sep 17 00:00:00 2001 From: Stefan Ernst Date: Wed, 29 Jul 2026 16:05:30 +0200 Subject: [PATCH 3/3] fix(windshift): make incremental sync cursor-safe --- connectors/windshift/README.md | 13 +- connectors/windshift/package-lock.json | 191 ++++++++++++---- connectors/windshift/src/client.test.ts | 91 +++++++- connectors/windshift/src/client.ts | 39 +++- connectors/windshift/src/connector.test.ts | 247 +++++++++++++++++++-- connectors/windshift/src/connector.ts | 214 +++++++++++++----- connectors/windshift/src/types.ts | 21 +- 7 files changed, 664 insertions(+), 152 deletions(-) diff --git a/connectors/windshift/README.md b/connectors/windshift/README.md index 724d2422a..98977b0fd 100644 --- a/connectors/windshift/README.md +++ b/connectors/windshift/README.md @@ -17,7 +17,7 @@ sync, and MCP requests use the internal route. No OAuth client ID or secret is configured manually. Omni dynamically registers as a public client, uses S256 PKCE, and requests tokens bound to -`${WINDSHIFT_BASE_URL}/mcp`. Windshift 0.8.3 or newer is required. +`${WINDSHIFT_BASE_URL}/mcp`. Windshift 0.8.4 or newer is required. Each user connects Windshift from **My Integrations**. The initial authorization grants read access for that user's sync and read-only MCP tools. Write and @@ -38,8 +38,9 @@ per-credential database lock. | Assignee | `attributes.assignee` and `assignee_email` | | Created/updated timestamps | Document metadata | -Full sync walks visible workspaces and items. Incremental sync stops once items -are older than the last checkpoint. Optional `workspace_keys` restricts sync to -specific Windshift workspaces. Windshift is a personal source in Omni: every -user has an independent sync backed by their own OAuth credential, and indexed -items are visible only to that Omni user. +Full sync walks visible workspaces and items while capturing each workspace's +change watermark. Incremental sync then reads Windshift's ordered item change +log, including comment activity and deletions. Optional `workspace_keys` +restricts sync to specific Windshift workspaces. Windshift is a personal source +in Omni: every user has an independent sync backed by their own OAuth +credential, and indexed items are visible only to that Omni user. diff --git a/connectors/windshift/package-lock.json b/connectors/windshift/package-lock.json index dc4e0ab41..82fb0188d 100644 --- a/connectors/windshift/package-lock.json +++ b/connectors/windshift/package-lock.json @@ -20,26 +20,15 @@ "node": ">=24.0.0" } }, - "../../sdk/typescript": { - "name": "@getomnico/connector", + "node_modules/@getomnico/connector": { "version": "0.1.0", + "resolved": "file:../../sdk/typescript", "license": "Apache-2.0", "dependencies": { "express": "^4.21.0", "pino": "^10.3.1", "zod": "^3.23.0" }, - "devDependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", - "@types/express": "^5.0.0", - "@types/node": "^22.19.15", - "@types/supertest": "^6.0.0", - "msw": "^2.6.0", - "pino-pretty": "^13.1.3", - "supertest": "^7.0.0", - "typescript": "^5.6.0", - "vitest": "^3.2.7" - }, "engines": { "node": ">=20.0.0" }, @@ -52,10 +41,6 @@ } } }, - "node_modules/@getomnico/connector": { - "resolved": "../../sdk/typescript", - "link": true - }, "node_modules/@hono/node-server": { "version": "1.19.14", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", @@ -454,6 +439,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", @@ -469,7 +460,6 @@ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", "license": "MIT", - "peer": true, "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" @@ -515,15 +505,22 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", "license": "MIT", - "peer": true + "engines": { + "node": ">=8.0.0" + } }, "node_modules/body-parser": { "version": "1.20.5", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", "license": "MIT", - "peer": true, "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", @@ -548,7 +545,6 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "side-channel": "^1.1.0" }, @@ -602,7 +598,6 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "5.2.1" }, @@ -632,8 +627,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/cors": { "version": "2.8.6", @@ -671,7 +665,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -690,7 +683,6 @@ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" @@ -796,7 +788,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -907,7 +898,6 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", "license": "MIT", - "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", @@ -935,7 +925,6 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -1056,7 +1045,6 @@ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "license": "MIT", - "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -1135,7 +1123,6 @@ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -1145,7 +1132,6 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/sindresorhus" } @@ -1155,7 +1141,6 @@ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -1165,7 +1150,6 @@ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", "license": "MIT", - "peer": true, "bin": { "mime": "cli.js" }, @@ -1178,7 +1162,6 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -1188,7 +1171,6 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", - "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -1200,15 +1182,13 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -1234,6 +1214,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -1277,8 +1266,44 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", "license": "MIT", - "peer": true + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" }, "node_modules/pkce-challenge": { "version": "5.0.1", @@ -1289,6 +1314,22 @@ "node": ">=16.20.0" } }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1307,7 +1348,6 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "side-channel": "^1.1.0" }, @@ -1318,6 +1358,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -1332,7 +1378,6 @@ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", "license": "MIT", - "peer": true, "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", @@ -1343,6 +1388,15 @@ "node": ">= 0.8" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -1419,8 +1473,16 @@ "url": "https://feross.org/support" } ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", "license": "MIT", - "peer": true + "engines": { + "node": ">=10" + } }, "node_modules/safer-buffer": { "version": "2.1.2", @@ -1433,7 +1495,6 @@ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", "license": "MIT", - "peer": true, "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -1457,15 +1518,13 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/serve-static": { "version": "1.16.3", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", "license": "MIT", - "peer": true, "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", @@ -1575,6 +1634,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1584,6 +1661,24 @@ "node": ">= 0.8" } }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1598,7 +1693,6 @@ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", "license": "MIT", - "peer": true, "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" @@ -1642,7 +1736,6 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4.0" } diff --git a/connectors/windshift/src/client.test.ts b/connectors/windshift/src/client.test.ts index 657c76130..a980a4aa0 100644 --- a/connectors/windshift/src/client.test.ts +++ b/connectors/windshift/src/client.test.ts @@ -17,8 +17,8 @@ test("item pagination uses REST v1 and normalizes nested fields", async () => { init?.headers && (init.headers as Record).Authorization, "Bearer token", ); - assert.equal(url.searchParams.get("sort"), "updated_at"); - assert.equal(url.searchParams.get("order"), "desc"); + assert.equal(url.searchParams.get("sort"), "key"); + assert.equal(url.searchParams.get("order"), "asc"); assert.equal(url.searchParams.get("workspace_id"), "1"); return new Response( JSON.stringify({ @@ -84,16 +84,28 @@ test("workspace pagination and comments use REST v1 response shapes", async () = }, }); } - return Response.json([ - { - id: 9, - item_id: 7, - content: "OAuth now works", - author: { id: 2, full_name: "Ada Lovelace" }, - created_at: "2026-07-21T12:00:00Z", - updated_at: "2026-07-21T12:00:00Z", + assert.equal(url.searchParams.get("page"), "1"); + assert.equal(url.searchParams.get("limit"), "50"); + assert.equal(url.searchParams.get("order"), "desc"); + return Response.json({ + data: [ + { + id: 9, + item_id: 7, + content: "OAuth now works", + author: { id: 2, full_name: "Ada Lovelace" }, + created_at: "2026-07-21T12:00:00Z", + updated_at: "2026-07-21T12:00:00Z", + }, + ], + pagination: { + page: 1, + limit: 50, + total: 1, + total_pages: 1, + has_more: false, }, - ]); + }); }; try { @@ -120,3 +132,60 @@ test("workspace pagination and comments use REST v1 response shapes", async () = globalThis.fetch = originalFetch; } }); + +test("item changes use string cursors and batch-fetch changed items", async () => { + const originalFetch = globalThis.fetch; + const requestedPaths: string[] = []; + globalThis.fetch = async (input) => { + const url = new URL(String(input)); + requestedPaths.push(`${url.pathname}?${url.searchParams}`); + if (url.pathname.endsWith("/items/changes")) { + assert.equal(url.searchParams.get("workspace_id"), "1"); + assert.equal(url.searchParams.get("since"), "10"); + assert.equal(url.searchParams.get("through"), "13"); + assert.equal(url.searchParams.get("limit"), "500"); + return Response.json({ + changes: [ + { item_id: 7, change_type: "upsert" }, + { item_id: 8, change_type: "delete" }, + ], + next_cursor: "13", + watermark: "13", + has_more: false, + reset_required: false, + }); + } + assert.equal(url.pathname.endsWith("/items/batch"), true); + assert.equal(url.searchParams.get("ids"), "7,9"); + return Response.json([ + { + id: 7, + workspace_id: 1, + workspace_key: "ENG", + workspace_item_number: 7, + title: "Changed item", + created_at: "2026-07-21T12:00:00Z", + updated_at: "2026-07-21T12:00:00Z", + }, + ]); + }; + + try { + const client = new WindshiftApiClient("https://windshift.example", "token"); + const changes = await client.fetchItemChanges(1, "10", "13"); + assert.deepEqual(changes.changes, [ + { item_id: 7, change_type: "upsert" }, + { item_id: 8, change_type: "delete" }, + ]); + assert.equal(changes.next_cursor, "13"); + + const items = await client.fetchItemsByIds([7, 9]); + assert.deepEqual( + items.map((item) => item.id), + [7], + ); + assert.equal(requestedPaths.length, 2); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/connectors/windshift/src/client.ts b/connectors/windshift/src/client.ts index 919f149aa..376fc2702 100644 --- a/connectors/windshift/src/client.ts +++ b/connectors/windshift/src/client.ts @@ -3,9 +3,11 @@ import type { WindshiftPaginatedResponse, WindshiftWorkspace, WindshiftComment, + WindshiftItemChangesResponse, } from "./types.js"; const PAGE_SIZE = 100; +const CHANGE_PAGE_SIZE = 500; const MAX_COMMENTS_PER_ITEM = 50; function joinUrl(baseUrl: string, path: string): string { @@ -136,8 +138,8 @@ export class WindshiftApiClient { const params = new URLSearchParams({ page: String(page), limit: String(PAGE_SIZE), - sort: "updated_at", - order: "desc", + sort: "key", + order: "asc", }); if (workspaceId !== undefined) { params.set("workspace_id", String(workspaceId)); @@ -160,11 +162,38 @@ export class WindshiftApiClient { } } + async fetchItemChanges( + workspaceId: number, + since?: string, + through?: string, + ): Promise { + const params = new URLSearchParams({ + workspace_id: String(workspaceId), + limit: String(CHANGE_PAGE_SIZE), + }); + if (since !== undefined) params.set("since", since); + if (through !== undefined) params.set("through", through); + return this.request( + `/items/changes?${params}`, + ); + } + + async fetchItemsByIds(itemIds: number[]): Promise { + if (itemIds.length === 0) return []; + const params = new URLSearchParams({ ids: itemIds.join(",") }); + const items = await this.request( + `/items/batch?${params}`, + ); + return items.map(mapItem); + } + async fetchItemComments(itemId: number): Promise { - const data = await this.request( - `/items/${itemId}/comments`, + const response = await this.request< + WindshiftPaginatedResponse + >( + `/items/${itemId}/comments?page=1&limit=${MAX_COMMENTS_PER_ITEM}&order=desc`, ); - return data.slice(0, MAX_COMMENTS_PER_ITEM).map(mapComment); + return response.data.map(mapComment); } async getItem(itemId: number): Promise { diff --git a/connectors/windshift/src/connector.test.ts b/connectors/windshift/src/connector.test.ts index aeab9963a..662efdd62 100644 --- a/connectors/windshift/src/connector.test.ts +++ b/connectors/windshift/src/connector.test.ts @@ -57,7 +57,7 @@ test("builds MCP authorization from sync and action credential shapes", () => { assert.deepEqual(connector.prepareMcpHeaders({}), {}); }); -test("full sync ignores an existing incremental checkpoint", async () => { +test("full sync checkpoints only after indexing visible items", async () => { const previousPublicUrl = process.env.WINDSHIFT_BASE_URL; const previousInternalUrl = process.env.WINDSHIFT_INTERNAL_BASE_URL; const previousFetch = globalThis.fetch; @@ -65,10 +65,10 @@ test("full sync ignores an existing incremental checkpoint", async () => { process.env.WINDSHIFT_INTERNAL_BASE_URL = "http://windshift:8080"; let emitted = 0; - let updated = 0; let scanned = 0; let completed = false; let emittedPermissions: unknown; + let completedState: unknown; globalThis.fetch = async (input) => { const url = String(input); @@ -78,6 +78,15 @@ test("full sync ignores an existing incremental checkpoint", async () => { pagination: { page: 1, total_pages: 1, has_more: false }, }); } + if (url.includes("/items/changes?")) { + return Response.json({ + changes: [], + next_cursor: "5", + watermark: "5", + has_more: false, + reset_required: false, + }); + } if (url.includes("/items?")) { return Response.json({ data: [ @@ -95,7 +104,9 @@ test("full sync ignores an existing incremental checkpoint", async () => { pagination: { page: 1, total_pages: 1, has_more: false }, }); } - if (url.includes("/items/2/comments")) return Response.json([]); + if (url.includes("/items/2/comments")) { + return Response.json({ data: [] }); + } throw new Error(`Unexpected request: ${url}`); }; @@ -110,12 +121,9 @@ test("full sync ignores an existing incremental checkpoint", async () => { emitted++; emittedPermissions = document.permissions; }, - emitUpdated: async () => { - updated++; - }, - saveState: async () => {}, - complete: async () => { + complete: async (state: unknown) => { completed = true; + completedState = state; }, fail: async (message: string) => { throw new Error(message); @@ -126,29 +134,236 @@ test("full sync ignores an existing incremental checkpoint", async () => { return scanned; }, get documentsEmitted() { - return emitted + updated; + return emitted; }, } as unknown as SyncContext; try { const connector = new WindshiftConnector(); + assert.deepEqual(connector.syncModes, ["full", "incremental"]); connector.bootstrapMcp = async () => {}; - await connector.sync( - {}, - { access_token: "token" }, - { last_sync_at: "2026-07-01T00:00:00.000Z" }, - ctx, - ); + await connector.sync({}, { access_token: "token" }, null, ctx); assert.equal(scanned, 1); assert.equal(emitted, 1); - assert.equal(updated, 0); assert.equal(completed, true); + assert.deepEqual(completedState, { + workspace_cursors: { "1": "5" }, + }); assert.deepEqual(emittedPermissions, { public: false, users: ["owner@example.com"], groups: [], }); + + completed = false; + let failedMessage = ""; + await connector.sync( + {}, + { access_token: "token" }, + null, + { + ...ctx, + contentStorage: { + save: async () => { + throw new Error("storage unavailable"); + }, + }, + complete: async () => { + completed = true; + }, + fail: async (message: string) => { + failedMessage = message; + }, + } as unknown as SyncContext, + ); + assert.match(failedMessage, /storage unavailable/); + assert.equal(completed, false); + } finally { + globalThis.fetch = previousFetch; + if (previousPublicUrl === undefined) delete process.env.WINDSHIFT_BASE_URL; + else process.env.WINDSHIFT_BASE_URL = previousPublicUrl; + if (previousInternalUrl === undefined) + delete process.env.WINDSHIFT_INTERNAL_BASE_URL; + else process.env.WINDSHIFT_INTERNAL_BASE_URL = previousInternalUrl; + } +}); + +test("incremental sync consumes change pages and emits deletions", async () => { + const previousPublicUrl = process.env.WINDSHIFT_BASE_URL; + const previousInternalUrl = process.env.WINDSHIFT_INTERNAL_BASE_URL; + const previousFetch = globalThis.fetch; + process.env.WINDSHIFT_BASE_URL = "http://localhost:5111"; + process.env.WINDSHIFT_INTERNAL_BASE_URL = "http://windshift:8080"; + + const requestedPaths: string[] = []; + let changePage = 0; + let failureMode = false; + globalThis.fetch = async (input) => { + const url = new URL(String(input)); + requestedPaths.push(`${url.pathname}?${url.searchParams}`); + if (url.pathname.endsWith("/workspaces")) { + return Response.json({ + data: [{ id: 1, key: "W1", name: "Workspace 1" }], + pagination: { page: 1, total_pages: 1, has_more: false }, + }); + } + if (url.pathname.endsWith("/items/changes")) { + if (failureMode) { + return Response.json({ + changes: [{ item_id: 9, change_type: "upsert" }], + next_cursor: "13", + watermark: "13", + has_more: false, + reset_required: false, + }); + } + changePage++; + if (changePage === 1) { + assert.equal(url.searchParams.get("since"), "10"); + assert.equal(url.searchParams.has("through"), false); + return Response.json({ + changes: [{ item_id: 7, change_type: "upsert" }], + next_cursor: "11", + watermark: "12", + has_more: true, + reset_required: false, + }); + } + assert.equal(url.searchParams.get("since"), "11"); + assert.equal(url.searchParams.get("through"), "12"); + return Response.json({ + changes: [{ item_id: 8, change_type: "delete" }], + next_cursor: "12", + watermark: "12", + has_more: false, + reset_required: false, + }); + } + if (url.pathname.endsWith("/items/batch")) { + if (failureMode) { + return Response.json([ + { + id: 9, + workspace_id: 1, + workspace_key: "W1", + workspace_item_number: 9, + title: "Broken item", + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-02T00:00:00.000Z", + }, + ]); + } + assert.equal(url.searchParams.get("ids"), "7"); + return Response.json([ + { + id: 7, + workspace_id: 1, + workspace_key: "W1", + workspace_item_number: 7, + title: "Changed item", + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-02T00:00:00.000Z", + }, + ]); + } + if (url.pathname.endsWith("/items/7/comments")) { + return Response.json({ data: [] }); + } + if (url.pathname.endsWith("/items/9/comments")) { + return Response.json({ data: [] }); + } + throw new Error(`Unexpected request: ${url}`); + }; + + let updated = 0; + const deleted: string[] = []; + const savedStates: unknown[] = []; + let completedState: unknown; + const ctx = { + syncMode: SyncMode.INCREMENTAL, + isCancelled: () => false, + incrementScanned: async () => {}, + contentStorage: { save: async () => "content-7" }, + emitUpdated: async () => { + updated++; + }, + emitDeleted: async (externalId: string) => { + deleted.push(externalId); + }, + saveState: async (state: unknown) => { + savedStates.push(structuredClone(state)); + }, + complete: async (state: unknown) => { + completedState = state; + }, + fail: async (message: string) => { + throw new Error(message); + }, + emitError: () => {}, + getUserEmailForSource: async () => "owner@example.com", + get documentsScanned() { + return 1; + }, + get documentsEmitted() { + return updated; + }, + } as unknown as SyncContext; + + try { + const connector = new WindshiftConnector(); + connector.bootstrapMcp = async () => {}; + await connector.sync( + {}, + { access_token: "token" }, + { workspace_cursors: { "1": "10" } }, + ctx, + ); + + assert.equal(updated, 1); + assert.deepEqual(deleted, ["windshift:item:8"]); + assert.deepEqual(savedStates, [ + { workspace_cursors: { "1": "11" } }, + { workspace_cursors: { "1": "12" } }, + ]); + assert.deepEqual(completedState, { + workspace_cursors: { "1": "12" }, + }); + assert.equal( + requestedPaths.some((path) => path.startsWith("/rest/api/v1/items?")), + false, + ); + + failureMode = true; + let failedMessage = ""; + let savedAfterFailure = false; + let completedAfterFailure = false; + const failedCtx = { + ...ctx, + contentStorage: { + save: async () => { + throw new Error("storage unavailable"); + }, + }, + saveState: async () => { + savedAfterFailure = true; + }, + complete: async () => { + completedAfterFailure = true; + }, + fail: async (message: string) => { + failedMessage = message; + }, + } as unknown as SyncContext; + await connector.sync( + {}, + { access_token: "token" }, + { workspace_cursors: { "1": "12" } }, + failedCtx, + ); + assert.match(failedMessage, /storage unavailable/); + assert.equal(savedAfterFailure, false); + assert.equal(completedAfterFailure, false); } finally { globalThis.fetch = previousFetch; if (previousPublicUrl === undefined) delete process.env.WINDSHIFT_BASE_URL; diff --git a/connectors/windshift/src/connector.ts b/connectors/windshift/src/connector.ts index 510157733..c67ac8939 100644 --- a/connectors/windshift/src/connector.ts +++ b/connectors/windshift/src/connector.ts @@ -11,12 +11,12 @@ import { WindshiftApiClient } from "./client.js"; import { generateItemContent, mapItemToDocument } from "./mappers.js"; import type { WindshiftCredentials, + WindshiftItem, WindshiftSourceConfig, WindshiftSyncState, } from "./types.js"; const logger = getLogger("windshift"); -const CHECKPOINT_INTERVAL = 100; const READ_SCOPES = [ "mcp:access", @@ -149,7 +149,7 @@ export class WindshiftConnector extends Connector< }; } - // Windshift 0.8.3 exposes a public-client DCR endpoint. Omni registers + // Windshift exposes a public-client DCR endpoint. Omni registers // itself automatically, uses S256 PKCE, and binds every issued token to // this exact MCP resource. No administrator-managed client secret is needed. override get oauthConfig() { @@ -214,15 +214,7 @@ export class WindshiftConnector extends Connector< // SDK and do not block document sync. await this.bootstrapMcp(credentials); - const client = new WindshiftApiClient( - transportBaseUrl, - accessToken, - ); - - const isIncremental = ctx.syncMode === SyncMode.INCREMENTAL; - const lastSyncAt = isIncremental ? state?.last_sync_at : undefined; - const cutoff = lastSyncAt ? new Date(lastSyncAt).getTime() : null; - let docsSinceCheckpoint = 0; + const client = new WindshiftApiClient(transportBaseUrl, accessToken); let allWorkspaces; try { @@ -243,6 +235,12 @@ export class WindshiftConnector extends Connector< workspaceFilter.includes(w.key), ) : allWorkspaces; + const incremental = ctx.syncMode === SyncMode.INCREMENTAL; + const nextState: WindshiftSyncState = { + workspace_cursors: incremental + ? { ...(state?.workspace_cursors ?? {}) } + : {}, + }; for (const workspace of workspaces) { if (ctx.isCancelled()) { @@ -253,60 +251,37 @@ export class WindshiftConnector extends Connector< `Syncing items for workspace: ${workspace.name} (${workspace.key})`, ); - let stoppedEarly = false; - for await (const item of client.fetchItems(workspace.id)) { - if (ctx.isCancelled()) { - await ctx.fail("Cancelled by user"); - return; - } - - // Server has no updated_since filter — sort=updated_at&order=desc lets us stop when - // we cross the cutoff. See plan: a Windshift-side updated_since param is the long-term fix. - if (cutoff !== null && new Date(item.updated_at).getTime() < cutoff) { - stoppedEarly = true; - break; - } - - await ctx.incrementScanned(); - try { - const comments = await client.fetchItemComments(item.id); - const content = generateItemContent(item, comments); - const contentId = await ctx.contentStorage.save( - content, - "text/markdown", - ); - const doc = mapItemToDocument( - item, - comments, - contentId, - publicBaseUrl, - sourceOwnerEmail, - ); - if (isIncremental) { - await ctx.emitUpdated(doc); - } else { - await ctx.emit(doc); - } - docsSinceCheckpoint++; - if (docsSinceCheckpoint >= CHECKPOINT_INTERVAL) { - await ctx.saveState({ last_sync_at: new Date().toISOString() }); - docsSinceCheckpoint = 0; - } - } catch (e) { - const eid = `windshift:item:${item.id}`; - logger.warn(`Error processing ${eid}: ${e}`); - ctx.emitError(eid, String(e)); - } - } - - if (stoppedEarly) { - logger.info( - `Reached incremental cutoff for workspace ${workspace.key}, moving on`, + const workspaceKey = String(workspace.id); + const cursor = nextState.workspace_cursors[workspaceKey]; + if (incremental && cursor !== undefined) { + await this.syncWorkspaceChanges( + client, + workspace.id, + cursor, + nextState, + publicBaseUrl, + sourceOwnerEmail, + ctx, ); + continue; } + + // Capture the watermark before crawling. Changes committed while the + // crawl is running remain above it and are replayed incrementally. + const primed = await client.fetchItemChanges(workspace.id); + await this.syncWorkspaceFull( + client, + workspace.id, + incremental, + publicBaseUrl, + sourceOwnerEmail, + ctx, + ); + nextState.workspace_cursors[workspaceKey] = primed.watermark; + if (incremental) await ctx.saveState(nextState); } - await ctx.complete({ last_sync_at: new Date().toISOString() }); + await ctx.complete(nextState); logger.info( `Sync completed: ${ctx.documentsScanned} scanned, ${ctx.documentsEmitted} emitted`, ); @@ -315,4 +290,121 @@ export class WindshiftConnector extends Connector< await ctx.fail(String(e)); } } + + private async syncWorkspaceFull( + client: WindshiftApiClient, + workspaceId: number, + emitAsUpdate: boolean, + publicBaseUrl: string, + sourceOwnerEmail: string, + ctx: SyncContext, + ): Promise { + for await (const item of client.fetchItems(workspaceId)) { + if (ctx.isCancelled()) throw new Error("Cancelled by user"); + try { + await this.emitItem( + client, + item, + emitAsUpdate, + publicBaseUrl, + sourceOwnerEmail, + ctx, + ); + } catch (e) { + const externalId = `windshift:item:${item.id}`; + logger.warn(`Error processing ${externalId}: ${e}`); + ctx.emitError(externalId, String(e)); + throw e; + } + } + } + + private async syncWorkspaceChanges( + client: WindshiftApiClient, + workspaceId: number, + initialCursor: string, + state: WindshiftSyncState, + publicBaseUrl: string, + sourceOwnerEmail: string, + ctx: SyncContext, + ): Promise { + let cursor = initialCursor; + let through: string | undefined; + while (true) { + if (ctx.isCancelled()) throw new Error("Cancelled by user"); + const page = await client.fetchItemChanges(workspaceId, cursor, through); + if (page.reset_required) { + throw new Error( + `Windshift change cursor for workspace ${workspaceId} is no longer valid; run a full sync`, + ); + } + through ??= page.watermark; + + const latestChanges = new Map(); + for (const change of page.changes) { + latestChanges.set(change.item_id, change.change_type); + } + const upsertIds = [...latestChanges] + .filter(([, changeType]) => changeType === "upsert") + .map(([itemId]) => itemId); + const items = await client.fetchItemsByIds(upsertIds); + const itemsById = new Map(items.map((item) => [item.id, item])); + + for (const [itemId, changeType] of latestChanges) { + if (ctx.isCancelled()) throw new Error("Cancelled by user"); + if (changeType === "delete") { + await ctx.emitDeleted(`windshift:item:${itemId}`); + continue; + } + const item = itemsById.get(itemId); + if (!item) { + // The item was deleted or became invisible after the change event. + await ctx.emitDeleted(`windshift:item:${itemId}`); + continue; + } + try { + await this.emitItem( + client, + item, + true, + publicBaseUrl, + sourceOwnerEmail, + ctx, + ); + } catch (e) { + const externalId = `windshift:item:${item.id}`; + ctx.emitError(externalId, String(e)); + throw new Error(`Failed to process ${externalId}: ${e}`); + } + } + + cursor = page.next_cursor; + state.workspace_cursors[String(workspaceId)] = cursor; + await ctx.saveState(state); + if (!page.has_more) return; + } + } + + private async emitItem( + client: WindshiftApiClient, + item: WindshiftItem, + update: boolean, + publicBaseUrl: string, + sourceOwnerEmail: string, + ctx: SyncContext, + ): Promise { + await ctx.incrementScanned(); + const comments = await client.fetchItemComments(item.id); + const content = generateItemContent(item, comments); + const contentId = await ctx.contentStorage.save(content, "text/markdown"); + const doc = mapItemToDocument( + item, + comments, + contentId, + publicBaseUrl, + sourceOwnerEmail, + ); + if (update) await ctx.emitUpdated(doc); + else await ctx.emit(doc); + } } diff --git a/connectors/windshift/src/types.ts b/connectors/windshift/src/types.ts index 2994305a4..2f3617d76 100644 --- a/connectors/windshift/src/types.ts +++ b/connectors/windshift/src/types.ts @@ -1,11 +1,11 @@ -export type WindshiftSyncState = { - last_sync_at: string; -}; - export type WindshiftSourceConfig = { workspace_keys?: string[]; }; +export type WindshiftSyncState = { + workspace_cursors: Record; +}; + // Credentials are written by Omni's generic OAuth dispatcher // (`web/src/lib/server/oauth/connectorOAuth.ts`) after the user completes // the authorization-code flow against Windshift's `/oauth/authorize` + @@ -82,6 +82,19 @@ export type WindshiftPaginatedResponse = { }; }; +export type WindshiftItemChange = { + item_id: number; + change_type: "upsert" | "delete"; +}; + +export type WindshiftItemChangesResponse = { + changes: WindshiftItemChange[]; + next_cursor: string; + watermark: string; + has_more: boolean; + reset_required: boolean; +}; + export type WindshiftWorkspace = { id: number; key: string;