diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5de18ba --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# Normalize line endings to LF in the repository for all text files. +# Prevents CRLF churn across Windows/macOS/Linux contributors. +* text=auto eol=lf + +# Explicitly binary (no EOL normalization) for common binary assets. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..b2d2bed --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# +* @microsoft/sharepoint-embedded diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a1d1727 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: deps + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: ci diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a1bfe9e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + - feat/** + +permissions: + contents: read + +jobs: + build-test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: + - 22.x + - 24.x + - 26.x + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + - run: npm ci + - run: npm run lint + - run: npm run typecheck + - run: npm run build + - run: npm test diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..a1df61e --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,45 @@ +name: Security + +on: + pull_request: + push: + branches: + - main + schedule: + - cron: '0 12 * * 1' + +permissions: + contents: read + +jobs: + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24.x + cache: npm + - run: npm ci + - run: npm audit --audit-level=high + + secrets: + runs-on: ubuntu-latest + env: + GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + # gitleaks-action@v2 requires a GITLEAKS_LICENSE when run under a GitHub + # organization (free only for personal accounts). Until the owner + # provisions the secret, this step is skipped so the workflow stays green; + # it is also continue-on-error as a belt-and-suspenders. Owner action: + # add GITLEAKS_LICENSE (or switch to GitHub Advanced Security secret + # scanning, which is available org-wide) — see SECURITY.md. + - name: Scan for secrets (gitleaks) + if: ${{ env.GITLEAKS_LICENSE != '' }} + continue-on-error: true + uses: gitleaks/gitleaks-action@v2 + env: + GITLEAKS_ENABLE_COMMENTS: "false" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23b41a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +coverage/ +*.tgz + +# Sample app build outputs (the sample SOURCES under samples/ are committed) +samples/**/bin/ +samples/**/obj/ +samples/**/.publish/ diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 0000000..ada670f --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,15 @@ +{ + // SPE MCP server for this repo. Bootstrap mode uses your Azure CLI sign-in, + // so no Entra app is required: + // 1. az login --allow-no-subscriptions + // 2. npm run build + // 3. Click the 'Start' CodeLens above the "spe" entry below. + // Then ask Copilot Chat things like "List my SPE container types". + "servers": { + "spe": { + "type": "stdio", + "command": "node", + "args": ["${workspaceFolder:SharePoint-Embedded-MCP-Server}/dist/cli.js", "start"] + } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ec93124 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +All notable changes to this project will be documented in this file. The format +is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this +project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.2.0-alpha.1] + +### Added + +- **Per-instance data directory.** New `--data-dir ` flag and `SPE_DATA_DIR` + environment variable select where the provisioning `state.json` and MSAL token + cache are stored (precedence: flag > env > default `~/.spe-mcp`). Point each + server instance at a unique directory to run multiple instances (e.g. two + tenants, or a published build alongside a local build) without clobbering + shared state. Applies uniformly to `start`, `auth`, and `logout`. The default + path is unchanged and byte-identical to prior releases. + +### Security + +- **Fail-closed credential/state file handling.** The data directory and token + cache files are now validated fail-closed: a symlinked, foreign-owned, or + group/other-accessible directory is refused (POSIX `0o700`); an off-`%USERPROFILE%` + Windows override is given an owner-only DACL or refused. Reads and writes use + `O_NOFOLLOW` + `fstat` fd verification and `fchmod` the descriptor (never the + path) to defeat symlink/TOCTOU swaps. A caller-supplied `--data-dir` must be an + absolute (or `~/`-relative) path; CWD-relative paths are rejected so credentials + can never be written into a working directory. On an insecure/unverifiable + target, refresh-token persistence is skipped (forcing a fresh interactive + sign-in) rather than writing a token to an unsafe location. + +## [0.1.0] + +Initial release. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..686e5e7 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,10 @@ +# Microsoft Open Source Code of Conduct + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). + +Resources: + +- [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) +- [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) +- Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns +- Employees can reach out at [aka.ms/opensource/moderation-support](https://aka.ms/opensource/moderation-support) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ea1b6c0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,44 @@ +# Contributing to SharePoint Embedded MCP Server + +Thank you for your interest in contributing! This project welcomes contributions and +suggestions. + +## Contributor License Agreement + +Most contributions require you to agree to a Contributor License Agreement (CLA) +declaring that you have the right to, and actually do, grant us the rights to use your +contribution. For details, visit . + +When you submit a pull request, a CLA bot will automatically determine whether you need +to provide a CLA and decorate the PR appropriately (e.g., status check, comment). Simply +follow the instructions provided by the bot. You will only need to do this once across +all repositories using our CLA. + +## How to contribute + +1. Fork the repository and create your branch from `main`. +2. Install dependencies: `npm install`. +3. Make your change. Add or update tests alongside the code (`src/**/*.test.ts`). +4. Validate locally: `npm run ci` (typecheck + test + build). +5. Open a pull request describing the change and its motivation. + +## Code style + +- TypeScript, ES modules, strict mode. Run `npm run lint` and `npm run typecheck` + before opening a PR. +- Each tool is a `{ name, description, inputSchema, handler }` export — see + "Adding New Tools" in the [README](README.md). + +## Code of Conduct + +This project has adopted the +[Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +For more information see the +[Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact +[opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or +comments. + +## Reporting security issues + +Please report security issues privately as described in [SECURITY.md](SECURITY.md). Do +not file public GitHub issues for security vulnerabilities. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f70610b --- /dev/null +++ b/LICENSE @@ -0,0 +1,23 @@ +MIT License + +SharePoint Embedded MCP Server + +Copyright (c) Microsoft Corporation. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..d3624c6 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,57 @@ +# Privacy + +`@microsoft/spe-mcp` ("the tool") is an open-source Model Context Protocol (MCP) +server that you run **locally** to manage **your own** SharePoint Embedded, Microsoft Graph, +and Azure resources. This notice explains what the tool does and does not do with data. It is +provided for transparency and does not replace the +[Microsoft Privacy Statement](https://privacy.microsoft.com/privacystatement) or your +organization's agreements with Microsoft. + +## What the tool collects and sends + +**The tool does not collect telemetry or usage analytics, and it opens no dedicated channel +to send data to Microsoft.** Specifically: + +- **No telemetry channel.** The tool does not implement application telemetry and does not + "phone home." Diagnostic logs are written to the local process's **stderr only**, with + tokens and secrets redacted (`src/logging.ts`), and are never transmitted by the tool. +- **Authentication against your tenant.** You sign in with your own Microsoft Entra identity + via [MSAL](https://learn.microsoft.com/entra/identity-platform/msal-overview). Access and + refresh tokens are cached **locally** with owner-only file permissions (control + **SEC-003**). The tool does not send your tokens anywhere other than the standard Microsoft + authentication and API calls you initiate. +- **API calls you initiate.** When you invoke a tool, the server calls Microsoft first-party + endpoints — Microsoft Graph and Azure Resource Manager — **on your behalf**, in **your** + tenant and subscription. The content and directory data involved flow between your machine + and those Microsoft services; the tool adds no additional recipients. +- **Product `User-Agent`.** Outbound Graph/ARM requests are stamped with a static + `User-Agent` of the form `spe-mcp-server/` (`src/user-agent.ts`). It contains + **no personal, tenant, or usage information** and exists only so the service can measure + aggregate traffic driven by this tool. It is a request header on calls you already make — + not a separate data feed. + +See [docs/DATA-FLOW.md](docs/DATA-FLOW.md) for the full list of network endpoints and what +travels to each. + +## Service-side data handling + +Microsoft Graph, Azure, and SharePoint Embedded are Microsoft Online Services. Any data you +create or access through them is handled under the +[Microsoft Product Terms](https://www.microsoft.com/licensing/terms/), the +[Microsoft Products and Services Data Protection Addendum (DPA)](https://www.microsoft.com/licensing/docs/view/Microsoft-Products-and-Services-Data-Protection-Addendum-DPA), +and the [Microsoft Privacy Statement](https://privacy.microsoft.com/privacystatement), +according to your tenant's configuration (including any **EU Data Boundary** commitments). +This tool does not change that handling. + +## Third-party MCP clients + +You connect the tool to an MCP client (for example VS Code, Claude Desktop, or Cursor). The +prompts you type and the data the client displays are handled under **that client's** privacy +terms, which are outside the control of this project. + +## Turning it off + +Because the tool has no telemetry channel, there is nothing to opt out of. To further limit +outbound calls you can run with `--read-only` (no mutating operations) or `--tools` (restrict +the exposed tool set, including the optional Microsoft Learn documentation lookup). See +[docs/DATA-FLOW.md](docs/DATA-FLOW.md) and [docs/SECURITY-CONTROLS.md](docs/SECURITY-CONTROLS.md). diff --git a/README.md b/README.md new file mode 100644 index 0000000..eb3bc87 --- /dev/null +++ b/README.md @@ -0,0 +1,630 @@ +# SPE MCP Server + +A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server for SharePoint Embedded. Lets any MCP-compatible AI client (VS Code Copilot, Claude Desktop, Cursor, Azure Foundry) manage SPE resources via natural language. + +> ⚠️ **Preview software.** Provisioning SharePoint Embedded resources can incur Azure +> charges, and any connected AI agent can act on your tenant with your credentials. Please +> read the **[Important notices](#important-notices)** before use. + +## Documentation + +- **Get started on Microsoft Learn:** [SharePoint Embedded MCP server](https://learn.microsoft.com/sharepoint/dev/embedded/getting-started/spe-mcp-server) +- **SharePoint Embedded product docs:** +- **In this repo:** [Available Tools](#available-tools) · [Configuration](#configuration) · [Security controls](docs/SECURITY-CONTROLS.md) · [Troubleshooting](docs/TROUBLESHOOTING.md) + +## Available Tools + +The server exposes **40 tools**, plus an MCP **Prompt** (`provision_spe_app`) and **Resources** (reference architectures). + +**Provisioning & status** + +| Tool | Description | +|------|-------------| +| `status_get` | Signed-in identity (Azure CLI) + provisioning readiness | +| `project_app_create` | Create the owning Entra app (via az bootstrap token) | +| `project_provision` | One-call orchestrator: app → container type → (billing) → register → container | +| `container_type_create` / `container_type_register` / `container_create` | Individual provisioning steps | +| `container_type_list` / `container_list` / `container_get` / `container_type_get` | Read operations | +| `container_type_update` / `container_type_delete` | Update or delete a container type | +| `container_type_grant_owner` / `container_type_revoke_owner` / `container_type_owners_list` | Manage container-type owners (beta; enables PCA container creation) | +| `container_type_app_grant_add` / `container_type_app_grant_remove` / `container_type_app_grants_list` | Manage application permission grants on a container type registration (authorize consuming apps; v1.0) | + +**Billing** + +| Tool | Description | +|------|-------------| +| `azure_subscriptions_list` / `azure_resource_groups_list` | Pick where standard billing lands (az) | +| `billing_setup` | Register Microsoft.Syntex RP + link the container type (standard) | +| `billing_check` | Inspect billing classification / trial expiry | + +**Scaffold, run & deploy** + +| Tool | Description | +|------|-------------| +| `project_scaffold` | Materialize a reference architecture (React SPA+Functions, C# web) | +| `project_hydrate_config` | Write `.env` / `appsettings` / `azure.yaml` from provisioning state | +| `project_seed_sample_data` | Seed sample containers + documents (closed loop) | +| `project_run_local` | Start the scaffolded app locally | +| `project_deploy` | Deploy to Azure with `azd up`, return the live URL | + +**Content plane (opt-in) & lifecycle** + +| Tool | Description | +|------|-------------| +| `content_access_grant` / `content_access_revoke` | Opt-in file read/manage consent | +| `content_file_upload` / `content_folder_create` / `content_search` / `content_file_preview` / `content_sharing_manage` / `container_permissions_manage` / `container_archive_restore` / `container_delete` | Container & content operations | +| `project_cleanup` | Delete provisioned CT + owning app (confirm required) | + +**Documentation (grounded via Microsoft Learn MCP)** + +| Tool | Description | +|------|-------------| +| `docs_search` | Search official SPE / Graph docs (proxies the [Microsoft Learn MCP](https://learn.microsoft.com/api/mcp)) | +| `docs_fetch` | Fetch a full Microsoft Learn doc page by URL | + +> The documentation tools require the public **Microsoft Learn MCP** server +> (`https://learn.microsoft.com/api/mcp`, no auth). Override the endpoint with +> `SPE_LEARN_MCP_URL` (used by tests). + +## Install + +Run the published npm package directly from your MCP client with `npx`; no +global install is required. + +### VS Code / Cursor + +Add an MCP server entry to `.vscode/mcp.json` (VS Code) or your Cursor MCP +configuration: + +```json +{ + "servers": { + "spe": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@microsoft/spe-mcp"] + } + } +} +``` + + + +### Claude Desktop + +Add to `%APPDATA%\Claude\claude_desktop_config.json` (Windows) or +`~/Library/Application Support/Claude/claude_desktop_config.json` (macOS): + +```json +{ + "mcpServers": { + "spe": { + "command": "npx", + "args": ["-y", "@microsoft/spe-mcp"] + } + } +} +``` + +> Bootstrap mode needs no app-specific environment variables; sign in once with +> `az login --allow-no-subscriptions`. + +### Updating / removing + +Because clients run the package through `npx`, they pick up published updates +without a global install. Pin a specific version with +`@microsoft/spe-mcp@0.1.0-alpha.1`. To remove the server, delete the MCP +client config entry. + +## Prerequisites + +- **Node.js** 22, 24, or 26 + +### Running modes + +**Bootstrap mode (default, recommended for the standalone POC)** — no Microsoft +app registration required. The server uses your **Azure CLI** session for the +control plane and provisions the owning app on demand. + +- Install the [Azure CLI](https://aka.ms/install-azure-cli) +- Sign in once: `az login --allow-no-subscriptions` (the flag is required for M365-only tenants with no Azure subscription) +- Start the server with **no** `--client-id` + +> **Conditional Access / step-up authentication (standard billing).** Standard-billing +> provisioning performs Azure Resource Manager (ARM) writes — registering the +> `Microsoft.Syntex` resource provider and creating the `Microsoft.Syntex/accounts` +> billing account. If your tenant has a Conditional Access policy that requires a +> step-up (MFA / auth-context) for ARM, `az` can fail with `InteractionRequired` / +> `AADSTS50076` / a **claims challenge**. The MCP server detects this and surfaces an +> actionable error. To satisfy the policy, re-authenticate **interactively in your own +> terminal**, then retry: +> +> ```bash +> az login --scope https://management.core.windows.net//.default --tenant +> ``` +> +> If interactive browser sign-in still doesn't clear the policy (e.g. an auth-context +> "p1" step-up), complete the step-up via the **SharePoint admin center**, then retry the +> operation. The Azure CLI cannot redeem a claims challenge non-interactively, so the +> server does **not** automate this step (detect + surface + document only). + +**Pre-provisioned-app mode (back-compat)** — pass an existing public-client +Entra app that already has these admin-consented delegated permissions: + +- `FileStorageContainer.Selected` +- `FileStorageContainerType.Manage.All` +- `FileStorageContainerTypeReg.Manage.All` + +> Don't have an app? Create one manually in the [Azure Portal](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade). The app must be a **public client** (`isFallbackPublicClient: true`) with `http://localhost` as a redirect URI. + +## Quick Start (from source) + +```bash +# 1. Install dependencies +npm install + +# 2. Build +npm run build + +# 3a. Bootstrap mode — just sign into Azure CLI (no app needed) +az login --allow-no-subscriptions +npx @modelcontextprotocol/inspector node dist/cli.js start + +# 3b. OR pre-provisioned-app mode — authenticate as an existing app (once) +node dist/cli.js auth --client-id YOUR_CLIENT_ID --tenant-id YOUR_TENANT_ID + +# 4. Test with MCP Inspector +npx @modelcontextprotocol/inspector node dist/cli.js start +``` + +For step 4, set these **Environment Variables** in the Inspector UI: +- `SPE_CLIENT_ID` = your client ID +- `SPE_TENANT_ID` = your tenant ID + +## Configuration + +The server accepts configuration via CLI flags or environment variables: + +| CLI Flag | Env Var | Description | +|----------|---------|-------------| +| `--client-id` | `SPE_CLIENT_ID` | Entra ID Application (Client) ID | +| `--tenant-id` | `SPE_TENANT_ID` | Entra ID Tenant ID | +| `--read-only` | `SPE_READ_ONLY` | Advertise/allow only read/list/get/search tools; reject mutating calls | +| `--tools` | `SPE_TOOLS` | Restrict exposed tools to a profile (`readOnly`, `docsOnly`, `provisioning`, `content`, `admin`) or a comma-separated tool list | +| `--data-dir` | `SPE_DATA_DIR` | Directory for the token cache + provisioning state (default `~/.spe-mcp`). Point each instance at a unique **absolute** path (or `~/...`; CWD-relative paths are rejected) to run multiple servers without clobbering state | + +> The CLI flag wins when both a flag and its env var are set. Run +> `spe-mcp start --help` to see the authoritative option list and descriptions. +> +> The `--read-only` and `--tools` behaviors are part of the server's documented +> security model — see [docs/SECURITY-CONTROLS.md](docs/SECURITY-CONTROLS.md) +> for the full legend of security-control codes used in the source. + +For troubleshooting, see [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md). + +## Usage with VS Code + +Add an MCP server entry to `.vscode/mcp.json` in your workspace: + +```json +{ + "servers": { + "spe": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@microsoft/spe-mcp"], + "env": { + "SPE_CLIENT_ID": "your-client-id", + "SPE_TENANT_ID": "your-tenant-id" + } + } + } +} +``` + +Then in Copilot Chat you can ask: +- *"List my SPE container types"* +- *"Create a trial container type called Contoso Docs for app ID abc-123"* + +To point an MCP client at a local source build instead: + +```json +{ + "servers": { + "spe": { + "type": "stdio", + "command": "node", + "args": ["\\mcp-server\\dist\\cli.js", "start"], + "env": { + "SPE_CLIENT_ID": "your-client-id", + "SPE_TENANT_ID": "your-tenant-id" + } + } + } +} +``` + +> **`npx -y`** suppresses the install prompt so VS Code can launch the server +> non-interactively. Bootstrap mode needs no app, so you can drop the `env` block +> and just `az login --allow-no-subscriptions`. + +## Usage with Claude Desktop + +Add to `%APPDATA%\Claude\claude_desktop_config.json` (Windows) or `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS): + +```json +{ + "mcpServers": { + "spe": { + "command": "npx", + "args": ["-y", "@microsoft/spe-mcp"], + "env": { + "SPE_CLIENT_ID": "your-client-id", + "SPE_TENANT_ID": "your-tenant-id" + } + } + } +} +``` + +## CLI Commands + +```bash +# Start the MCP server (stdio transport) +spe-mcp start [--client-id ID] [--tenant-id ID] [--read-only] [--tools ] + +# Authenticate interactively (cache tokens for headless use) +spe-mcp auth --client-id ID --tenant-id ID [--reset] + +# Clear cached tokens +spe-mcp logout +``` + +Every command has built-in help — run `spe-mcp --help` (e.g. +`spe-mcp start --help`) for the full flag list and descriptions. `start` flags: + +| Flag | Description | +|------|-------------| +| `--client-id ` | Owning Entra app Client ID. Omit to run in bootstrap mode (Azure CLI control plane). | +| `--tenant-id ` | Entra ID Tenant ID. Discovered from the Azure CLI when omitted. | +| `--read-only` | Read-only mode: only read/list/get/search tools are exposed and callable. | +| `--tools ` | Tool allowlist: a profile (`readOnly`, `docsOnly`, `provisioning`, `content`, `admin`) or a comma-separated list of tool names. | + +## Authentication + +The server uses [MSAL](https://learn.microsoft.com/en-us/entra/identity-platform/msal-overview) with this auth waterfall: + +1. **Silent** — uses a cached token from `~/.spe-mcp/token-cache...json` +2. **Interactive browser** — opens a browser for PKCE sign-in. This runs **in-process by default, even when the server is launched over stdio** by an MCP client — so the first SharePoint Embedded call opens a browser for a one-time consent and caches the token live (no terminal, no restart). +3. **Device code** — prints a URL + code to stderr; used only as a fallback when a terminal (TTY) is attached to see the code. The device code is valid for ~15 minutes (the Azure AD lifetime); the server waits up to that long for you to complete sign-in and **never cancels a code that is still valid**. + +For most developers nothing extra is needed: create the owning app with the `project_app_create` tool, then the first SPE call prompts a browser consent automatically. + +**Automation / headless:** in CI (`CI=true`) or a Linux host with no display, interactive sign-in is disabled by default, and SPE operations return an actionable error. Pre-cache a token by running `spe-mcp auth --client-id --tenant-id ` once in a terminal. Override the defaults with `SPE_INTERACTIVE=1` (force browser sign-in) or `SPE_NON_INTERACTIVE=1` (force off). + +### Headless & orchestrator / sub-agent sign-in + +Interactive sign-in is **enabled by default for local use** (the server can open a browser on your machine) and **disabled by default in obvious automation/headless environments** — CI (`CI=true`) or Linux with no `DISPLAY`/`WAYLAND_DISPLAY` — so a tool call never silently blocks on a browser that can't open. The defaults are only defaults; explicit overrides always win: + +| Variable | Effect | +| --- | --- | +| `SPE_INTERACTIVE=1` | Force interactive sign-in **on** (browser + device-code fallback), even when the environment looks headless. | +| `SPE_NON_INTERACTIVE=1` | Force interactive sign-in **off**; SPE calls fail fast with an actionable error instead of prompting. | + +**Why interactive is supported (and on by default) locally.** A developer building an SPE app benefits from a one-time browser consent: it caches a token live on the first SPE call — no separate terminal step, no restart. Automation gets the opposite default (off) because there is no human to complete a browser flow. + +**Orchestrator / sub-agent / agent-team scenarios.** When the MCP server runs over stdio and is driven by a *calling* agent (an orchestrator spawning sub-agents), the sub-agent's terminal is usually **not visible** to the caller. The device-code prompt is printed to **stderr**, which the calling agent typically cannot see — so a device-code wait would block invisibly. To avoid that, the server only offers device code when its stderr prompt is on a real **TTY**; otherwise it **fails fast** with actionable guidance rather than hanging. Recommended pattern for headless/agent setups: + +1. **Pre-authenticate before starting the server.** For the bootstrap / control-plane token, run `az login` (`--allow-no-subscriptions` for M365-only tenants). For the owning-app token, sign in once interactively in a **visible** terminal: `spe-mcp auth --client-id --tenant-id `. +2. **Restart the server after signing in** so it re-primes auth from the freshly cached token (startup auth is stamped for the session), then let the agent drive tool calls. + +This keeps sub-agents non-blocking: they either use a pre-cached token silently or return a clear "sign in first" error instead of stalling on an invisible prompt. + + +### Token Storage + +Tokens are cached under the **data directory** (default `~/.spe-mcp/`, or a `--data-dir` / `SPE_DATA_DIR` override) in per-identity files named `token-cache...json` (a legacy `token-cache.json` may also exist). Each file contains MSAL's serialized token cache (refresh tokens, account info). On macOS/Linux the cache directory is created `0700` and the cache files `0600` (owner read/write only), and the server fails closed if the directory is a symlink, owned by another user, or group/other-accessible; on Windows the files are protected by the per-user profile ACL (an off-profile `--data-dir` override is given an owner-only DACL, or refused). + +### Running multiple instances (isolating state) + +The data directory holds a single provisioning `state.json` plus the token cache, so two servers pointed at the **same** directory can clobber each other's state. To run more than one instance (e.g. two tenants, or a published build alongside a local build), give each its own `--data-dir` / `SPE_DATA_DIR`: + +```jsonc +// .vscode/mcp.json — two isolated instances +{ + "servers": { + "spe-tenantA": { + "command": "npx", + "args": ["-y", "@microsoft/spe-mcp", "start"], + "env": { "SPE_DATA_DIR": "~/.spe-mcp-tenantA", "SPE_TENANT_ID": "" } + }, + "spe-tenantB": { + "command": "npx", + "args": ["-y", "@microsoft/spe-mcp", "start"], + "env": { "SPE_DATA_DIR": "~/.spe-mcp-tenantB", "SPE_TENANT_ID": "" } + } + } +} +``` + +The path must be **absolute** (a leading `~/` is expanded against your home directory); CWD-relative paths are rejected so credentials can never be written into a working directory. The same value must be used for `start`, `auth`, and `logout` of a given instance — set it once via `SPE_DATA_DIR` (as above) and all three commands agree. + +### Full Local Auth Reset + +If you want a completely clean local auth/provisioning state (tokens + Azure CLI session + remembered owning app/tenant), run: + +```powershell +npx spe-mcp logout +az logout +Remove-Item "$HOME/.spe-mcp/state.json" -Force -ErrorAction SilentlyContinue +``` + +`spe-mcp logout` clears MSAL token cache files, while `state.json` stores persisted provisioning metadata used to prime bootstrap auth on startup. + +> **TODO:** Add OS keychain support via [keytar](https://github.com/nicktrav/keytar) as the primary cache, falling back to file cache. Keytar provides OS-managed encryption (Windows Credential Manager / macOS Keychain / Linux Secret Service) but hit data size limits with MSAL's multi-scope cache during initial testing. + +## Architecture + +``` +src/ +├── index.ts — MCP server: TOOLS registry, dispatch, transport, prompts/resources wiring +├── cli.ts — CLI entry point (start, auth, logout) +├── auth.ts — MSAL auth (silent → browser → device code) +├── bootstrap.ts — Azure CLI bootstrap (signed-in identity, az token) +├── azure-cli.ts — az invocations (subscriptions, resource groups, RP registration) +├── graph-client.ts — Microsoft Graph client with retry + auth +├── docs-client.ts — Microsoft Learn MCP proxy (docs_search / docs_fetch) +├── container-retry.ts — Retry helper for registration propagation delays +├── validation.ts — Shared input validation +├── state.ts — Provisioning state persistence +├── prompts.ts — MCP Prompt (provision_spe_app) +├── resources.ts — MCP Resources (reference architectures) +├── reference-architectures.ts — Reference-architecture catalog (reads ../samples/) +├── elicitation.ts — Interactive consent / step-up prompts +├── user-agent.ts — Product User-Agent string (no telemetry channel) +├── types.ts — Shared TypeScript types +└── tools/ — 31 tools across 28 modules (one McpTool per export) + ├── status.ts — status_get + ├── create-app.ts / provision.ts — project_app_create, project_provision + ├── create-container-type.ts / register-container-type.ts / list-container-types.ts + ├── create-container.ts / list-containers.ts / get-container.ts + ├── manage-permissions.ts / archive-restore.ts / delete-container.ts + ├── upload-file.ts / create-folder.ts / search-content.ts / preview-file.ts / manage-sharing.ts + ├── content-access.ts — content_access_grant / content_access_revoke (+ withContentAccess gate) + ├── check-billing.ts / setup-billing.ts / list-azure.ts + ├── scaffold.ts / hydrate-config.ts / seed-sample-data.ts / run-local.ts / deploy-azure.ts + ├── cleanup.ts — project_cleanup + └── search-docs.ts — docs_search / docs_fetch +``` + +> Unit/integration tests live alongside their modules as `*.test.ts` (run with `npm test`). + +Architecture highlights: +- Transport connects before auth (MCP handshake never blocked) +- Auth initializes in background; retries on first tool call if startup auth fails +- Tools are `{ name, description, inputSchema, handler }` — ListTools strips handlers for serialization +- Content-plane tools are wrapped with `withContentAccess(...)` so they stay gated behind the opt-in consent + +## Adding New Tools + +1. Create `src/tools/your-tool.ts`. Name the tool in grouped `snake_case` + (`_`, e.g. `container_get`, `content_file_upload`): + +```typescript +import type { McpTool } from "../types.js"; + +export const yourTool: McpTool = { + name: "container_example_action", + description: "What the tool does", + inputSchema: { + type: "object", + properties: { + param: { type: "string", description: "..." }, + }, + required: ["param"], + }, + handler: async (args) => { + // Call graph-client functions + return { + content: [{ type: "text", text: "result" }], + }; + }, +}; +``` + +2. Add Graph API calls to `src/graph-client.ts` (or `azure-cli.ts` for `az`-backed tools) +3. Import the tool and add it to the `TOOLS` array in `src/index.ts`. If it reads or + writes container content, wrap it with `withContentAccess(...)` so it respects the + content-plane opt-in gate. +4. Rebuild: `npm run build` + +## Testing + +```bash +npm test # vitest unit/integration tests (tool logic, mocked I/O) +npm run lint # eslint +npm run typecheck # tsc --noEmit +npm run ci # typecheck + test + build (what CI runs) +``` + +Vitest runs in watch mode with `npm run test:watch`, which is handy alongside a +debugger (see below). `npm run build:watch` recompiles on save. + +## Debugging + +The server is a **stdio MCP server**: its entry point is `dist/cli.js start` +(the `spe-mcp` bin), and its stdout carries the MCP JSON-RPC stream while all +logs/diagnostics go to stderr. TypeScript is compiled with `sourceMap: true`, so +`.js.map` files are shipped next to the build and breakpoints set in `src/*.ts` +map straight onto the running `dist/*.js`. + +**1. Build first** so the source maps exist: + +```bash +npm run build +``` + +**2. VS Code — launch the server (and tests) under the debugger.** Add a +`.vscode/launch.json`: + +```json +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Debug SPE MCP server", + "program": "${workspaceFolder}/dist/cli.js", + "args": ["start"], + "console": "integratedTerminal", + "sourceMaps": true, + "outFiles": ["${workspaceFolder}/dist/**/*.js"] + }, + { + "type": "node", + "request": "launch", + "name": "Debug vitest", + "program": "${workspaceFolder}/node_modules/vitest/vitest.mjs", + "args": ["run"], + "console": "integratedTerminal", + "sourceMaps": true + } + ] +} +``` + +Set breakpoints in `src/` (e.g. a tool handler, `dispatch` in `index.ts`, or the +`catch` in `startServer`), then press **F5**. The "Debug SPE MCP server" config +starts a bootstrap-mode session (sign in first with +`az login --allow-no-subscriptions`); pass `--client-id`/`--tenant-id` in `args` +for pre-provisioned-app mode. + +**3. Attach with `--inspect` (CLI, Chrome DevTools, or when an MCP client spawns +the server).** Break on the first line so you can attach before startup runs: + +```bash +node --inspect-brk dist/cli.js start +``` + +Then attach from VS Code (**Attach to Node Process**) or open `chrome://inspect`. +Because logs are on stderr, the inspector banner and server logs never corrupt +the JSON-RPC stream on stdout. You can also exercise the server interactively +under the debugger with the MCP Inspector: + +```bash +npx @modelcontextprotocol/inspector node dist/cli.js start +``` + +When a failure surfaces a `correlationId`, grep the server's stderr for that id +to find the matching `Tool error ()` log line — see +[docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md#correlation-ids). + +## Contributing + +This project welcomes contributions and suggestions. See +[CONTRIBUTING.md](CONTRIBUTING.md) for details. Most contributions require you to agree +to a Contributor License Agreement (CLA); for details visit +. + +This project has adopted the +[Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +For more information see the +[Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact +[opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or +comments. + +## Security + +Microsoft takes security seriously. If you believe you have found a security +vulnerability, please report it privately as described in [SECURITY.md](SECURITY.md) — +**do not** file a public GitHub issue. + + +## Important notices + +> **Preview software.** `@microsoft/spe-mcp` is an early (alpha) preview released for +> evaluation and feedback. It is provided **"as is"**, without warranty of any kind; see the +> [MIT License](LICENSE). Tool names, options, and behavior may change without notice. + +### Autonomous and agent-invoked operations + +This server exposes tools that **create, modify, and delete real resources** in your +Microsoft Entra tenant and Azure subscription — for example app registrations, SharePoint +Embedded container types, containers, and their content. Any connected MCP client, +**including autonomous AI agents**, can invoke these tools on your behalf, and you are +responsible for the actions taken with your credentials. To stay in control: + +- **Review each action.** State-changing tools require an explicit confirmation + (`confirm: true`) before they run — the destructive-operation confirmation gate + (**SAFE-002**). +- **Explore read-only.** Start the server with `--read-only` (or `SPE_READ_ONLY`) to + advertise and allow only read-only tools (**SAFE-003**). +- **Limit the surface.** Use the `--tools` allowlist / profiles (or `SPE_TOOLS`) to expose + only the tools you need (**SAFE-004**). + +See [docs/SECURITY-CONTROLS.md](docs/SECURITY-CONTROLS.md) for the full list of safeguards. + +### Cost and billing + +SharePoint Embedded is a **metered, billable** service (standard billing is registered +through the `Microsoft.Syntex` resource provider in your Azure subscription). Provisioning +or using SPE resources with this tool **may incur charges** on the subscription you connect. +A free **trial** container type is available for evaluation. You are responsible for any +charges incurred in your tenant and subscription. + +### Data, privacy, and telemetry + +The server runs **locally** and talks to your MCP client over stdio. It authenticates **to +your own tenant** and calls Microsoft first-party endpoints — Microsoft Graph and Azure +Resource Manager — **on your behalf**; the content and directory data involved flow only +between your machine, your MCP client, and those Microsoft services in your own +tenant/subscription. + +The server opens **no separate telemetry channel** and sends **no usage analytics** to +Microsoft. Outbound Graph/ARM requests carry a **static product `User-Agent`** +(`spe-mcp-server/`) that contains **no personal, tenant, or usage data** and is +used only for aggregate traffic attribution. Authentication tokens are cached locally with +owner-only permissions (**SEC-003**). For details see [PRIVACY.md](PRIVACY.md) and +[docs/DATA-FLOW.md](docs/DATA-FLOW.md); Microsoft's handling of data you send to its online +services is described in the +[Microsoft Privacy Statement](https://privacy.microsoft.com/privacystatement). + +### Data residency and EU Data Boundary + +This tool performs **no independent cross-region processing** and stores no customer content +of its own. Because it calls your own tenant's Microsoft Graph and Azure endpoints, data +location, residency, and **EU Data Boundary (EUDB)** commitments follow the underlying +Microsoft Online Services and your tenant configuration — not this tool. The only additional +endpoint is the read-only, public [Microsoft Learn MCP](https://learn.microsoft.com/api/mcp) +documentation service (no authentication, no customer data; host-validated per **SEC-007**), +which can be disabled with `--tools`. All outbound calls target Microsoft-operated services; +the server contacts **no non-Microsoft services**. + +### Product Terms + +SharePoint Embedded, Microsoft Graph, and other Microsoft Online Services accessed through +this tool are **licensed separately**, and their use is governed by the agreement under which +you obtained them — including the +[Microsoft Product Terms](https://www.microsoft.com/licensing/terms/) and the +[Microsoft Products and Services Data Protection Addendum (DPA)](https://www.microsoft.com/licensing/docs/view/Microsoft-Products-and-Services-Data-Protection-Addendum-DPA). +This open-source tool grants no rights to any Microsoft Online Service and does not modify +those terms. + +## Trademarks + +This project may contain trademarks or logos for projects, products, or services. +Authorized use of Microsoft trademarks or logos is subject to and must follow +[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/legal/intellectualproperty/trademarks/usage/general). +Use of Microsoft trademarks or logos in modified versions of this project must not cause +confusion or imply Microsoft sponsorship. Any use of third-party trademarks or logos is +subject to those third-parties' policies. + +## License + +Licensed under the [MIT License](LICENSE). diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..b06dfa1 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,16 @@ +# Support + +## How to file issues and get help + +This project uses GitHub Issues to track bugs and feature requests. Please search the +existing [issues](https://github.com/microsoft/SharePoint-Embedded-MCP-Server/issues) +before filing new issues to avoid duplicates. For new issues, file your bug or feature +request as a new Issue. + +For help and questions about using this project, file a GitHub Issue in this repository. +Please do **not** report security vulnerabilities through public GitHub issues — follow the +process described in [SECURITY.md](SECURITY.md) instead. + +## Microsoft Support Policy + +Support for the SharePoint Embedded MCP Server is limited to the resources listed above. diff --git a/THIRD-PARTY-NOTICES b/THIRD-PARTY-NOTICES new file mode 100644 index 0000000..9c9b0ca --- /dev/null +++ b/THIRD-PARTY-NOTICES @@ -0,0 +1,3403 @@ +NOTICES AND INFORMATION +Do Not Translate or Localize + +This software incorporates material from third parties. Microsoft makes certain +open source code available at https://3rdpartysource.microsoft.com, or you may +send a check or money order for US $5.00, including the product name, the open +source component name, and version number, to: + + Source Code Compliance Team + Microsoft Corporation + One Microsoft Way + Redmond, WA 98052 + USA + +Notwithstanding any other terms, you may reverse engineer this software to the +extent required to debug changes to any libraries licensed under the GNU Lesser +General Public License. + +This file lists the third-party production dependencies of @microsoft/spe-mcp and their +licenses. It is generated by scripts/generate-third-party-notices.mjs. + +--------------------------------------------------------------- + +1. @azure/msal-common 14.16.1 (MIT) +https://github.com/AzureAD/microsoft-authentication-library-for-js + +MIT License + +Copyright (c) Microsoft Corporation. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE + +--------------------------------------------------------------- + +2. @azure/msal-node 2.16.3 (MIT) +https://github.com/AzureAD/microsoft-authentication-library-for-js + +MIT License + +Copyright (c) 2020 Microsoft + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +3. @hono/node-server 1.19.14 (MIT) +https://github.com/honojs/node-server + +MIT License + +Copyright (c) 2022 - present, Yusuke Wada and Hono contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +4. @modelcontextprotocol/sdk 1.29.0 (MIT) +https://github.com/modelcontextprotocol/typescript-sdk + +MIT License + +Copyright (c) 2024 Anthropic, PBC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +5. accepts 2.0.0 (MIT) +jshttp/accepts + +(The MIT License) + +Copyright (c) 2014 Jonathan Ong +Copyright (c) 2015 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +6. ajv 8.20.0 (MIT) +ajv-validator/ajv + +The MIT License (MIT) + +Copyright (c) 2015-2021 Evgeny Poberezkin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +7. ajv-formats 3.0.1 (MIT) +https://github.com/ajv-validator/ajv-formats + +MIT License + +Copyright (c) 2020 Evgeny Poberezkin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +8. body-parser 2.3.0 (MIT) +expressjs/body-parser + +(The MIT License) + +Copyright (c) 2014 Jonathan Ong +Copyright (c) 2014-2015 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +9. buffer-equal-constant-time 1.0.1 (BSD-3-Clause) +git@github.com:goinstant/buffer-equal-constant-time.git + +Copyright (c) 2013, GoInstant Inc., a salesforce.com company +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +* Neither the name of salesforce.com, nor GoInstant, nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--------------------------------------------------------------- + +10. bundle-name 4.1.0 (MIT) +sindresorhus/bundle-name + +MIT License + +Copyright (c) Sindre Sorhus (https://sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +11. bytes 3.1.2 (MIT) +visionmedia/bytes.js + +(The MIT License) + +Copyright (c) 2012-2014 TJ Holowaychuk +Copyright (c) 2015 Jed Watson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +12. call-bind-apply-helpers 1.0.2 (MIT) +https://github.com/ljharb/call-bind-apply-helpers + +MIT License + +Copyright (c) 2024 Jordan Harband + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +13. call-bound 1.0.4 (MIT) +https://github.com/ljharb/call-bound + +MIT License + +Copyright (c) 2024 Jordan Harband + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +14. commander 12.1.0 (MIT) +https://github.com/tj/commander.js + +(The MIT License) + +Copyright (c) 2011 TJ Holowaychuk + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +15. content-disposition 1.1.0 (MIT) +jshttp/content-disposition + +(The MIT License) + +Copyright (c) 2014-2017 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +16. content-type 1.0.5 (MIT) +jshttp/content-type + +(The MIT License) + +Copyright (c) 2015 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +17. content-type 2.0.0 (MIT) +jshttp/content-type + +(The MIT License) + +Copyright (c) 2015 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +18. cookie 0.7.2 (MIT) +jshttp/cookie + +(The MIT License) + +Copyright (c) 2012-2014 Roman Shtylman +Copyright (c) 2015 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +19. cookie-signature 1.2.2 (MIT) +https://github.com/visionmedia/node-cookie-signature + +(The MIT License) + +Copyright (c) 2012–2024 LearnBoost and other contributors; + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +20. cors 2.8.6 (MIT) +expressjs/cors + +(The MIT License) + +Copyright (c) 2013 Troy Goode + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +21. cross-spawn 7.0.6 (MIT) +git@github.com:moxystudio/node-cross-spawn + +The MIT License (MIT) + +Copyright (c) 2018 Made With MOXY Lda + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +--------------------------------------------------------------- + +22. debug 4.4.3 (MIT) +git://github.com/debug-js/debug + +(The MIT License) + +Copyright (c) 2014-2017 TJ Holowaychuk +Copyright (c) 2018-2021 Josh Junon + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the 'Software'), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +23. default-browser 5.5.0 (MIT) +sindresorhus/default-browser + +MIT License + +Copyright (c) Sindre Sorhus (https://sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +24. default-browser-id 5.0.1 (MIT) +sindresorhus/default-browser-id + +MIT License + +Copyright (c) Sindre Sorhus (https://sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +25. define-lazy-prop 3.0.0 (MIT) +sindresorhus/define-lazy-prop + +MIT License + +Copyright (c) Sindre Sorhus (https://sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +26. depd 2.0.0 (MIT) +dougwilson/nodejs-depd + +(The MIT License) + +Copyright (c) 2014-2018 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +27. dunder-proto 1.0.1 (MIT) +https://github.com/es-shims/dunder-proto + +MIT License + +Copyright (c) 2024 ECMAScript Shims + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +28. ecdsa-sig-formatter 1.0.11 (Apache-2.0) +ssh://git@github.com/Brightspace/node-ecdsa-sig-formatter + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2015 D2L Corporation + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +--------------------------------------------------------------- + +29. ee-first 1.1.1 (MIT) +jonathanong/ee-first + +The MIT License (MIT) + +Copyright (c) 2014 Jonathan Ong me@jongleberry.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +--------------------------------------------------------------- + +30. encodeurl 2.0.0 (MIT) +pillarjs/encodeurl + +(The MIT License) + +Copyright (c) 2016 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +31. es-define-property 1.0.1 (MIT) +https://github.com/ljharb/es-define-property + +MIT License + +Copyright (c) 2024 Jordan Harband + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +32. es-errors 1.3.0 (MIT) +https://github.com/ljharb/es-errors + +MIT License + +Copyright (c) 2024 Jordan Harband + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +33. es-object-atoms 1.1.2 (MIT) +https://github.com/ljharb/es-object-atoms + +MIT License + +Copyright (c) 2024 Jordan Harband + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +34. escape-html 1.0.3 (MIT) +component/escape-html + +(The MIT License) + +Copyright (c) 2012-2013 TJ Holowaychuk +Copyright (c) 2015 Andreas Lubbe +Copyright (c) 2015 Tiancheng "Timothy" Gu + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +35. etag 1.8.1 (MIT) +jshttp/etag + +(The MIT License) + +Copyright (c) 2014-2016 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +36. eventsource 3.0.7 (MIT) +git://git@github.com/EventSource/eventsource + +The MIT License + +Copyright (c) EventSource GitHub organisation + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +37. eventsource-parser 3.1.0 (MIT) +ssh://git@github.com/rexxars/eventsource-parser + +MIT License + +Copyright (c) 2026 Espen Hovlandsdal + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +38. express 5.2.1 (MIT) +expressjs/express + +(The MIT License) + +Copyright (c) 2009-2014 TJ Holowaychuk +Copyright (c) 2013-2014 Roman Shtylman +Copyright (c) 2014-2015 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +39. express-rate-limit 8.5.2 (MIT) +https://github.com/express-rate-limit/express-rate-limit + +# MIT License + +Copyright 2023 Nathan Friedly, Vedant K + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +40. fast-deep-equal 3.1.3 (MIT) +https://github.com/epoberezkin/fast-deep-equal + +MIT License + +Copyright (c) 2017 Evgeny Poberezkin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +41. fast-uri 3.1.2 (BSD-3-Clause) +https://github.com/fastify/fast-uri + +Copyright (c) 2011-2021, Gary Court until https://github.com/garycourt/uri-js/commit/a1acf730b4bba3f1097c9f52e7d9d3aba8cdcaae +Copyright (c) 2021-present The Fastify team +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * The names of any contributors may not be used to endorse or promote + products derived from this software without specific prior written + permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS AND CONTRIBUTORS BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + * * * + +The complete list of contributors can be found at: +- https://github.com/garycourt/uri-js/graphs/contributors + +--------------------------------------------------------------- + +42. finalhandler 2.1.1 (MIT) +pillarjs/finalhandler + +(The MIT License) + +Copyright (c) 2014-2022 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +43. forwarded 0.2.0 (MIT) +jshttp/forwarded + +(The MIT License) + +Copyright (c) 2014-2017 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +44. fresh 2.0.0 (MIT) +jshttp/fresh + +(The MIT License) + +Copyright (c) 2012 TJ Holowaychuk +Copyright (c) 2016-2017 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +45. function-bind 1.1.2 (MIT) +https://github.com/Raynos/function-bind + +Copyright (c) 2013 Raynos. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +--------------------------------------------------------------- + +46. get-intrinsic 1.3.0 (MIT) +https://github.com/ljharb/get-intrinsic + +MIT License + +Copyright (c) 2020 Jordan Harband + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +47. get-proto 1.0.1 (MIT) +https://github.com/ljharb/get-proto + +MIT License + +Copyright (c) 2025 Jordan Harband + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +48. gopd 1.2.0 (MIT) +https://github.com/ljharb/gopd + +MIT License + +Copyright (c) 2022 Jordan Harband + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +49. has-symbols 1.1.0 (MIT) +git://github.com/inspect-js/has-symbols + +MIT License + +Copyright (c) 2016 Jordan Harband + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +50. hasown 2.0.4 (MIT) +https://github.com/inspect-js/hasOwn + +MIT License + +Copyright (c) Jordan Harband and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +51. hono 4.12.26 (MIT) +https://github.com/honojs/hono + +MIT License + +Copyright (c) 2021 - present, Yusuke Wada and Hono contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +52. http-errors 2.0.1 (MIT) +jshttp/http-errors + +The MIT License (MIT) + +Copyright (c) 2014 Jonathan Ong me@jongleberry.com +Copyright (c) 2016 Douglas Christopher Wilson doug@somethingdoug.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +--------------------------------------------------------------- + +53. iconv-lite 0.7.2 (MIT) +https://github.com/pillarjs/iconv-lite + +Copyright (c) 2011 Alexander Shtuchkin + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +54. inherits 2.0.4 (ISC) +git://github.com/isaacs/inherits + +The ISC License + +Copyright (c) Isaac Z. Schlueter + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + +--------------------------------------------------------------- + +55. ip-address 10.2.0 (MIT) +git://github.com/beaugunderson/ip-address + +Copyright (C) 2011 by Beau Gunderson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +--------------------------------------------------------------- + +56. ipaddr.js 1.9.1 (MIT) +git://github.com/whitequark/ipaddr.js + +Copyright (C) 2011-2017 whitequark + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +--------------------------------------------------------------- + +57. is-docker 3.0.0 (MIT) +sindresorhus/is-docker + +MIT License + +Copyright (c) Sindre Sorhus (https://sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +58. is-inside-container 1.0.0 (MIT) +sindresorhus/is-inside-container + +MIT License + +Copyright (c) Sindre Sorhus (https://sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +59. is-promise 4.0.0 (MIT) +https://github.com/then/is-promise + +Copyright (c) 2014 Forbes Lindesay + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +--------------------------------------------------------------- + +60. is-wsl 3.1.1 (MIT) +sindresorhus/is-wsl + +MIT License + +Copyright (c) Sindre Sorhus (https://sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +61. isexe 2.0.0 (ISC) +https://github.com/isaacs/isexe + +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--------------------------------------------------------------- + +62. jose 6.2.3 (MIT) +panva/jose + +The MIT License (MIT) + +Copyright (c) 2018 Filip Skokan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +63. json-schema-traverse 1.0.0 (MIT) +https://github.com/epoberezkin/json-schema-traverse + +MIT License + +Copyright (c) 2017 Evgeny Poberezkin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +64. json-schema-typed 8.0.2 (BSD-2-Clause) +https://github.com/RemyRylan/json-schema-typed + +BSD 2-Clause License + +Original source code is copyright (c) 2019-2025 Remy Rylan + + +All JSON Schema documentation and descriptions are copyright (c): + +2009 [draft-0] IETF Trust , Kris Zyp , +and SitePen (USA) . + +2009 [draft-1] IETF Trust , Kris Zyp , +and SitePen (USA) . + +2010 [draft-2] IETF Trust , Kris Zyp , +and SitePen (USA) . + +2010 [draft-3] IETF Trust , Kris Zyp , +Gary Court , and SitePen (USA) . + +2013 [draft-4] IETF Trust ), Francis Galiegue +, Kris Zyp , Gary Court +, and SitePen (USA) . + +2018 [draft-7] IETF Trust , Austin Wright , +Henry Andrews , Geraint Luff , and +Cloudflare, Inc. . + +2019 [draft-2019-09] IETF Trust , Austin Wright +, Henry Andrews , Ben Hutton +, and Greg Dennis . + +2020 [draft-2020-12] IETF Trust , Austin Wright +, Henry Andrews , Ben Hutton +, and Greg Dennis . + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--------------------------------------------------------------- + +65. jsonwebtoken 9.0.3 (MIT) +https://github.com/auth0/node-jsonwebtoken + +The MIT License (MIT) + +Copyright (c) 2015 Auth0, Inc. (http://auth0.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +66. jwa 2.0.1 (MIT) +git://github.com/brianloveswords/node-jwa + +Copyright (c) 2013 Brian J. Brennan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +67. jws 4.0.1 (MIT) +git://github.com/brianloveswords/node-jws + +Copyright (c) 2013 Brian J. Brennan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +68. lodash.includes 4.3.0 (MIT) +lodash/lodash + +Copyright jQuery Foundation and other contributors + +Based on Underscore.js, copyright Jeremy Ashkenas, +DocumentCloud and Investigative Reporters & Editors + +This software consists of voluntary contributions made by many +individuals. For exact contribution history, see the revision history +available at https://github.com/lodash/lodash + +The following license applies to all parts of this software except as +documented below: + +==== + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +==== + +Copyright and related rights for sample code are waived via CC0. Sample +code is defined as all source code displayed within the prose of the +documentation. + +CC0: http://creativecommons.org/publicdomain/zero/1.0/ + +==== + +Files located in the node_modules and vendor directories are externally +maintained libraries used by this software which have their own +licenses; we recommend you read them, as their terms may differ from the +terms above. + +--------------------------------------------------------------- + +69. lodash.isboolean 3.0.3 (MIT) +lodash/lodash + +Copyright 2012-2016 The Dojo Foundation +Based on Underscore.js, copyright 2009-2016 Jeremy Ashkenas, +DocumentCloud and Investigative Reporters & Editors + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +70. lodash.isinteger 4.0.4 (MIT) +lodash/lodash + +Copyright jQuery Foundation and other contributors + +Based on Underscore.js, copyright Jeremy Ashkenas, +DocumentCloud and Investigative Reporters & Editors + +This software consists of voluntary contributions made by many +individuals. For exact contribution history, see the revision history +available at https://github.com/lodash/lodash + +The following license applies to all parts of this software except as +documented below: + +==== + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +==== + +Copyright and related rights for sample code are waived via CC0. Sample +code is defined as all source code displayed within the prose of the +documentation. + +CC0: http://creativecommons.org/publicdomain/zero/1.0/ + +==== + +Files located in the node_modules and vendor directories are externally +maintained libraries used by this software which have their own +licenses; we recommend you read them, as their terms may differ from the +terms above. + +--------------------------------------------------------------- + +71. lodash.isnumber 3.0.3 (MIT) +lodash/lodash + +Copyright 2012-2016 The Dojo Foundation +Based on Underscore.js, copyright 2009-2016 Jeremy Ashkenas, +DocumentCloud and Investigative Reporters & Editors + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +72. lodash.isplainobject 4.0.6 (MIT) +lodash/lodash + +Copyright jQuery Foundation and other contributors + +Based on Underscore.js, copyright Jeremy Ashkenas, +DocumentCloud and Investigative Reporters & Editors + +This software consists of voluntary contributions made by many +individuals. For exact contribution history, see the revision history +available at https://github.com/lodash/lodash + +The following license applies to all parts of this software except as +documented below: + +==== + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +==== + +Copyright and related rights for sample code are waived via CC0. Sample +code is defined as all source code displayed within the prose of the +documentation. + +CC0: http://creativecommons.org/publicdomain/zero/1.0/ + +==== + +Files located in the node_modules and vendor directories are externally +maintained libraries used by this software which have their own +licenses; we recommend you read them, as their terms may differ from the +terms above. + +--------------------------------------------------------------- + +73. lodash.isstring 4.0.1 (MIT) +lodash/lodash + +Copyright 2012-2016 The Dojo Foundation +Based on Underscore.js, copyright 2009-2016 Jeremy Ashkenas, +DocumentCloud and Investigative Reporters & Editors + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +74. lodash.once 4.1.1 (MIT) +lodash/lodash + +Copyright jQuery Foundation and other contributors + +Based on Underscore.js, copyright Jeremy Ashkenas, +DocumentCloud and Investigative Reporters & Editors + +This software consists of voluntary contributions made by many +individuals. For exact contribution history, see the revision history +available at https://github.com/lodash/lodash + +The following license applies to all parts of this software except as +documented below: + +==== + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +==== + +Copyright and related rights for sample code are waived via CC0. Sample +code is defined as all source code displayed within the prose of the +documentation. + +CC0: http://creativecommons.org/publicdomain/zero/1.0/ + +==== + +Files located in the node_modules and vendor directories are externally +maintained libraries used by this software which have their own +licenses; we recommend you read them, as their terms may differ from the +terms above. + +--------------------------------------------------------------- + +75. math-intrinsics 1.1.0 (MIT) +https://github.com/es-shims/math-intrinsics + +MIT License + +Copyright (c) 2024 ECMAScript Shims + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +76. media-typer 1.1.0 (MIT) +jshttp/media-typer + +(The MIT License) + +Copyright (c) 2014-2017 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +77. merge-descriptors 2.0.0 (MIT) +sindresorhus/merge-descriptors + +MIT License + +Copyright (c) Jonathan Ong +Copyright (c) Douglas Christopher Wilson +Copyright (c) Sindre Sorhus (https://sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +78. mime-db 1.54.0 (MIT) +jshttp/mime-db + +(The MIT License) + +Copyright (c) 2014 Jonathan Ong +Copyright (c) 2015-2022 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +79. mime-types 3.0.2 (MIT) +jshttp/mime-types + +(The MIT License) + +Copyright (c) 2014 Jonathan Ong +Copyright (c) 2015 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +80. ms 2.1.3 (MIT) +vercel/ms + +The MIT License (MIT) + +Copyright (c) 2020 Vercel, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +81. negotiator 1.0.0 (MIT) +jshttp/negotiator + +(The MIT License) + +Copyright (c) 2012-2014 Federico Romero +Copyright (c) 2012-2014 Isaac Z. Schlueter +Copyright (c) 2014-2015 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +82. object-assign 4.1.1 (MIT) +sindresorhus/object-assign + +The MIT License (MIT) + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +--------------------------------------------------------------- + +83. object-inspect 1.13.4 (MIT) +git://github.com/inspect-js/object-inspect + +MIT License + +Copyright (c) 2013 James Halliday + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +84. on-finished 2.4.1 (MIT) +jshttp/on-finished + +(The MIT License) + +Copyright (c) 2013 Jonathan Ong +Copyright (c) 2014 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +85. once 1.4.0 (ISC) +git://github.com/isaacs/once + +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--------------------------------------------------------------- + +86. open 10.2.0 (MIT) +sindresorhus/open + +MIT License + +Copyright (c) Sindre Sorhus (https://sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +87. parseurl 1.3.3 (MIT) +pillarjs/parseurl + +(The MIT License) + +Copyright (c) 2014 Jonathan Ong +Copyright (c) 2014-2017 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +88. path-key 3.1.1 (MIT) +sindresorhus/path-key + +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +89. path-to-regexp 8.4.2 (MIT) +https://github.com/pillarjs/path-to-regexp + +The MIT License (MIT) + +Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +--------------------------------------------------------------- + +90. pkce-challenge 5.0.1 (MIT) +https://github.com/crouchcd/pkce-challenge + +MIT License + +Copyright (c) 2019 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +91. proxy-addr 2.0.7 (MIT) +jshttp/proxy-addr + +(The MIT License) + +Copyright (c) 2014-2016 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +92. qs 6.15.2 (BSD-3-Clause) +https://github.com/ljharb/qs + +BSD 3-Clause License + +Copyright (c) 2014, Nathan LaFreniere and other [contributors](https://github.com/ljharb/qs/graphs/contributors) +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--------------------------------------------------------------- + +93. range-parser 1.2.1 (MIT) +jshttp/range-parser + +(The MIT License) + +Copyright (c) 2012-2014 TJ Holowaychuk +Copyright (c) 2015-2016 Douglas Christopher Wilson +Copyright (c) 2014-2022 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +--------------------------------------------------------------- + +95. require-from-string 2.0.2 (MIT) +floatdrop/require-from-string + +The MIT License (MIT) + +Copyright (c) Vsevolod Strukchinsky (github.com/floatdrop) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +--------------------------------------------------------------- + +96. router 2.2.0 (MIT) +pillarjs/router + +(The MIT License) + +Copyright (c) 2013 Roman Shtylman +Copyright (c) 2014-2022 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +97. run-applescript 7.1.0 (MIT) +sindresorhus/run-applescript + +MIT License + +Copyright (c) Sindre Sorhus (https://sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +98. safe-buffer 5.2.1 (MIT) +git://github.com/feross/safe-buffer + +The MIT License (MIT) + +Copyright (c) Feross Aboukhadijeh + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +--------------------------------------------------------------- + +99. safer-buffer 2.1.2 (MIT) +https://github.com/ChALkeR/safer-buffer + +MIT License + +Copyright (c) 2018 Nikita Skovoroda + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +100. semver 7.8.4 (ISC) +https://github.com/npm/node-semver + +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--------------------------------------------------------------- + +101. send 1.2.1 (MIT) +pillarjs/send + +(The MIT License) + +Copyright (c) 2012 TJ Holowaychuk +Copyright (c) 2014-2022 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +102. serve-static 2.2.1 (MIT) +expressjs/serve-static + +(The MIT License) + +Copyright (c) 2010 Sencha Inc. +Copyright (c) 2011 LearnBoost +Copyright (c) 2011 TJ Holowaychuk +Copyright (c) 2014-2016 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +103. setprototypeof 1.2.0 (ISC) +https://github.com/wesleytodd/setprototypeof + +Copyright (c) 2015, Wes Todd + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--------------------------------------------------------------- + +104. shebang-command 2.0.0 (MIT) +kevva/shebang-command + +MIT License + +Copyright (c) Kevin Mårtensson (github.com/kevva) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +105. shebang-regex 3.0.0 (MIT) +sindresorhus/shebang-regex + +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +106. side-channel 1.1.1 (MIT) +https://github.com/ljharb/side-channel + +MIT License + +Copyright (c) 2019 Jordan Harband + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +107. side-channel-list 1.0.1 (MIT) +https://github.com/ljharb/side-channel-list + +MIT License + +Copyright (c) 2024 Jordan Harband + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +108. side-channel-map 1.0.1 (MIT) +https://github.com/ljharb/side-channel-map + +MIT License + +Copyright (c) 2024 Jordan Harband + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +109. side-channel-weakmap 1.0.2 (MIT) +https://github.com/ljharb/side-channel-weakmap + +MIT License + +Copyright (c) 2019 Jordan Harband + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +110. statuses 2.0.2 (MIT) +jshttp/statuses + +The MIT License (MIT) + +Copyright (c) 2014 Jonathan Ong +Copyright (c) 2016 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +--------------------------------------------------------------- + +111. toidentifier 1.0.1 (MIT) +component/toidentifier + +MIT License + +Copyright (c) 2016 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +112. type-is 2.1.0 (MIT) +jshttp/type-is + +(The MIT License) + +Copyright (c) 2014 Jonathan Ong +Copyright (c) 2014-2015 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +113. unpipe 1.0.0 (MIT) +stream-utils/unpipe + +(The MIT License) + +Copyright (c) 2015 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +114. uuid 11.1.1 (MIT) +https://github.com/uuidjs/uuid + +The MIT License (MIT) + +Copyright (c) 2010-2020 Robert Kieffer and other contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +115. vary 1.1.2 (MIT) +jshttp/vary + +(The MIT License) + +Copyright (c) 2014-2017 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +116. which 2.0.2 (ISC) +git://github.com/isaacs/node-which + +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--------------------------------------------------------------- + +117. wrappy 1.0.2 (ISC) +https://github.com/npm/wrappy + +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--------------------------------------------------------------- + +118. wsl-utils 0.1.0 (MIT) +sindresorhus/wsl-utils + +MIT License + +Copyright (c) Sindre Sorhus (https://sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +119. zod 4.4.3 (MIT) +https://github.com/colinhacks/zod + +MIT License + +Copyright (c) 2025 Colin McDonnell + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------- + +120. zod-to-json-schema 3.25.2 (ISC) +https://github.com/StefanTerdell/zod-to-json-schema + +ISC License + +Copyright (c) 2020, Stefan Terdell + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--------------------------------------------------------------- diff --git a/docs/DATA-FLOW.md b/docs/DATA-FLOW.md new file mode 100644 index 0000000..8dbb395 --- /dev/null +++ b/docs/DATA-FLOW.md @@ -0,0 +1,53 @@ +# Data flow and network endpoints + +This document enumerates every network destination the SPE MCP server can contact, what +travels there, and how that maps to Microsoft compliance boundaries. It backs the +"Data, privacy, and telemetry" and "Data residency and EU Data Boundary" notices in the +[README](../README.md#important-notices) and the [PRIVACY](../PRIVACY.md) notice. + +## Topology + +``` +MCP client <--stdio--> spe-mcp-server (local process) <--HTTPS--> Microsoft endpoints +``` + +- The server is a **local** process. It talks to your MCP client over **stdio**; it opens no + network socket for the client connection. +- Every outbound network call is HTTPS to a **Microsoft-operated** endpoint, made **on your + behalf**, using **your** credentials, into **your** tenant and subscription. + +## Outbound endpoints + +| Endpoint | Purpose | Authentication | Data sent | Boundary | +|----------|---------|----------------|-----------|----------| +| Microsoft Entra / MSAL (`login.microsoftonline.com`) | Interactive/silent sign-in and token acquisition | User (PKCE / device code) | Your sign-in and auth-code exchange | Microsoft first-party | +| Microsoft Graph (`graph.microsoft.com`) | Create/manage app registrations, container types, containers, and content | Your delegated token | The requests you invoke, in your tenant | Microsoft first-party, in-tenant | +| Azure Resource Manager (`management.azure.com`) | Register the `Microsoft.Syntex` provider and wire SPE billing to your subscription | Your Azure token | ARM requests in your subscription | Microsoft first-party, in-subscription | +| Microsoft Learn MCP (`learn.microsoft.com/api/mcp`) | Read-only public documentation lookup (`docs_search`) | **None** | Documentation queries only — **no customer data** | Microsoft first-party, public docs | + +The server contacts **no non-Microsoft services**. The Microsoft Learn documentation lookup +is the only unauthenticated, out-of-tenant call; it carries no customer data, is host- +validated before use (control **SEC-007**), and can be disabled with `--tools`. + +## Local artifacts + +These never leave your machine: + +- The MSAL **token cache** and the **provisioning-state** file, written owner-only (control + **SEC-003**). +- **stderr** diagnostic logs, with tokens and secrets redacted (`src/logging.ts`). + +## Compliance boundary and EU Data Boundary (EUDB) + +- Microsoft Graph, Azure Resource Manager, and SharePoint Embedded are Microsoft Online + Services operating **within the Microsoft 365 / Azure compliance boundary**. Requests you + make through this tool stay within that boundary and your tenant's configured data location. +- The tool performs **no independent cross-region processing** and stores **no customer + content** of its own. Data location, residency, and **EU Data Boundary** commitments are + determined by those underlying services and your tenant configuration — not by this tool. + +## Telemetry + +The server opens **no telemetry channel** and sends **no usage analytics**. Outbound requests +carry only a static product `User-Agent` (`spe-mcp-server/`) with no personal, +tenant, or usage data. See [PRIVACY.md](../PRIVACY.md) for details. diff --git a/docs/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md new file mode 100644 index 0000000..4044863 --- /dev/null +++ b/docs/KNOWN-ISSUES.md @@ -0,0 +1,25 @@ +# Known issues + +## Eventual consistency + +New container types, registrations, containers, permission changes, and uploaded content may take time to propagate across Graph, SharePoint Embedded, and Microsoft 365 search. Retry read/search operations after a short delay. + +## Trial billing expiry + +Trial container types are intended for evaluation and expire after 30 days. For production or longer-running development, create a new container type with `billingClassification=standard` and complete `billing_setup`. + +## Trial-to-standard conversion is not supported + +This server does not use SharePoint-admin write APIs, so it cannot convert an existing trial container type to standard billing. Choose standard at creation time with `project_provision` or `container_type_create`. + +## Public-client app limitations + +The server uses a public-client Entra app for local developer flows. Do not put client secrets in MCP client configuration. Some enterprise Conditional Access policies may require interactive sign-in or admin consent before delegated SPE or ARM operations succeed. + +## Local run and deployment dependencies + +`project_run_local` requires the scaffold's runtime toolchain (Node.js/npm for React SPA + Functions, .NET SDK for C# web). `project_deploy` requires Azure Developer CLI (`azd`) and an Azure login. + +## Search freshness + +`content_search` may lag behind `content_file_upload` or sample seeding because search indexing is asynchronous. diff --git a/docs/SECURITY-CONTROLS.md b/docs/SECURITY-CONTROLS.md new file mode 100644 index 0000000..56f5d63 --- /dev/null +++ b/docs/SECURITY-CONTROLS.md @@ -0,0 +1,29 @@ +# Security Controls + +The SharePoint Embedded MCP server tags its security-relevant behaviors with +short, stable control codes (`SAFE-00x`, `SEC-00x`). These codes appear in code +comments and test labels so that a given safeguard can be traced across the +codebase and discussed unambiguously. + +**User-facing surfaces (CLI help, error messages) never rely on these codes** — +they describe the behavior in plain language. This legend is the single place +that maps each code to a human-readable name and a one-line description. + +## SAFE — tool-exposure and destructive-operation safeguards + +| Code | Name | What it does | +|------|------|--------------| +| SAFE-002 | Destructive-operation confirmation gate | Mutating/irreversible operations (e.g. permanent delete) require an explicit `confirm: true`; the call is rejected before it reaches Graph/Azure otherwise. | +| SAFE-003 | Read-only mode | When enabled (`--read-only` / `SPE_READ_ONLY`), only tools annotated read-only are advertised and callable; every mutating call is rejected. | +| SAFE-004 | Tool allowlist / profiles | Restricts the exposed tool set (`--tools` / `SPE_TOOLS`) to a built-in profile (`readOnly`, `docsOnly`, `provisioning`, `content`, `admin`) or a comma-separated tool list. | + +## SEC — data-handling and hardening safeguards + +| Code | Name | What it does | +|------|------|--------------| +| SEC-002 | Client-safe error messages | Tool `catch` blocks surface only sanitized, consistent messages to clients; internal detail stays in server-side logs. | +| SEC-003 | Secure filesystem (owner-only) | Credential and state files (token cache, server state) are written owner-only (POSIX `0o600`; ACL-governed on Windows). | +| SEC-007 | Docs endpoint validation | The Microsoft Learn MCP endpoint is resolved and validated before use to prevent redirection to an untrusted host. | + +> Adding a new safeguard? Give it the next code in its family and add a row here +> so code comments and tests have a lookup. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 0000000..9a01d83 --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,82 @@ +# Troubleshooting + +## `az login` has not been run + +Most provisioning and billing flows start with `status_get`. If it reports no Azure CLI identity, run: + +```bash +az login --allow-no-subscriptions +``` + +Use `--allow-no-subscriptions` for M365-only tenants that do not have an Azure subscription. + +## Auth, scope, or consent errors + +The owning Entra public-client app needs SPE delegated permissions such as `FileStorageContainer.Selected`, `FileStorageContainerType.Manage.All`, and `FileStorageContainerTypeReg.Manage.All`. Re-run the provisioning flow or grant/admin-consent the missing permissions in Entra ID. + +For ARM Conditional Access or claims-challenge errors during standard billing, complete an interactive ARM-scoped sign-in and retry: + +```bash +az login --scope https://management.core.windows.net//.default --tenant +``` + +If your tenant requires an auth-context step-up that Azure CLI cannot satisfy, complete the step-up in the SharePoint admin center, then retry the MCP tool. + +## Container-type registration delays + +After `container_type_register` or `project_provision`, Graph and SharePoint registration state can take time to propagate. If `container_create`, `container_type_get`, or app access fails immediately after registration, retry after a short delay. + +## Billing or Microsoft.Syntex RP failures + +Standard billing requires a container type created with `billingClassification=standard`; trial container types cannot be converted. Use: + +1. `azure_subscriptions_list` +2. `azure_resource_groups_list` +3. `billing_setup` without `confirm` to preview +4. `billing_setup` with `confirm=true` after explicit approval +5. `billing_check` to verify + +If the `Microsoft.Syntex` resource provider is not registered or is still registering, retry after registration completes. + +## Search index latency + +`content_search` depends on Microsoft 365 indexing. Newly uploaded files may not appear immediately. Retry after indexing has caught up. + +## Content tools fail before `content_access_grant` + +Content-plane tools are intentionally off by default and fail closed. Before `project_seed_sample_data`, `content_file_upload`, `content_folder_create`, `content_search`, `content_file_preview`, or `content_sharing_manage`, ask for explicit opt-in and run: + +```text +content_access_grant confirm=true +``` + +Access can be disabled later with `content_access_revoke`. + +## Correlation IDs + +When a tool fails, the client-facing error carries a short **correlation ID**, +for example: + +```text +The tool failed. See server logs for details. (correlationId: a1b2c3d4) +``` + +That same id is logged to the server's **stderr** at the point of failure: + +```text +[2026-07-08T12:34:56.789Z] [MCP] Tool error (a1b2c3d4) {"tool":"container_create","argKeys":["displayName","containerTypeId"], ...} +``` + +To debug a reported failure, grep the server's stderr log for the id to find the +redacted argument preview and the sanitized upstream (Graph/ARM) error that +produced it: + +```bash +grep a1b2c3d4 spe-mcp.log +``` + +(stdout is reserved for the MCP JSON-RPC protocol, so all logs — including this +line — go to stderr; redirect stderr to a file to retain it, e.g. +`node dist/cli.js start 2> spe-mcp.log`.) The correlation ID is a client↔log +join key only: it is generated locally per failure and is **not** sent to +Graph/ARM as an `x-ms-client-request-id`. diff --git a/docs/e2e-prompts.md b/docs/e2e-prompts.md new file mode 100644 index 0000000..13d7d64 --- /dev/null +++ b/docs/e2e-prompts.md @@ -0,0 +1,71 @@ +# E2E prompt catalog + +Use these natural-language prompts for MCP UX regression across clients. + +## Provisioning flow + +- "Create a SharePoint Embedded trial app for a construction document portal, scaffold a React sample, hydrate config, and offer to run it locally." +- "Create a standard-billing SPE app for a legal document review portal; let me choose the Azure subscription and resource group before provisioning." + +## Individual provisioning tools + +- `status_get`: "Check whether I am signed in and ready to provision SharePoint Embedded resources." +- `project_app_create`: "Create the owning Entra app for my SPE project." +- `container_type_create`: "Create a standard SPE container type named Contoso Docs for my owning app." +- `container_type_register`: "Register my SPE container type so consuming apps can use it." +- `container_create`: "Create a demo container for my registered SPE container type." +- `container_type_list`: "List my SPE container types." +- `container_type_get`: "Show details for this SPE container type ID." +- `container_list`: "List containers for this SPE container type." +- `container_get`: "Show details for this SPE container." +- `container_type_update`: "Rename this SPE container type." +- `container_type_grant_owner`: "Grant another app owner access to this container type." +- `container_type_owners_list`: "List owner grants on this container type." +- `container_type_revoke_owner`: "Revoke an owner grant from this container type." +- `container_type_app_grant_add`: "Authorize a consuming app for this registered container type." +- `container_type_app_grants_list`: "List consuming app grants for this container type registration." +- `container_type_app_grant_remove`: "Remove a consuming app grant from this container type registration." +- `container_type_delete`: "Delete this unused trial container type after confirming what will be removed." + +## Billing flow + +- "Set up standard billing for my current SPE container type; show me subscriptions and resource groups, preview first, then ask before confirming." +- `azure_subscriptions_list`: "List Azure subscriptions I can use for SPE standard billing." +- `azure_resource_groups_list`: "List resource groups in this Azure subscription for SPE billing." +- `billing_setup`: "Attach standard billing to this already-standard container type, but preview before making changes." +- `billing_check`: "Check billing status and trial expiry for my SPE container type." + +## Scaffold, run, and deploy + +- `project_scaffold`: "Show SPE reference app options, then scaffold the React SPA + Functions sample into ./spe-demo." +- `project_hydrate_config`: "Hydrate the scaffolded app configuration from my current SPE provisioning state." +- `project_run_local`: "Run my scaffolded SPE app locally and tell me the URL." +- `project_deploy`: "Deploy my scaffolded SPE app to Azure in eastus and return the live URL." + +## Content operations + +- `content_access_grant`: "Enable content access so you can seed sample documents in my SPE container." +- `project_seed_sample_data`: "Seed sample containers and documents for my current SPE project." +- `content_folder_create`: "Create a Reports folder in this SPE container." +- `content_file_upload`: "Upload this small sample document to my SPE container." +- `content_search`: "Search my SPE content for permit documents." +- `content_file_preview`: "Preview this file from my SPE container." +- `content_sharing_manage`: "List sharing links for this SPE file." +- `content_access_revoke`: "Revoke content-plane access after the demo." + +## Container lifecycle and permissions + +- `container_permissions_manage`: "Grant this user access to the demo SPE container." +- `container_archive_restore`: "Archive this demo container after confirming." +- `container_delete`: "Delete this test container after confirming." + +## Docs and troubleshooting + +- `docs_search`: "Find official Microsoft documentation for SharePoint Embedded container type registration." +- `docs_fetch`: "Fetch the full Microsoft Learn page for this SharePoint Embedded article URL." +- "Troubleshoot why standard billing fails with a Microsoft.Syntex resource provider error." +- "Troubleshoot why content search cannot find the file I just uploaded." + +## Cleanup flow + +- `project_cleanup`: "Preview cleanup for my current SPE project and ask before deleting anything." diff --git a/docs/new-tool.md b/docs/new-tool.md new file mode 100644 index 0000000..ac861d4 --- /dev/null +++ b/docs/new-tool.md @@ -0,0 +1,39 @@ +# Adding a new tool + +Use this checklist when contributing a new SPE MCP tool. + +1. **Define the schema** + - Create `src/tools/.ts`. + - Export a `McpTool` with a grouped snake_case `name`, clear `description`, JSON-object `inputSchema`, and required fields where needed. + +2. **Classify the tool** + - Mark the plane in code review notes and docs: control plane, content plane, billing, docs, or lifecycle. + - Use read-only behavior for inspect/list/get tools. + - Treat delete, cleanup, billing setup, deployment, permission mutation, and content writes as destructive or write operations. + - Content-plane tools must be gated with `withContentAccess(...)` in `src/index.ts`. + +3. **Validate inputs** + - Reuse shared validation helpers where available. + - Return actionable errors instead of throwing for user-correctable input problems. + +4. **Return structured MCP results** + - Return `{ content: [{ type: "text", text }], isError? }`. + - Include IDs, next steps, and retry guidance in successful output. + +5. **Register the tool** + - Import it in `src/index.ts`. + - Add it to the `TOOLS` array in the right section. + - Wrap content-plane tools with `withContentAccess(...)`. + +6. **Test it** + - Add a same-name test file next to the tool, such as `src/tools/.test.ts`. + - Mock Graph, Azure CLI, filesystem, or process execution as needed. + - Update registry tests if the tool catalog changes. + +7. **Update user-facing surfaces** + - Add or update prompts in `src/prompts.ts` if the tool belongs in a guided flow. + - Add resources/runbooks in `src/resources.ts` when clients need copy/paste guidance. + - Update docs under `docs/` and README tool tables when ownership allows. + +8. **Validate locally** + - Run `npm run typecheck` and `npm test`. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..1e0b3fa --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// ESLint v9 flat config for the SPE MCP server. +// +// Goal: a pragmatic, error-free baseline so `npm run lint` exits 0 while still +// surfacing useful signal as warnings. The `lint` script does not pass +// `--max-warnings`, so warnings do not fail the run. +// +// File targeting is handled here (flat config replaces the old `--ext` flag). +import js from '@eslint/js'; +import tsParser from '@typescript-eslint/parser'; +import tsPlugin from '@typescript-eslint/eslint-plugin'; + +export default [ + // Globally ignored paths (build output, deps, coverage). + { + ignores: ['dist/**', 'node_modules/**', 'coverage/**'], + }, + + // Base JS recommended rules. + js.configs.recommended, + + // TypeScript sources. + { + files: ['**/*.ts'], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + }, + plugins: { + '@typescript-eslint': tsPlugin, + }, + rules: { + // Start from the TypeScript-ESLint recommended ruleset. + ...tsPlugin.configs.recommended.rules, + + // --- Pragmatic baseline ------------------------------------------------- + // Downgrade or disable rules that currently produce ERRORS on src so the + // run is error-free. Product source is intentionally NOT modified; these + // are surfaced as warnings (or off) instead. + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + '@typescript-eslint/no-empty-object-type': 'warn', + '@typescript-eslint/no-unsafe-function-type': 'warn', + '@typescript-eslint/no-wrapper-object-types': 'warn', + '@typescript-eslint/ban-ts-comment': 'warn', + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-this-alias': 'warn', + '@typescript-eslint/no-require-imports': 'warn', + 'no-empty': 'warn', + 'no-constant-condition': 'warn', + 'no-control-regex': 'off', + 'no-useless-escape': 'warn', + 'no-prototype-builtins': 'warn', + 'no-async-promise-executor': 'warn', + 'no-case-declarations': 'warn', + 'no-fallthrough': 'warn', + 'no-undef': 'off', // TypeScript handles undefined identifiers. + }, + }, +]; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c0c78e0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,5201 @@ +{ + "name": "@microsoft/spe-mcp", + "version": "0.1.0-alpha.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@microsoft/spe-mcp", + "version": "0.1.0-alpha.1", + "license": "MIT", + "dependencies": { + "@azure/msal-node": "^2.6.0", + "@modelcontextprotocol/sdk": "^1.27.1", + "commander": "^12.0.0", + "open": "^10.0.0", + "zod": "^4.4.3", + "zod-to-json-schema": "^3.25.2" + }, + "bin": { + "spe-mcp": "dist/cli.js" + }, + "devDependencies": { + "@microsoft/microsoft-graph-types": "2.43.1", + "@microsoft/microsoft-graph-types-beta": "0.44.0-preview", + "@types/node": "^20.11.0", + "@typescript-eslint/eslint-plugin": "^8.57.2", + "@typescript-eslint/parser": "^8.57.2", + "@vitest/coverage-v8": "^3.2.4", + "eslint": "^9.39.4", + "typescript": "^5.3.0", + "vitest": "^3.2.4" + }, + "engines": { + "node": "^22.0.0 || ^24.0.0 || ^26.0.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "14.16.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.1.tgz", + "integrity": "sha512-nyxsA6NA4SVKh5YyRpbSXiMr7oQbwark7JU9LMeg6tJYTSPyAGkdx61wPT4gyxZfxlSxMMEyAsWaubBlNyIa1w==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-2.16.3.tgz", + "integrity": "sha512-CO+SE4weOsfJf+C5LM8argzvotrXw252/ZU6SM2Tz63fEblhH1uuVaaO4ISYFuN4Q6BhTo7I3qIdi8ydUQCqhw==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "14.16.1", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "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/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@microsoft/microsoft-graph-types": { + "version": "2.43.1", + "resolved": "https://registry.npmjs.org/@microsoft/microsoft-graph-types/-/microsoft-graph-types-2.43.1.tgz", + "integrity": "sha512-7r3FiJYW2qTWnl+Li8GV5MzJqPiJp27hvY98kH5V/ZMzGuIOkcJqOfIpusoIQrskLDfYk5kFT8AjpeW713qcIg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@microsoft/microsoft-graph-types-beta": { + "version": "0.44.0-preview", + "resolved": "https://registry.npmjs.org/@microsoft/microsoft-graph-types-beta/-/microsoft-graph-types-beta-0.44.0-preview.tgz", + "integrity": "sha512-XTCcWnUNpWbaqTp6x2ybGyY4ud3e5fAURcSNvhwAq73E6P8hEQqviOtAYFf053UctZnJd6nWkzQ9xMpOCwcSBA==", + "dev": true + }, + "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/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", + "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/type-utils": "8.61.1", + "@typescript-eslint/utils": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.61.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz", + "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz", + "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.61.1", + "@typescript-eslint/types": "^8.61.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz", + "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz", + "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz", + "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz", + "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz", + "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.61.1", + "@typescript-eslint/tsconfig-utils": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz", + "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz", + "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.6.tgz", + "integrity": "sha512-LsAdmUapA0qSN306d8+zOyawM0hFm2m2Hg9IwVNIKBm+qJV8cijiq2c+gxKZcB1HCfIWAy+0qEZDCUQA58A1cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.6", + "vitest": "3.2.6" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.6", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "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/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "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/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/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=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/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "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/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.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/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": "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/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/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/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "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/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "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-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "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/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "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/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "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/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "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-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "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/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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": "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/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.26", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz", + "integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "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.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "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/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "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-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "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/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "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/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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": "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/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/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/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/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "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/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "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/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/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "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-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=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-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.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/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "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": "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/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/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", + "fsevents": "~2.3.2" + } + }, + "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/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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" + }, + "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/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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/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/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/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "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/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "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/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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/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/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/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "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/vite": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "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/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "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/package.json b/package.json new file mode 100644 index 0000000..8286f7c --- /dev/null +++ b/package.json @@ -0,0 +1,88 @@ +{ + "name": "@microsoft/spe-mcp", + "version": "0.2.0-alpha.1", + "description": "SharePoint Embedded MCP Server — manage container types, containers, and content via any MCP client", + "keywords": [ + "mcp", + "model-context-protocol", + "sharepoint-embedded", + "spe", + "microsoft-graph", + "sharepoint", + "containers", + "ai-tools", + "copilot" + ], + "homepage": "https://learn.microsoft.com/sharepoint/dev/embedded/overview", + "bugs": { + "url": "https://github.com/microsoft/SharePoint-Embedded-MCP-Server/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/microsoft/SharePoint-Embedded-MCP-Server.git" + }, + "author": { + "name": "Microsoft Corporation", + "url": "https://www.microsoft.com" + }, + "type": "module", + "main": "dist/index.js", + "bin": { + "spe-mcp": "./dist/cli.js" + }, + "files": [ + "dist", + "samples", + "README.md", + "LICENSE", + "THIRD-PARTY-NOTICES" + ], + "scripts": { + "build": "tsc", + "build:watch": "tsc --watch", + "start": "node dist/index.js", + "dev": "tsc && node dist/index.js", + "clean": "rm -rf dist", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "lint": "eslint src", + "typecheck": "tsc --noEmit", + "notices": "node scripts/generate-third-party-notices.mjs", + "prepublishOnly": "npm run build", + "ci": "npm run typecheck && npm run build && npm run test" + }, + "dependencies": { + "@azure/msal-node": "^2.6.0", + "@modelcontextprotocol/sdk": "^1.27.1", + "commander": "^12.0.0", + "open": "^10.0.0", + "zod": "^4.4.3", + "zod-to-json-schema": "^3.25.2" + }, + "devDependencies": { + "@microsoft/microsoft-graph-types": "2.43.1", + "@microsoft/microsoft-graph-types-beta": "0.44.0-preview", + "@types/node": "^20.11.0", + "@typescript-eslint/eslint-plugin": "^8.57.2", + "@typescript-eslint/parser": "^8.57.2", + "@vitest/coverage-v8": "^3.2.4", + "eslint": "^9.39.4", + "typescript": "^5.3.0", + "vitest": "^3.2.4" + }, + "engines": { + "node": "^22.0.0 || ^24.0.0 || ^26.0.0" + }, + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "overrides": { + "@azure/msal-node": { + "uuid": "^11.1.1" + }, + "esbuild": "^0.28.1" + }, + "mcpName": "com.microsoft/sharepointembedded-mcp-server" +} diff --git a/samples/csharp-web/.dockerignore b/samples/csharp-web/.dockerignore new file mode 100644 index 0000000..577bf32 --- /dev/null +++ b/samples/csharp-web/.dockerignore @@ -0,0 +1,7 @@ +bin/ +obj/ +.git/ +.vs/ +.vscode/ +**/*.user +.azure/ diff --git a/samples/csharp-web/.gitignore b/samples/csharp-web/.gitignore new file mode 100644 index 0000000..b5d3657 --- /dev/null +++ b/samples/csharp-web/.gitignore @@ -0,0 +1,4 @@ +# .NET build outputs (kept out of git and the scaffolded project) +bin/ +obj/ +.publish/ diff --git a/samples/csharp-web/Dockerfile b/samples/csharp-web/Dockerfile new file mode 100644 index 0000000..caa8089 --- /dev/null +++ b/samples/csharp-web/Dockerfile @@ -0,0 +1,13 @@ +# Build stage +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src +COPY . . +RUN dotnet publish -c Release -o /app + +# Runtime stage +FROM mcr.microsoft.com/dotnet/aspnet:8.0 +WORKDIR /app +COPY --from=build /app . +EXPOSE 8080 +ENV ASPNETCORE_URLS=http://+:8080 +ENTRYPOINT ["dotnet", "app.dll"] diff --git a/samples/csharp-web/Program.cs b/samples/csharp-web/Program.cs new file mode 100644 index 0000000..0ac0247 --- /dev/null +++ b/samples/csharp-web/Program.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// Minimal SharePoint Embedded reference app (ASP.NET Core, top-level statements). +// SPE settings arrive as environment variables injected by the Container App +// (see infra/app/web.bicep) or from appsettings.Development.json when run locally. +var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); + +var cfg = app.Configuration; + +app.MapGet("/", () => Results.Json(new +{ + status = "ok", + message = "SharePoint Embedded reference app is running.", + tenantId = cfg["SharePointEmbedded:TenantId"], + clientId = cfg["SharePointEmbedded:ClientId"], + containerTypeId = cfg["SharePointEmbedded:ContainerTypeId"] +})); + +app.MapGet("/healthz", () => Results.Ok("healthy")); + +app.Run(); diff --git a/samples/csharp-web/README.md b/samples/csharp-web/README.md new file mode 100644 index 0000000..9b10758 --- /dev/null +++ b/samples/csharp-web/README.md @@ -0,0 +1,38 @@ +# spe-sample-csharp-web + +A **SharePoint Embedded** reference app scaffolded by the SPE Builder MCP and based on the +ODSP security-approved azd template +[`microsoft/app-with-sharepoint-knowledge`](https://azure.github.io/ai-app-templates/repo/microsoft/app-with-sharepoint-knowledge/). +It deploys to **Azure Container Apps**. + +## Security model (why this template) + +- **No client secrets.** A user-assigned **managed identity** is federated to the Entra app + (`federatedIdentityCredentials`), so the app gets tokens via + `SignedAssertionFromManagedIdentity` — there is nothing to leak or rotate. +- **Least-privilege RBAC.** The identity is granted only **AcrPull** to pull its image. +- **Declarative + reproducible.** Subscription-scoped `infra/main.bicep` provisions its own + resource group; names are derived from `abbreviations.json` + a `resourceToken`. + +## Configuration + +SPE settings are injected by `project_hydrate_config` into `.env` / +`appsettings.Development.json`: `TENANT_ID`, `CLIENT_ID`, `CONTAINER_TYPE_ID`, `CONTAINER_ID`. +In Azure they are surfaced to the container as `SharePointEmbedded__*` environment variables +(see `infra/app/web.bicep`). + +## Run locally + +```bash +dotnet run +``` + +## Deploy to Azure + +```bash +azd up +``` + +This provisions the managed identity, Azure Container Registry, Container Apps environment, the +federated Entra app, and the container app — then deploys the image. The Entra app's production +redirect URI is configured automatically from the deployed app's FQDN. diff --git a/samples/csharp-web/appsettings.json b/samples/csharp-web/appsettings.json new file mode 100644 index 0000000..7c516dd --- /dev/null +++ b/samples/csharp-web/appsettings.json @@ -0,0 +1,20 @@ +{ + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "TenantId": "", + "ClientId": "", + "CallbackPath": "/signin-oidc" + }, + "SharePointEmbedded": { + "TenantId": "", + "ClientId": "", + "ContainerTypeId": "", + "ContainerId": "" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/csharp-web/azure.yaml b/samples/csharp-web/azure.yaml new file mode 100644 index 0000000..2afdb31 --- /dev/null +++ b/samples/csharp-web/azure.yaml @@ -0,0 +1,11 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json +name: spe-sample-csharp-web +metadata: + template: spe-builder-mcp +services: + web: + project: . + language: dotnet + host: containerapp + docker: + path: ./Dockerfile diff --git a/samples/csharp-web/bicepconfig.json b/samples/csharp-web/bicepconfig.json new file mode 100644 index 0000000..3ce6221 --- /dev/null +++ b/samples/csharp-web/bicepconfig.json @@ -0,0 +1,5 @@ +{ + "experimentalFeaturesEnabled": { + "extensibility": true + } +} diff --git a/samples/csharp-web/infra/abbreviations.json b/samples/csharp-web/infra/abbreviations.json new file mode 100644 index 0000000..dc62141 --- /dev/null +++ b/samples/csharp-web/infra/abbreviations.json @@ -0,0 +1,135 @@ +{ + "analysisServicesServers": "as", + "apiManagementService": "apim-", + "appConfigurationStores": "appcs-", + "appManagedEnvironments": "cae-", + "appContainerApps": "ca-", + "authorizationPolicyDefinitions": "policy-", + "automationAutomationAccounts": "aa-", + "blueprintBlueprints": "bp-", + "blueprintBlueprintsArtifacts": "bpa-", + "cacheRedis": "redis-", + "cdnProfiles": "cdnp-", + "cdnProfilesEndpoints": "cdne-", + "cognitiveServicesAccounts": "cog-", + "cognitiveServicesFormRecognizer": "cog-fr-", + "cognitiveServicesTextAnalytics": "cog-ta-", + "computeAvailabilitySets": "avail-", + "computeCloudServices": "cld-", + "computeDiskEncryptionSets": "des", + "computeDisks": "disk", + "computeDisksOs": "osdisk", + "computeGalleries": "gal", + "computeSnapshots": "snap-", + "computeVirtualMachines": "vm", + "computeVirtualMachineScaleSets": "vmss-", + "containerInstanceContainerGroups": "ci", + "containerRegistryRegistries": "cr", + "containerServiceManagedClusters": "aks-", + "databricksWorkspaces": "dbw-", + "dataFactoryFactories": "adf-", + "dataLakeAnalyticsAccounts": "dla", + "dataLakeStoreAccounts": "dls", + "dataMigrationServices": "dms-", + "dBforMySQLServers": "mysql-", + "dBforPostgreSQLServers": "psql-", + "devicesIotHubs": "iot-", + "devicesProvisioningServices": "provs-", + "devicesProvisioningServicesCertificates": "pcert-", + "documentDBDatabaseAccounts": "cosmos-", + "eventGridDomains": "evgd-", + "eventGridDomainsTopics": "evgt-", + "eventGridEventSubscriptions": "evgs-", + "eventHubNamespaces": "evhns-", + "eventHubNamespacesEventHubs": "evh-", + "hdInsightClustersHadoop": "hadoop-", + "hdInsightClustersHbase": "hbase-", + "hdInsightClustersKafka": "kafka-", + "hdInsightClustersMl": "mls-", + "hdInsightClustersSpark": "spark-", + "hdInsightClustersStorm": "storm-", + "hybridComputeMachines": "arcs-", + "insightsActionGroups": "ag-", + "insightsComponents": "appi-", + "keyVaultVaults": "kv-", + "kubernetesConnectedClusters": "arck", + "kustoClusters": "dec", + "kustoClustersDatabases": "dedb", + "logicIntegrationAccounts": "ia-", + "logicWorkflows": "logic-", + "machineLearningServicesWorkspaces": "mlw-", + "managedIdentityUserAssignedIdentities": "id-", + "managementManagementGroups": "mg-", + "migrateAssessmentProjects": "migr-", + "networkApplicationGateways": "agw-", + "networkApplicationSecurityGroups": "asg-", + "networkAzureFirewalls": "afw-", + "networkBastionHosts": "bas-", + "networkConnections": "con-", + "networkDnsZones": "dnsz-", + "networkExpressRouteCircuits": "erc-", + "networkFirewallPolicies": "afwp-", + "networkFirewallPoliciesWebApplication": "waf", + "networkFirewallPoliciesRuleGroups": "wafrg", + "networkFrontDoors": "fd-", + "networkFrontdoorWebApplicationFirewallPolicies": "fdfp-", + "networkLoadBalancersExternal": "lbe-", + "networkLoadBalancersInternal": "lbi-", + "networkLoadBalancersInboundNatRules": "rule-", + "networkLocalNetworkGateways": "lgw-", + "networkNatGateways": "ng-", + "networkNetworkInterfaces": "nic-", + "networkNetworkSecurityGroups": "nsg-", + "networkNetworkSecurityGroupsSecurityRules": "nsgsr-", + "networkNetworkWatchers": "nw-", + "networkPrivateDnsZones": "pdnsz-", + "networkPrivateLinkServices": "pl-", + "networkPublicIPAddresses": "pip-", + "networkPublicIPPrefixes": "ippre-", + "networkRouteFilters": "rf-", + "networkRouteTables": "rt-", + "networkRouteTablesRoutes": "udr-", + "networkTrafficManagerProfiles": "traf-", + "networkVirtualNetworkGateways": "vgw-", + "networkVirtualNetworks": "vnet-", + "networkVirtualNetworksSubnets": "snet-", + "networkVirtualNetworksVirtualNetworkPeerings": "peer-", + "networkVirtualWans": "vwan-", + "networkVpnGateways": "vpng-", + "networkVpnGatewaysVpnConnections": "vcn-", + "networkVpnGatewaysVpnSites": "vst-", + "notificationHubsNamespaces": "ntfns-", + "notificationHubsNamespacesNotificationHubs": "ntf-", + "operationalInsightsWorkspaces": "log-", + "portalDashboards": "dash-", + "powerBIDedicatedCapacities": "pbi-", + "purviewAccounts": "pview-", + "recoveryServicesVaults": "rsv-", + "resourcesResourceGroups": "rg-", + "searchSearchServices": "srch-", + "serviceBusNamespaces": "sb-", + "serviceBusNamespacesQueues": "sbq-", + "serviceBusNamespacesTopics": "sbt-", + "serviceEndPointPolicies": "se-", + "serviceFabricClusters": "sf-", + "signalRServiceSignalR": "sigr", + "sqlManagedInstances": "sqlmi-", + "sqlServers": "sql-", + "sqlServersDataWarehouse": "sqldw-", + "sqlServersDatabases": "sqldb-", + "sqlServersDatabasesStretch": "sqlstrdb-", + "storageStorageAccounts": "st", + "storageStorageAccountsVm": "stvm", + "storSimpleManagers": "ssimp", + "streamAnalyticsCluster": "asa-", + "synapseWorkspaces": "syn", + "synapseWorkspacesAnalyticsWorkspaces": "synw", + "synapseWorkspacesSqlPoolsDedicated": "syndp", + "synapseWorkspacesSqlPoolsSpark": "synsp", + "timeSeriesInsightsEnvironments": "tsi-", + "webServerFarms": "plan-", + "webSitesAppService": "app-", + "webSitesAppServiceEnvironment": "ase-", + "webSitesFunctions": "func-", + "webStaticSites": "stapp-" +} diff --git a/samples/csharp-web/infra/app/web.bicep b/samples/csharp-web/infra/app/web.bicep new file mode 100644 index 0000000..975f911 --- /dev/null +++ b/samples/csharp-web/infra/app/web.bicep @@ -0,0 +1,221 @@ +extension 'br:mcr.microsoft.com/bicep/extensions/microsoftgraph/v1.0:0.1.8-preview' + +@description('Name of the container app') +param name string +param location string = resourceGroup().location +param tags object = {} + +param identityName string +param containerRegistryName string +param containerAppsEnvironmentName string +param exists bool +param resourceToken string +param deploymentTimestamp string + +@description('SharePoint Embedded container type id to expose to the app') +param containerTypeId string = '' + +@secure() +param appDefinition object + +// Microsoft Graph delegated permissions the SPE app needs. +var graphAppId = '00000003-0000-0000-c000-000000000000' +var fileStorageContainerSelectedId = '085ca537-6565-41c2-aca7-db852babc212' // FileStorageContainer.Selected (delegated) +var userReadId = 'e1fe6dd8-ba31-4d61-89e7-88639da4683d' // User.Read (delegated) + +var appSettingsArray = filter(array(appDefinition.settings), i => i.name != '') +var secrets = map(filter(appSettingsArray, i => i.?secret != null), i => { + name: i.name + value: i.value + secretRef: i.?secretRef ?? take(replace(replace(toLower(i.name), '_', '-'), '.', '-'), 32) +}) +var appEnv = map(filter(appSettingsArray, i => i.?secret == null), i => { + name: i.name + value: i.value +}) + +resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' existing = { + name: identityName +} + +resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-01-01-preview' existing = { + name: containerRegistryName +} + +resource containerAppsEnvironment 'Microsoft.App/managedEnvironments@2023-05-01' existing = { + name: containerAppsEnvironmentName +} + +var appFqdn = '${name}.${containerAppsEnvironment.properties.defaultDomain}' + +// Entra application that trusts the container app's managed identity via a +// federated credential — the app authenticates to Microsoft Graph / SPE with NO +// client secret (SignedAssertionFromManagedIdentity). +resource azureAdApp 'Microsoft.Graph/applications@v1.0' = { + displayName: 'SharePoint Embedded App' + uniqueName: 'spe-app-${resourceToken}-${uniqueString(resourceToken, deploymentTimestamp)}' + web: { + redirectUris: [ + 'https://${appFqdn}/signin-oidc' + ] + logoutUrl: 'https://${appFqdn}/signout-oidc' + } + requiredResourceAccess: [ + { + // Microsoft Graph + resourceAppId: graphAppId + resourceAccess: [ + { + // FileStorageContainer.Selected (delegated) — SPE content access + id: fileStorageContainerSelectedId + type: 'Scope' + } + { + // User.Read (delegated) + id: userReadId + type: 'Scope' + } + ] + } + ] + + resource managedIdentityFederatedCredential 'federatedIdentityCredentials@v1.0' = { + name: '${azureAdApp.uniqueName}/managed-identity-federation' + description: 'Trust the container app managed identity to impersonate the Entra application' + audiences: [ + 'api://AzureADTokenExchange' + ] + issuer: '${environment().authentication.loginEndpoint}${tenant().tenantId}/v2.0' + subject: identity.properties.principalId + } +} + +// Least-privilege: grant ONLY AcrPull to the managed identity (no registry admin creds). +resource acrPullRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: containerRegistry + name: guid(subscription().id, resourceGroup().id, identity.id, 'acrPullRole') + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + principalType: 'ServicePrincipal' + principalId: identity.properties.principalId + } +} + +module fetchLatestImage '../modules/fetch-container-image.bicep' = { + name: '${name}-fetch-image' + params: { + exists: exists + name: name + } +} + +resource app 'Microsoft.App/containerApps@2023-05-02-preview' = { + name: name + location: location + tags: union(tags, { 'azd-service-name': 'web' }) + dependsOn: [ + acrPullRole + ] + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${identity.id}': {} + } + } + properties: { + managedEnvironmentId: containerAppsEnvironment.id + configuration: { + ingress: { + external: true + targetPort: 8080 + transport: 'auto' + } + registries: [ + { + server: '${containerRegistryName}.azurecr.io' + identity: identity.id + } + ] + secrets: union([], map(secrets, secret => { + name: secret.secretRef + value: secret.value + })) + } + template: { + containers: [ + { + image: fetchLatestImage.outputs.?containers[?0].?image ?? 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' + name: 'main' + env: union([ + { + name: 'PORT' + value: '8080' + } + { + name: 'AzureAd__Instance' + value: environment().authentication.loginEndpoint + } + { + name: 'AzureAd__TenantId' + value: tenant().tenantId + } + { + name: 'AzureAd__ClientId' + value: azureAdApp.appId + } + { + name: 'AzureAd__CallbackPath' + value: '/signin-oidc' + } + { + name: 'AzureAd__SignedOutCallbackPath' + value: '/signout-callback-oidc' + } + { + name: 'AzureAd__ClientCredentials__0__SourceType' + value: 'SignedAssertionFromManagedIdentity' + } + { + name: 'AzureAd__ClientCredentials__0__ManagedIdentityClientId' + value: identity.properties.clientId + } + { + name: 'AzureAd__ClientCredentials__0__TokenExchangeUrl' + value: 'api://AzureADTokenExchange/.default' + } + { + name: 'SharePointEmbedded__TenantId' + value: tenant().tenantId + } + { + name: 'SharePointEmbedded__ClientId' + value: azureAdApp.appId + } + { + name: 'SharePointEmbedded__ContainerTypeId' + value: containerTypeId + } + ], appEnv, map(secrets, secret => { + name: secret.name + secretRef: secret.secretRef + })) + resources: { + cpu: json('0.5') + memory: '1.0Gi' + } + } + ] + scale: { + minReplicas: 1 + maxReplicas: 10 + } + } + } +} + +output name string = app.name +output uri string = 'https://${app.properties.configuration.ingress.fqdn}' +output id string = app.id +output appId string = azureAdApp.appId +output appUniqueName string = azureAdApp.uniqueName +output defaultDomain string = containerAppsEnvironment.properties.defaultDomain diff --git a/samples/csharp-web/infra/main.bicep b/samples/csharp-web/infra/main.bicep new file mode 100644 index 0000000..7a5e165 --- /dev/null +++ b/samples/csharp-web/infra/main.bicep @@ -0,0 +1,99 @@ +targetScope = 'subscription' + +@minLength(1) +@maxLength(64) +@description('Name of the environment used to derive the resource group and resource names') +param environmentName string + +@minLength(1) +@description('Primary location for all resources') +param location string + +@description('Whether the web container app already exists (azd sets this on redeploys)') +param webExists bool = false + +@secure() +@description('Extra app settings/secrets for the web service (azd convention)') +param webDefinition object + +@description('Id of the user or service principal to assign application roles') +param principalId string = '' + +@description('SharePoint Embedded container type id to surface to the app') +param speContainerTypeId string = '' + +@description('Timestamp that keeps the federated Entra app uniqueName stable-yet-unique') +param deploymentTimestamp string = utcNow('yyyyMMddHHmmss') + +// Tags applied to every resource. 'azd-service-name' is applied separately on the host. +var tags = { + 'azd-env-name': environmentName +} + +var abbrs = loadJsonContent('./abbreviations.json') +var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) + +resource rg 'Microsoft.Resources/resourceGroups@2022-09-01' = { + name: 'rg-${environmentName}' + location: location + tags: tags +} + +module identity './shared/identity.bicep' = { + name: 'identity' + scope: rg + params: { + name: '${abbrs.managedIdentityUserAssignedIdentities}spe-${resourceToken}' + location: location + tags: tags + } +} + +module registry './shared/registry.bicep' = { + name: 'registry' + scope: rg + params: { + name: '${abbrs.containerRegistryRegistries}${resourceToken}' + location: location + tags: tags + } +} + +module appsEnv './shared/apps-env.bicep' = { + name: 'apps-env' + scope: rg + params: { + name: '${abbrs.appManagedEnvironments}${resourceToken}' + location: location + tags: tags + } +} + +module web './app/web.bicep' = { + name: 'web' + scope: rg + params: { + name: '${abbrs.appContainerApps}spe-${resourceToken}' + location: location + tags: tags + identityName: identity.outputs.name + containerAppsEnvironmentName: appsEnv.outputs.name + containerRegistryName: registry.outputs.name + exists: webExists + appDefinition: webDefinition + resourceToken: resourceToken + containerTypeId: speContainerTypeId + deploymentTimestamp: deploymentTimestamp + } +} + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = registry.outputs.loginServer +output MANAGED_IDENTITY_CLIENT_ID string = identity.outputs.clientId +output MANAGED_IDENTITY_PRINCIPAL_ID string = identity.outputs.principalId +output AZURE_CLIENT_ID string = web.outputs.appId +output SERVICE_WEB_NAME string = web.outputs.name +output SERVICE_WEB_URI string = web.outputs.uri +output AZURE_APP_UNIQUE_NAME string = web.outputs.appUniqueName + +// Echoed for reference (used by azd). +output DEPLOYMENT_PRINCIPAL_ID string = principalId diff --git a/samples/csharp-web/infra/main.parameters.json b/samples/csharp-web/infra/main.parameters.json new file mode 100644 index 0000000..f4b7487 --- /dev/null +++ b/samples/csharp-web/infra/main.parameters.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "environmentName": { + "value": "${AZURE_ENV_NAME}" + }, + "location": { + "value": "${AZURE_LOCATION}" + }, + "principalId": { + "value": "${AZURE_PRINCIPAL_ID}" + }, + "speContainerTypeId": { + "value": "${SPE_CONTAINER_TYPE_ID=}" + }, + "webExists": { + "value": "${SERVICE_WEB_RESOURCE_EXISTS=false}" + }, + "webDefinition": { + "value": { + "settings": [] + } + } + } +} diff --git a/samples/csharp-web/infra/modules/fetch-container-image.bicep b/samples/csharp-web/infra/modules/fetch-container-image.bicep new file mode 100644 index 0000000..78d1e7e --- /dev/null +++ b/samples/csharp-web/infra/modules/fetch-container-image.bicep @@ -0,0 +1,8 @@ +param exists bool +param name string + +resource existingApp 'Microsoft.App/containerApps@2023-05-02-preview' existing = if (exists) { + name: name +} + +output containers array = exists ? existingApp.properties.template.containers : [] diff --git a/samples/csharp-web/infra/shared/apps-env.bicep b/samples/csharp-web/infra/shared/apps-env.bicep new file mode 100644 index 0000000..8d12634 --- /dev/null +++ b/samples/csharp-web/infra/shared/apps-env.bicep @@ -0,0 +1,14 @@ +param name string +param location string = resourceGroup().location +param tags object = {} + +resource containerAppsEnvironment 'Microsoft.App/managedEnvironments@2022-10-01' = { + name: name + location: location + tags: tags + properties: { + } +} + +output name string = containerAppsEnvironment.name +output domain string = containerAppsEnvironment.properties.defaultDomain diff --git a/samples/csharp-web/infra/shared/identity.bicep b/samples/csharp-web/infra/shared/identity.bicep new file mode 100644 index 0000000..c87b25a --- /dev/null +++ b/samples/csharp-web/infra/shared/identity.bicep @@ -0,0 +1,14 @@ +param name string +param location string = resourceGroup().location +param tags object = {} + +resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: name + location: location + tags: tags +} + +output id string = identity.id +output principalId string = identity.properties.principalId +output clientId string = identity.properties.clientId +output name string = identity.name diff --git a/samples/csharp-web/infra/shared/registry.bicep b/samples/csharp-web/infra/shared/registry.bicep new file mode 100644 index 0000000..22c8971 --- /dev/null +++ b/samples/csharp-web/infra/shared/registry.bicep @@ -0,0 +1,36 @@ +param name string +param location string = resourceGroup().location +param tags object = {} + +param adminUserEnabled bool = false +param anonymousPullEnabled bool = false +param dataEndpointEnabled bool = false +param encryption object = { + status: 'disabled' +} +param networkRuleBypassOptions string = 'AzureServices' +param publicNetworkAccess string = 'Enabled' +param sku object = { + name: 'Standard' +} +param zoneRedundancy string = 'Disabled' + +// 2023-01-01-preview needed for anonymousPullEnabled +resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-01-01-preview' = { + name: name + location: location + tags: tags + sku: sku + properties: { + adminUserEnabled: adminUserEnabled + anonymousPullEnabled: anonymousPullEnabled + dataEndpointEnabled: dataEndpointEnabled + encryption: encryption + networkRuleBypassOptions: networkRuleBypassOptions + publicNetworkAccess: publicNetworkAccess + zoneRedundancy: zoneRedundancy + } +} + +output loginServer string = containerRegistry.properties.loginServer +output name string = containerRegistry.name diff --git a/samples/csharp-web/spe-sample-csharp-web.csproj b/samples/csharp-web/spe-sample-csharp-web.csproj new file mode 100644 index 0000000..ebe70c8 --- /dev/null +++ b/samples/csharp-web/spe-sample-csharp-web.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + app + SpeReferenceApp + + + + + PreserveNewest + + + + diff --git a/samples/react-spa-functions/.gitignore b/samples/react-spa-functions/.gitignore new file mode 100644 index 0000000..ef15ab5 --- /dev/null +++ b/samples/react-spa-functions/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.env +.azure/ +*.local +*.tsbuildinfo diff --git a/samples/react-spa-functions/README.md b/samples/react-spa-functions/README.md new file mode 100644 index 0000000..c2a9135 --- /dev/null +++ b/samples/react-spa-functions/README.md @@ -0,0 +1,28 @@ +# spe-sample-react-spa-functions + +A runnable **SharePoint Embedded** React SPA (Vite + TypeScript), scaffolded by the SPE Builder MCP. +Signs in with MSAL as your owning app and lists the container type's containers and files via Microsoft Graph. + +## Configuration + +`project_hydrate_config` writes SPE settings into `.env` as Vite variables: +`VITE_TENANT_ID`, `VITE_CLIENT_ID`, `VITE_CONTAINER_TYPE_ID`, `VITE_CONTAINER_ID`. + +## Run locally + +```bash +npm install +npm run dev # Vite dev server on http://localhost:5173 +``` + +## Deploy to Azure + +```bash +azd up # provisions a resource group + Azure Static Web App (Free) and deploys dist/ +``` + +The infrastructure in `infra/` is **subscription-scoped** and creates its own resource group, so +`azd up --no-prompt` needs only an environment name, location, and subscription. + +> Sign-in note: the owning Entra app must allow this app's origin as a **SPA redirect URI** +> (`project_deploy` adds the deployed URL automatically; for local dev add `http://localhost:5173`). diff --git a/samples/react-spa-functions/azure.yaml b/samples/react-spa-functions/azure.yaml new file mode 100644 index 0000000..5b96a45 --- /dev/null +++ b/samples/react-spa-functions/azure.yaml @@ -0,0 +1,10 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json +name: spe-sample-react-spa-functions +metadata: + template: spe-builder-mcp +services: + web: + project: . + language: js + host: staticwebapp + dist: dist diff --git a/samples/react-spa-functions/index.html b/samples/react-spa-functions/index.html new file mode 100644 index 0000000..3cc9d69 --- /dev/null +++ b/samples/react-spa-functions/index.html @@ -0,0 +1,17 @@ + + + + + + + + SharePoint Embedded Reference App + + +
+ + + diff --git a/samples/react-spa-functions/infra/main.bicep b/samples/react-spa-functions/infra/main.bicep new file mode 100644 index 0000000..6e1999e --- /dev/null +++ b/samples/react-spa-functions/infra/main.bicep @@ -0,0 +1,32 @@ +targetScope = 'subscription' + +@minLength(1) +@maxLength(64) +@description('Name of the environment used to derive the resource group and resource names') +param environmentName string + +@minLength(1) +@description('Primary location. Azure Static Web Apps (Free) supports e.g. westus2, centralus, eastus2, westeurope, eastasia.') +param location string + +var tags = { 'azd-env-name': environmentName } +var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) + +resource rg 'Microsoft.Resources/resourceGroups@2022-09-01' = { + name: 'rg-${environmentName}' + location: location + tags: tags +} + +module web './web.bicep' = { + name: 'web' + scope: rg + params: { + name: 'swa-${resourceToken}' + location: location + tags: tags + } +} + +output SERVICE_WEB_NAME string = web.outputs.name +output SERVICE_WEB_URI string = web.outputs.uri diff --git a/samples/react-spa-functions/infra/main.parameters.json b/samples/react-spa-functions/infra/main.parameters.json new file mode 100644 index 0000000..579c3be --- /dev/null +++ b/samples/react-spa-functions/infra/main.parameters.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "environmentName": { "value": "${AZURE_ENV_NAME}" }, + "location": { "value": "${AZURE_LOCATION}" } + } +} diff --git a/samples/react-spa-functions/infra/web.bicep b/samples/react-spa-functions/infra/web.bicep new file mode 100644 index 0000000..6c43087 --- /dev/null +++ b/samples/react-spa-functions/infra/web.bicep @@ -0,0 +1,36 @@ +@description('Name of the Static Web App') +param name string +param location string = resourceGroup().location +param tags object = {} + +// Azure Static Web Apps is only available in a subset of regions, so a deploy +// `location` that is valid for the resource group (e.g. eastus) can still be +// rejected for the Static Web App with "Static Web Apps aren't available in +// ". Map an unsupported region to a supported nearby one so `azd up` +// succeeds regardless of the chosen deploy location (the RG itself can stay in +// the requested region). Keep this list aligned with the SWA availability docs: +// https://learn.microsoft.com/azure/static-web-apps/overview#regions +var swaSupportedRegions = [ + 'centralus' + 'eastus2' + 'westus2' + 'westeurope' + 'eastasia' +] +var swaLocation = contains(swaSupportedRegions, toLower(location)) ? location : 'eastus2' + +// azd matches this resource to the azure.yaml 'web' service via azd-service-name. +resource swa 'Microsoft.Web/staticSites@2023-12-01' = { + name: name + location: swaLocation + tags: union(tags, { 'azd-service-name': 'web' }) + sku: { name: 'Free', tier: 'Free' } + properties: { + allowConfigFileUpdates: true + provider: 'Custom' + stagingEnvironmentPolicy: 'Enabled' + } +} + +output name string = swa.name +output uri string = 'https://${swa.properties.defaultHostname}' diff --git a/samples/react-spa-functions/package.json b/samples/react-spa-functions/package.json new file mode 100644 index 0000000..71493f2 --- /dev/null +++ b/samples/react-spa-functions/package.json @@ -0,0 +1,26 @@ +{ + "name": "spe-sample-react-spa-functions", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@azure/msal-browser": "^3.10.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.55", + "@types/react-dom": "^18.2.19", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.3.3", + "vite": "^6.3.6" + }, + "overrides": { + "esbuild": "^0.25.0" + } +} diff --git a/samples/react-spa-functions/src/App.tsx b/samples/react-spa-functions/src/App.tsx new file mode 100644 index 0000000..32d6016 --- /dev/null +++ b/samples/react-spa-functions/src/App.tsx @@ -0,0 +1,416 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + PublicClientApplication, + InteractionRequiredAuthError, + type AccountInfo, +} from "@azure/msal-browser"; + +// SPE settings injected by project_hydrate_config into .env (Vite VITE_* vars). +const cfg = { + tenantId: import.meta.env.VITE_TENANT_ID ?? "", + clientId: import.meta.env.VITE_CLIENT_ID ?? "", + containerTypeId: import.meta.env.VITE_CONTAINER_TYPE_ID ?? "", + containerId: import.meta.env.VITE_CONTAINER_ID ?? "", +}; + +const GRAPH = "https://graph.microsoft.com/v1.0"; +// Container creation goes through the Graph **beta** endpoint, and the owning app +// (this app, a public client) can create containers on the container type it +// owns when the signed-in user is an owner of that container type. +const GRAPH_BETA = "https://graph.microsoft.com/beta"; +const SCOPES = ["https://graph.microsoft.com/FileStorageContainer.Selected"]; + +const pca = new PublicClientApplication({ + auth: { + clientId: cfg.clientId, + authority: `https://login.microsoftonline.com/${cfg.tenantId}`, + redirectUri: window.location.origin, + }, + cache: { cacheLocation: "sessionStorage" }, +}); + +// Turn a Microsoft Entra (AAD) sign-in error into clear, actionable guidance for +// the SERVER-SIDE app-registration / redirect-URI failures (AADSTS9002326 +// cross-origin SPA token redemption, AADSTS50011 redirect URI mismatch) that +// otherwise surface as an opaque 400. This is a byte-for-byte copy of the +// canonical, unit-tested helper in the SPE Builder MCP +// (src/auth-error-guidance.ts); auth-error-guidance.test.ts asserts this sample +// stays in sync. Returns null for unrelated errors. +function interpretAuthError(errorText: string, origin: string): string | null { + const text = errorText || ""; + const isCrossOrigin = text.includes("AADSTS9002326"); + const isRedirectMismatch = text.includes("AADSTS50011"); + if (!isCrossOrigin && !isRedirectMismatch) { + return null; + } + const cause = isCrossOrigin + ? "AADSTS9002326: Entra refused to redeem the sign-in code because the request came from a cross-origin Single-Page Application (SPA) caller whose origin is not registered." + : "AADSTS50011: redirect URI mismatch — this app's current origin is not listed as a redirect URI on the owning Entra app registration."; + const azBody = + '"{\\"spa\\":{\\"redirectUris\\":[\\"' + origin + '\\"]}}"'; + return [ + "Sign-in failed because of a SERVER-SIDE Microsoft Entra app-registration issue.", + "", + "This is NOT a bug in this app and NOT a stale or not-reloaded dev server: the", + "owning Entra app registration is missing a Single-Page Application (SPA) redirect", + "URI for this app's origin, so re-running the same client build keeps failing.", + "", + cause, + "", + "Fix: add this app's origin as a Single-page application (SPA) redirect URI on the", + "owning Entra app registration:", + "", + " " + origin, + "", + "How to apply it:", + " - Newly provisioned apps: re-run provisioning / deploy — it now adds this SPA", + " redirect URI automatically.", + " - An app created before that fix (or a deployed origin not yet added): add it", + " manually —", + " Portal: Entra ID > App registrations > (this app) > Authentication >", + " Add a platform > Single-page application > Redirect URI:", + " " + origin, + " or with Azure CLI (replace with the app registration object id):", + " az rest --method PATCH --uri \"https://graph.microsoft.com/v1.0/applications/\" --headers \"Content-Type=application/json\" --body " + + azBody, + "", + "Entra app-registration changes are server-side: re-provision / redeploy to apply", + "them. They are NOT picked up by client hot-reload.", + ].join("\n"); +} + +// Pull the most descriptive text out of an MSAL/Graph error, then interpret it. +// Returns actionable guidance for a known app-registration error, else null. +function explainAuthError(e: unknown): string | null { + const err = e as { errorCode?: string; errorMessage?: string; message?: string } | null; + const text = + [err?.errorCode, err?.errorMessage, err?.message].filter(Boolean).join(" ") || String(e); + const guidance = interpretAuthError(text, window.location.origin); + if (guidance) console.error(guidance); + return guidance; +} + +// Humanize a byte count for the file list (e.g. 2048 → "2.0 KB"). +function formatSize(bytes?: number): string { + if (typeof bytes !== "number" || bytes < 0) return ""; + if (bytes < 1024) return `${bytes} B`; + const units = ["KB", "MB", "GB", "TB"]; + let v = bytes / 1024; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i++; + } + return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`; +} + +// Derive up-to-two-letter initials from a UPN/email for the account avatar. +function initials(username?: string): string { + const s = (username ?? "").trim(); + if (!s) return "?"; + const namePart = s.split("@")[0]; + const parts = namePart.split(/[.\-_ ]+/).filter(Boolean); + const chars = parts.length >= 2 ? parts[0][0] + parts[1][0] : namePart.slice(0, 2); + return chars.toUpperCase(); +} + +interface Container { + id: string; + displayName: string; + status?: string; +} + +interface DriveItem { + id: string; + name: string; + size?: number; + folder?: unknown; + webUrl?: string; +} + +export function App() { + const [ready, setReady] = useState(false); + const [account, setAccount] = useState(null); + const [containers, setContainers] = useState([]); + const [files, setFiles] = useState([]); + const [newName, setNewName] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + useEffect(() => { + pca + .initialize() + .then(() => { + const acct = pca.getAllAccounts()[0] ?? null; + if (acct) { + pca.setActiveAccount(acct); + setAccount(acct); + } + setReady(true); + }) + .catch((e) => setError(explainAuthError(e) ?? String(e))); + }, []); + + const getToken = useCallback(async (): Promise => { + try { + const r = await pca.acquireTokenSilent({ scopes: SCOPES, account: account ?? undefined }); + return r.accessToken; + } catch (e) { + if (e instanceof InteractionRequiredAuthError) { + const r = await pca.acquireTokenPopup({ scopes: SCOPES }); + return r.accessToken; + } + throw e; + } + }, [account]); + + const signIn = useCallback(async () => { + setError(""); + try { + const r = await pca.loginPopup({ scopes: SCOPES }); + pca.setActiveAccount(r.account); + setAccount(r.account); + } catch (e) { + setError(explainAuthError(e) ?? `Sign-in failed: ${String(e)}`); + } + }, []); + + const signOut = useCallback(async () => { + await pca.logoutPopup(); + setAccount(null); + setContainers([]); + setFiles([]); + }, []); + + const loadContainers = useCallback(async () => { + setBusy(true); + setError(""); + try { + const token = await getToken(); + const res = await fetch( + `${GRAPH}/storage/fileStorage/containers?$filter=containerTypeId eq ${cfg.containerTypeId}`, + { headers: { Authorization: `Bearer ${token}` } }, + ); + if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); + const data = await res.json(); + setContainers(data.value ?? []); + } catch (e) { + setError(explainAuthError(e) ?? `Could not list containers: ${String(e)}`); + } finally { + setBusy(false); + } + }, [getToken]); + + const loadFiles = useCallback( + async (containerId: string) => { + setBusy(true); + setError(""); + try { + const token = await getToken(); + const res = await fetch(`${GRAPH}/drives/${containerId}/root/children`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); + const data = await res.json(); + setFiles(data.value ?? []); + } catch (e) { + setError(explainAuthError(e) ?? `Could not list files: ${String(e)}`); + } finally { + setBusy(false); + } + }, + [getToken], + ); + + // Create a container of the configured container type. This works when this + // app is the OWNING APP of the container type and the signed-in user is an + // owner of it (Microsoft Graph beta). The new container is activated so it is + // immediately usable. + const createContainer = useCallback(async () => { + const name = newName.trim(); + if (!name) { + setError("Enter a name for the new container."); + return; + } + if (!cfg.containerTypeId) { + setError("No container type configured (VITE_CONTAINER_TYPE_ID)."); + return; + } + setBusy(true); + setError(""); + try { + const token = await getToken(); + const res = await fetch(`${GRAPH_BETA}/storage/fileStorage/containers`, { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ displayName: name, containerTypeId: cfg.containerTypeId }), + }); + if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); + const created = (await res.json()) as Container; + // Activate the new container so it is usable right away (best-effort). + await fetch(`${GRAPH_BETA}/storage/fileStorage/containers/${created.id}/activate`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + }).catch(() => undefined); + setNewName(""); + await loadContainers(); + } catch (e) { + setError(explainAuthError(e) ?? `Could not create container: ${String(e)}`); + } finally { + setBusy(false); + } + }, [getToken, loadContainers, newName]); + + const configRows = useMemo( + () => [ + ["Tenant", cfg.tenantId], + ["Client (owning app)", cfg.clientId], + ["Container type", cfg.containerTypeId], + ["Default container", cfg.containerId], + ], + [], + ); + + return ( +
+
+
+
+
SharePoint Embedded
+
Reference app
+
+
+ {account && ( +
+
{initials(account.username)}
+ {account.username} + +
+ )} +
+ +
+ {!ready ? ( +
+ Initializing… +
+ ) : !account ? ( +
+
🗂️
+

Your SharePoint Embedded app

+

+ A runnable React starter scaffolded by the SPE Builder. Sign in to browse and create the + storage containers of your container type, and explore their files. +

+ +
+ ) : ( + <> +
+
+

Containers

+

Storage containers in your container type.

+
+ +
+ +
+ + +
+ + {containers.length > 0 ? ( +
+ {containers.map((c) => ( +
+
📦
+
+
{c.displayName}
+
+ {c.status ?? "active"} +
+
+ +
+ ))} +
+ ) : ( +
+
📭
+

+ No containers loaded yet. Click Refresh to list them, or create your + first one above. +

+
+ )} + + {files.length > 0 && ( +
+
+ 🗃️ Files + + {files.length} item{files.length === 1 ? "" : "s"} + +
+ {files.map((f) => ( +
+ {f.folder ? "📁" : "📄"} + {f.name} + {f.folder ? "—" : formatSize(f.size)} +
+ ))} +
+ )} + + )} + + {error && ( +
+ ⚠️ +
{error}
+
+ )} + +
+ Connection details +
+ {configRows.map(([k, v]) => ( +
+ {k} + {v || (not set)} +
+ ))} +
+
+
+
+ ); +} diff --git a/samples/react-spa-functions/src/main.tsx b/samples/react-spa-functions/src/main.tsx new file mode 100644 index 0000000..d5ec2f7 --- /dev/null +++ b/samples/react-spa-functions/src/main.tsx @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import React from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import "./styles.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/samples/react-spa-functions/src/styles.css b/samples/react-spa-functions/src/styles.css new file mode 100644 index 0000000..30cd855 --- /dev/null +++ b/samples/react-spa-functions/src/styles.css @@ -0,0 +1,632 @@ +/* Copyright (c) Microsoft Corporation. + Licensed under the MIT license. + + Design system for the SharePoint Embedded reference app. Fluent-inspired, + dependency-free (no UI framework) so the scaffolded starter stays tiny and + builds with plain Vite. Light + dark via prefers-color-scheme. */ + +:root { + --bg: #f3f4f8; + --bg-elev: #ffffff; + --surface: #ffffff; + --surface-muted: #f6f8fc; + --border: #e3e6ee; + --border-strong: #d3d7e3; + --text: #1b1f2a; + --text-muted: #5b6273; + --text-subtle: #8a91a3; + --brand: #0f6cbd; + --brand-hover: #115ea3; + --brand-press: #0c4a80; + --brand-soft: #eaf2fb; + --danger-bg: #fdf3f4; + --danger-border: #f3c9cd; + --danger-text: #b10e1c; + --success: #0f7b3f; + --success-soft: #e7f6ee; + --shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.06), 0 1px 3px rgba(16, 24, 40, 0.08); + --shadow-md: 0 4px 16px rgba(16, 24, 40, 0.08), 0 2px 6px rgba(16, 24, 40, 0.05); + --radius: 12px; + --radius-sm: 8px; + --font: "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + --mono: "Cascadia Code", "SF Mono", ui-monospace, "Consolas", monospace; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #14161c; + --bg-elev: #1c1f27; + --surface: #1c1f27; + --surface-muted: #232733; + --border: #2c3140; + --border-strong: #3a4152; + --text: #eef1f7; + --text-muted: #a7aec1; + --text-subtle: #7e8699; + --brand: #4aa3f0; + --brand-hover: #6cb6f5; + --brand-press: #8ac6f8; + --brand-soft: #1b2c3f; + --danger-bg: #2c1b1e; + --danger-border: #5a2b30; + --danger-text: #f7a1a8; + --success: #4cc38a; + --success-soft: #16301f; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-md: 0 6px 22px rgba(0, 0, 0, 0.45); + } +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; +} + +body { + font-family: var(--font); + color: var(--text); + background: + radial-gradient(1200px 600px at 100% -10%, var(--brand-soft), transparent 60%), + var(--bg); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +/* ── App shell ─────────────────────────────────────────────────────────── */ + +.app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.appbar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + gap: 12px; + padding: 14px 24px; + background: color-mix(in srgb, var(--bg-elev) 82%, transparent); + backdrop-filter: saturate(1.4) blur(10px); + border-bottom: 1px solid var(--border); +} + +.appbar__mark { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: 9px; + background: linear-gradient(135deg, var(--brand), #48b0e6); + color: #fff; + font-size: 18px; + box-shadow: var(--shadow-sm); + flex: none; +} + +.appbar__title { + font-size: 15px; + font-weight: 600; + line-height: 1.1; +} + +.appbar__subtitle { + font-size: 12px; + color: var(--text-subtle); +} + +.appbar__spacer { + flex: 1; +} + +.account { + display: flex; + align-items: center; + gap: 10px; +} + +.account__avatar { + display: grid; + place-items: center; + width: 30px; + height: 30px; + border-radius: 50%; + background: var(--brand-soft); + color: var(--brand); + font-weight: 600; + font-size: 13px; + flex: none; +} + +.account__name { + font-size: 13px; + color: var(--text-muted); + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.main { + width: 100%; + max-width: 960px; + margin: 0 auto; + padding: 28px 24px 64px; + flex: 1; +} + +/* ── Hero / signed-out ─────────────────────────────────────────────────── */ + +.hero { + text-align: center; + padding: 56px 24px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-md); +} + +.hero__glyph { + font-size: 44px; + line-height: 1; +} + +.hero h1 { + margin: 18px 0 8px; + font-size: 26px; + letter-spacing: -0.01em; +} + +.hero p { + margin: 0 auto 24px; + max-width: 460px; + color: var(--text-muted); + line-height: 1.55; +} + +/* ── Section header ────────────────────────────────────────────────────── */ + +.page-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; +} + +.page-head h1 { + margin: 0; + font-size: 22px; + letter-spacing: -0.01em; +} + +.page-head p { + margin: 4px 0 0; + color: var(--text-muted); + font-size: 14px; +} + +/* ── Toolbar (create container) ────────────────────────────────────────── */ + +.toolbar { + display: flex; + gap: 10px; + align-items: center; + margin: 20px 0; + flex-wrap: wrap; +} + +.field { + flex: 1; + min-width: 200px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 12px; + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + transition: border-color 0.15s, box-shadow 0.15s; +} + +.field:focus-within { + border-color: var(--brand); + box-shadow: 0 0 0 3px var(--brand-soft); +} + +.field__icon { + color: var(--text-subtle); + font-size: 15px; +} + +.field input { + flex: 1; + border: 0; + outline: 0; + background: transparent; + color: var(--text); + font: inherit; + padding: 10px 0; +} + +/* ── Buttons ───────────────────────────────────────────────────────────── */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 18px; + border-radius: var(--radius-sm); + border: 1px solid transparent; + font: inherit; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, transform 0.05s, box-shadow 0.15s; + white-space: nowrap; +} + +.btn:active { + transform: translateY(1px); +} + +.btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.btn:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--brand-soft); +} + +.btn--primary { + background: var(--brand); + color: #fff; + box-shadow: var(--shadow-sm); +} + +.btn--primary:hover:not(:disabled) { + background: var(--brand-hover); +} + +.btn--primary:active { + background: var(--brand-press); +} + +.btn--lg { + padding: 13px 26px; + font-size: 15px; +} + +.btn--ghost { + background: var(--surface); + color: var(--text); + border-color: var(--border-strong); +} + +.btn--ghost:hover:not(:disabled) { + background: var(--surface-muted); + border-color: var(--brand); + color: var(--brand); +} + +.btn--subtle { + background: transparent; + color: var(--brand); + padding: 7px 12px; +} + +.btn--subtle:hover:not(:disabled) { + background: var(--brand-soft); +} + +/* ── Cards / grid ──────────────────────────────────────────────────────── */ + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 14px; +} + +.card { + display: flex; + align-items: center; + gap: 14px; + padding: 16px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-sm); + transition: box-shadow 0.15s, border-color 0.15s, transform 0.12s; +} + +.card:hover { + box-shadow: var(--shadow-md); + border-color: var(--border-strong); + transform: translateY(-1px); +} + +.card__icon { + display: grid; + place-items: center; + width: 42px; + height: 42px; + border-radius: 10px; + background: var(--brand-soft); + font-size: 20px; + flex: none; +} + +.card__body { + flex: 1; + min-width: 0; +} + +.card__title { + font-weight: 600; + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.card__meta { + font-size: 12px; + color: var(--text-subtle); + margin-top: 2px; +} + +/* ── Pills ─────────────────────────────────────────────────────────────── */ + +.pill { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 9px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + background: var(--surface-muted); + color: var(--text-muted); +} + +.pill--ok { + background: var(--success-soft); + color: var(--success); +} + +.pill--dot::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +/* ── File list ─────────────────────────────────────────────────────────── */ + +.panel { + margin-top: 26px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-sm); + overflow: hidden; +} + +.panel__head { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 18px; + border-bottom: 1px solid var(--border); + font-weight: 600; + font-size: 14px; +} + +.panel__count { + margin-left: auto; + font-weight: 500; + font-size: 12px; + color: var(--text-subtle); +} + +.row { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 18px; + border-bottom: 1px solid var(--border); +} + +.row:last-child { + border-bottom: 0; +} + +.row:hover { + background: var(--surface-muted); +} + +.row__glyph { + font-size: 18px; + flex: none; +} + +.row__name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; +} + +.row__size { + font-size: 12px; + color: var(--text-subtle); + font-variant-numeric: tabular-nums; +} + +/* ── Empty state ───────────────────────────────────────────────────────── */ + +.empty { + text-align: center; + padding: 40px 24px; + color: var(--text-muted); +} + +.empty__glyph { + font-size: 34px; +} + +.empty p { + margin: 10px 0 0; + font-size: 14px; +} + +/* ── Error callout ─────────────────────────────────────────────────────── */ + +.callout { + margin: 22px 0 0; + display: flex; + gap: 12px; + padding: 14px 16px; + background: var(--danger-bg); + border: 1px solid var(--danger-border); + border-radius: var(--radius-sm); + color: var(--danger-text); +} + +.callout__glyph { + font-size: 16px; + line-height: 1.4; + flex: none; +} + +.callout pre { + margin: 0; + font-family: var(--mono); + font-size: 12.5px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + +/* ── Details / config ──────────────────────────────────────────────────── */ + +.details { + margin-top: 32px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + overflow: hidden; +} + +.details > summary { + list-style: none; + cursor: pointer; + padding: 13px 18px; + font-size: 13px; + font-weight: 600; + color: var(--text-muted); + display: flex; + align-items: center; + gap: 8px; +} + +.details > summary::-webkit-details-marker { + display: none; +} + +.details > summary::after { + content: "▸"; + margin-left: auto; + transition: transform 0.15s; + color: var(--text-subtle); +} + +.details[open] > summary::after { + transform: rotate(90deg); +} + +.config { + border-top: 1px solid var(--border); + padding: 6px 18px 14px; +} + +.config__row { + display: flex; + gap: 12px; + padding: 7px 0; + border-bottom: 1px dashed var(--border); + font-size: 13px; +} + +.config__row:last-child { + border-bottom: 0; +} + +.config__key { + width: 150px; + color: var(--text-muted); + flex: none; +} + +.config__val { + font-family: var(--mono); + font-size: 12.5px; + color: var(--text); + word-break: break-all; +} + +.config__val em { + color: var(--text-subtle); + font-family: var(--font); +} + +/* ── Spinner ───────────────────────────────────────────────────────────── */ + +.spinner { + width: 15px; + height: 15px; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + display: inline-block; + animation: spin 0.6s linear infinite; +} + +.center-pad { + display: flex; + align-items: center; + gap: 10px; + justify-content: center; + padding: 40px; + color: var(--text-muted); +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 560px) { + .appbar { + padding: 12px 16px; + } + .main { + padding: 20px 16px 48px; + } + .account__name { + display: none; + } +} diff --git a/samples/react-spa-functions/src/vite-env.d.ts b/samples/react-spa-functions/src/vite-env.d.ts new file mode 100644 index 0000000..1a001cc --- /dev/null +++ b/samples/react-spa-functions/src/vite-env.d.ts @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/// + +interface ImportMetaEnv { + readonly VITE_TENANT_ID: string; + readonly VITE_CLIENT_ID: string; + readonly VITE_CONTAINER_TYPE_ID: string; + readonly VITE_CONTAINER_ID: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/samples/react-spa-functions/staticwebapp.config.json b/samples/react-spa-functions/staticwebapp.config.json new file mode 100644 index 0000000..ee03d1e --- /dev/null +++ b/samples/react-spa-functions/staticwebapp.config.json @@ -0,0 +1,6 @@ +{ + "navigationFallback": { + "rewrite": "/index.html", + "exclude": ["/assets/*", "*.{css,js,png,svg,ico,json}"] + } +} diff --git a/samples/react-spa-functions/tsconfig.json b/samples/react-spa-functions/tsconfig.json new file mode 100644 index 0000000..df0556f --- /dev/null +++ b/samples/react-spa-functions/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/samples/react-spa-functions/vite.config.ts b/samples/react-spa-functions/vite.config.ts new file mode 100644 index 0000000..7d418a2 --- /dev/null +++ b/samples/react-spa-functions/vite.config.ts @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// Vite build output goes to dist/, which azd deploys to Azure Static Web Apps. +export default defineConfig({ + plugins: [react()], + build: { outDir: "dist" }, + // Dev-server port. Must equal the SPE Builder's LOCAL_DEV_PORT + // (mcp-server/src/constants.ts) — the single source from which the SPA redirect + // URI registered on the owning Entra app is derived. If you change + // it here, change it there too or browser sign-in breaks with AADSTS9002326. + server: { port: 5173 }, +}); diff --git a/scripts/generate-third-party-notices.mjs b/scripts/generate-third-party-notices.mjs new file mode 100644 index 0000000..189e09c --- /dev/null +++ b/scripts/generate-third-party-notices.mjs @@ -0,0 +1,159 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Generates THIRD-PARTY-NOTICES for @microsoft/spe-mcp by walking the + * resolved *production* dependency tree (`npm ls --omit=dev --all --json`) and + * collecting each package's declared license plus its license text from + * node_modules. Run after any production-dependency change: + * + * npm run notices + * + * This script is a dev tool and is intentionally NOT in the package `files` + * allow-list, so it is not published in the tarball. + */ +import { execSync } from "node:child_process"; +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const pkgRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const outFile = join(pkgRoot, "THIRD-PARTY-NOTICES"); + +const LICENSE_FILE_RE = /^(LICENSE|LICENCE|COPYING|NOTICE)(\.|$)/i; + +/** Run `npm ls` and tolerate a non-zero exit (npm exits 1 on extraneous/peer warnings). */ +function readProductionTree() { + try { + const out = execSync("npm ls --omit=dev --all --json", { + cwd: pkgRoot, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + stdio: ["ignore", "pipe", "ignore"], + }); + return JSON.parse(out); + } catch (err) { + if (err.stdout) return JSON.parse(err.stdout.toString()); + throw err; + } +} + +/** Recursively collect unique production packages keyed by name@version. */ +function collect(node, name, acc) { + if (!node || typeof node !== "object") return; + if (name && node.version) { + const key = `${name}@${node.version}`; + if (!acc.has(key)) { + acc.set(key, { + name, + version: node.version, + path: node.path || join(pkgRoot, "node_modules", name), + }); + } + } + for (const [depName, depNode] of Object.entries(node.dependencies || {})) { + collect(depNode, depName, acc); + } +} + +function readJson(file) { + try { + return JSON.parse(readFileSync(file, "utf8")); + } catch { + return null; + } +} + +function normalizeLicense(manifest) { + if (!manifest) return "UNKNOWN"; + if (typeof manifest.license === "string") return manifest.license; + if (manifest.license && typeof manifest.license === "object" && manifest.license.type) { + return manifest.license.type; + } + if (Array.isArray(manifest.licenses)) { + return manifest.licenses.map((l) => (typeof l === "string" ? l : l.type)).join(", "); + } + return "UNKNOWN"; +} + +function readLicenseText(pkgPath) { + if (!existsSync(pkgPath)) return null; + let entries; + try { + entries = readdirSync(pkgPath); + } catch { + return null; + } + const file = entries.find((e) => LICENSE_FILE_RE.test(e)); + if (!file) return null; + try { + return readFileSync(join(pkgPath, file), "utf8").trim(); + } catch { + return null; + } +} + +function repoUrl(manifest) { + if (!manifest) return ""; + if (typeof manifest.repository === "string") return manifest.repository; + if (manifest.repository && manifest.repository.url) { + return manifest.repository.url.replace(/^git\+/, "").replace(/\.git$/, ""); + } + return manifest.homepage || ""; +} + +const tree = readProductionTree(); +const rootName = tree.name; +const collected = new Map(); +for (const [depName, depNode] of Object.entries(tree.dependencies || {})) { + collect(depNode, depName, collected); +} + +const packages = [...collected.values()] + .filter((p) => p.name !== rootName) + .sort((a, b) => a.name.localeCompare(b.name) || a.version.localeCompare(b.version)); + +const SEP = "-".repeat(63); +const header = `NOTICES AND INFORMATION +Do Not Translate or Localize + +This software incorporates material from third parties. Microsoft makes certain +open source code available at https://3rdpartysource.microsoft.com, or you may +send a check or money order for US $5.00, including the product name, the open +source component name, and version number, to: + + Source Code Compliance Team + Microsoft Corporation + One Microsoft Way + Redmond, WA 98052 + USA + +Notwithstanding any other terms, you may reverse engineer this software to the +extent required to debug changes to any libraries licensed under the GNU Lesser +General Public License. + +This file lists the third-party production dependencies of ${rootName} and their +licenses. It is generated by scripts/generate-third-party-notices.mjs. +`; + +const blocks = packages.map((p, i) => { + const manifest = readJson(join(p.path, "package.json")); + const license = normalizeLicense(manifest); + const url = repoUrl(manifest); + const text = readLicenseText(p.path); + const lines = [ + SEP, + "", + `${i + 1}. ${p.name} ${p.version} (${license})`, + ]; + if (url) lines.push(url); + lines.push(""); + lines.push(text || `License: ${license} (no bundled license text found).`); + lines.push(""); + return lines.join("\n"); +}); + +const body = `${header}\n${blocks.join("\n")}\n${SEP}\n`; +writeFileSync(outFile, body, "utf8"); +console.log(`Wrote ${outFile} with ${packages.length} third-party packages.`); diff --git a/server.json b/server.json new file mode 100644 index 0000000..d7cee05 --- /dev/null +++ b/server.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "com.microsoft/sharepointembedded-mcp-server", + "description": "MCP server for SharePoint Embedded management", + "version": "0.1.0-alpha.1", + "repository": { + "url": "https://github.com/microsoft/SharePoint-Embedded-MCP-Server", + "source": "github" + }, + "websiteUrl": "https://learn.microsoft.com/sharepoint/dev/embedded/overview", + "packages": [ + { + "registryType": "npm", + "registryBaseUrl": "https://registry.npmjs.org", + "identifier": "@microsoft/spe-mcp", + "version": "0.1.0-alpha.1", + "runtimeHint": "npx", + "transport": { + "type": "stdio" + } + } + ] +} diff --git a/src/auth-error-guidance.test.ts b/src/auth-error-guidance.test.ts new file mode 100644 index 0000000..b918fca --- /dev/null +++ b/src/auth-error-guidance.test.ts @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for the React SPA sign-in guidance. + * + * Focus: the `interpretAuthError` helper that turns an opaque Microsoft Entra + * sign-in failure into clear, actionable guidance for the SERVER-SIDE + * app-registration / redirect-URI errors (AADSTS9002326 cross-origin SPA token + * redemption, AADSTS50011 redirect URI mismatch), plus a drift guard asserting + * the runnable React sample (`samples/react-spa-functions`) embeds the same + * helper and routes its MSAL error paths through it. The sample is the single + * source of truth for the shipped app, so we read it through the real scaffolder + * (`findArchitecture(...).files()`). + */ + +import { describe, it, expect } from "vitest"; +import { interpretAuthError } from "./auth-error-guidance.js"; +import { findArchitecture } from "./reference-architectures.js"; +import { LOCAL_DEV_PORT, LOCAL_SPA_REDIRECT_URI } from "./constants.js"; + +const SPA_ORIGIN = "https://swa-abc123.azurestaticapps.net"; +const LOCAL_ORIGIN = "http://localhost:5173"; + +describe("interpretAuthError — AADSTS9002326 (cross-origin SPA token redemption)", () => { + const msg = interpretAuthError( + "AADSTS9002326: Cross-origin token redemption is permitted only for the 'Single-Page Application' client-type.", + SPA_ORIGIN, + ); + + it("returns guidance (not null)", () => { + expect(msg).not.toBeNull(); + }); + + it("identifies it as a server-side Entra app-registration issue", () => { + expect(msg).toContain("SERVER-SIDE"); + expect(msg).toContain("app-registration"); + }); + + it("makes clear it is not a client bug or a stale dev server", () => { + expect(msg).toContain("NOT a bug in this app"); + expect(msg).toContain("NOT a stale"); + expect(msg).toContain("hot-reload"); + }); + + it("states the concrete fix: register the origin as a SPA redirect URI", () => { + expect(msg).toContain("Single-page application"); + expect(msg).toContain("redirect URI"); + }); + + it("shows the exact, copy-pasteable origin to register", () => { + expect(msg).toContain(SPA_ORIGIN); + // and includes an az rest PATCH body that registers the origin as a SPA URI + expect(msg).toContain("redirectUris"); + expect(msg).toContain(`[\\"${SPA_ORIGIN}\\"]`); + }); + + it("explains how to apply it (re-provision/deploy or add manually)", () => { + expect(msg).toContain("re-run provisioning"); + expect(msg).toContain("Authentication"); + expect(msg).toContain("az rest --method PATCH"); + }); + + it("references the originating error code", () => { + expect(msg).toContain("AADSTS9002326"); + }); +}); + +describe("interpretAuthError — AADSTS50011 (redirect URI mismatch)", () => { + const msg = interpretAuthError( + "AADSTS50011: The redirect URI specified in the request does not match the redirect URIs configured for the application.", + LOCAL_ORIGIN, + ); + + it("returns guidance (not null)", () => { + expect(msg).not.toBeNull(); + }); + + it("identifies it as a server-side app-registration / redirect-URI issue", () => { + expect(msg).toContain("SERVER-SIDE"); + expect(msg).toContain("app-registration"); + expect(msg).toContain("redirect URI"); + }); + + it("shows the exact origin and the SPA fix", () => { + expect(msg).toContain(LOCAL_ORIGIN); + expect(msg).toContain("Single-page application"); + }); + + it("references the originating error code", () => { + expect(msg).toContain("AADSTS50011"); + }); +}); + +describe("interpretAuthError — unrelated errors fall through to generic handling", () => { + it("returns null for an unrelated AADSTS error", () => { + expect(interpretAuthError("AADSTS50058: Silent sign-in was not possible.", LOCAL_ORIGIN)).toBeNull(); + }); + + it("returns null for a non-auth network error", () => { + expect(interpretAuthError("TypeError: Failed to fetch", LOCAL_ORIGIN)).toBeNull(); + }); + + it("returns null for empty/missing error text", () => { + expect(interpretAuthError("", LOCAL_ORIGIN)).toBeNull(); + }); +}); + +describe("the react-spa-functions sample embeds the interpreter and wires it into the UI error path", () => { + const files = findArchitecture("react-spa-functions")!.files("demo-app"); + const app = files["src/App.tsx"]; + + it("embeds the interpretAuthError helper", () => { + expect(app).toContain("function interpretAuthError(errorText: string, origin: string): string | null"); + }); + + it("interprets the same AAD error codes the unit tests cover", () => { + expect(app).toContain("AADSTS9002326"); + expect(app).toContain("AADSTS50011"); + expect(app).toContain("SERVER-SIDE"); + }); + + it("uses window.location.origin so the message shows the running origin", () => { + expect(app).toContain("interpretAuthError(text, window.location.origin)"); + }); + + it("routes the sign-in and Graph error paths through explainAuthError", () => { + expect(app).toContain("function explainAuthError(e: unknown): string | null"); + expect(app).toContain("setError(explainAuthError(e) ?? `Sign-in failed:"); + expect(app).toContain("setError(explainAuthError(e) ?? `Could not list containers:"); + expect(app).toContain("setError(explainAuthError(e) ?? `Could not list files:"); + expect(app).toContain("setError(explainAuthError(e) ?? `Could not create container:"); + }); + + it("still exports a valid-looking App component", () => { + expect(app).toContain("export function App()"); + }); +}); + +describe("single source for the local dev port", () => { + it("derives LOCAL_SPA_REDIRECT_URI from LOCAL_DEV_PORT (no hand-kept literal)", () => { + expect(LOCAL_SPA_REDIRECT_URI).toBe(`http://localhost:${LOCAL_DEV_PORT}`); + }); + + it("keeps the dev port resolving to 5173", () => { + expect(LOCAL_DEV_PORT).toBe(5173); + expect(LOCAL_SPA_REDIRECT_URI).toBe("http://localhost:5173"); + }); + + it("the sample's Vite server.port matches the shared dev port — no drift, no stray literal", () => { + const viteConfig = findArchitecture("react-spa-functions")!.files("demo-app")["vite.config.ts"]; + expect(viteConfig).toContain(`port: ${LOCAL_DEV_PORT}`); + expect(LOCAL_SPA_REDIRECT_URI).toContain(String(LOCAL_DEV_PORT)); + }); +}); diff --git a/src/auth-error-guidance.ts b/src/auth-error-guidance.ts new file mode 100644 index 0000000..ac85c53 --- /dev/null +++ b/src/auth-error-guidance.ts @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Actionable guidance for the SERVER-SIDE Microsoft Entra app-registration / + * redirect-URI sign-in failures that otherwise surface in the + * scaffolded React SPA as an opaque 400: + * - AADSTS9002326 — cross-origin token redemption refused because this app's + * origin is not registered as a Single-Page Application (SPA) redirect URI. + * - AADSTS50011 — redirect URI mismatch (the current origin is not listed on + * the owning Entra app registration). + * + * The point is to tell the user this is a SERVER-SIDE Entra app-registration + * issue (not a client bug, not a stale/not-reloaded dev server) and exactly how + * to fix it, showing the precise origin that must be registered. + * + * This is the canonical, unit-tested copy. A byte-for-byte copy of this function + * is embedded in the runnable React sample (`samples/react-spa-functions/src/ + * App.tsx`) so the shipped app shows the same guidance; `auth-error-guidance.test.ts` + * asserts the sample stays in sync. The function is intentionally self-contained + * (no module-scope references) and uses string concatenation (no template + * literals) so the embedded copy reads identically. + */ +export function interpretAuthError(errorText: string, origin: string): string | null { + const text = errorText || ""; + const isCrossOrigin = text.includes("AADSTS9002326"); + const isRedirectMismatch = text.includes("AADSTS50011"); + if (!isCrossOrigin && !isRedirectMismatch) { + return null; + } + const cause = isCrossOrigin + ? "AADSTS9002326: Entra refused to redeem the sign-in code because the request came from a cross-origin Single-Page Application (SPA) caller whose origin is not registered." + : "AADSTS50011: redirect URI mismatch — this app's current origin is not listed as a redirect URI on the owning Entra app registration."; + const azBody = + '"{\\"spa\\":{\\"redirectUris\\":[\\"' + origin + '\\"]}}"'; + return [ + "Sign-in failed because of a SERVER-SIDE Microsoft Entra app-registration issue.", + "", + "This is NOT a bug in this app and NOT a stale or not-reloaded dev server: the", + "owning Entra app registration is missing a Single-Page Application (SPA) redirect", + "URI for this app's origin, so re-running the same client build keeps failing.", + "", + cause, + "", + "Fix: add this app's origin as a Single-page application (SPA) redirect URI on the", + "owning Entra app registration:", + "", + " " + origin, + "", + "How to apply it:", + " - Newly provisioned apps: re-run provisioning / deploy — it now adds this SPA", + " redirect URI automatically.", + " - An app created before that fix (or a deployed origin not yet added): add it", + " manually —", + " Portal: Entra ID > App registrations > (this app) > Authentication >", + " Add a platform > Single-page application > Redirect URI:", + " " + origin, + " or with Azure CLI (replace with the app registration object id):", + " az rest --method PATCH --uri \"https://graph.microsoft.com/v1.0/applications/\" --headers \"Content-Type=application/json\" --body " + + azBody, + "", + "Entra app-registration changes are server-side: re-provision / redeploy to apply", + "them. They are NOT picked up by client hot-reload.", + ].join("\n"); +} diff --git a/src/auth.test.ts b/src/auth.test.ts new file mode 100644 index 0000000..9efd9d0 --- /dev/null +++ b/src/auth.test.ts @@ -0,0 +1,506 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for tenant-safe token caching and account selection. + * + * These cover the regression where a cached identity from a prior tenant + * interfered on a tenant switch: + * - per-tenant cache file partitioning (no co-mingling), + * - getCachedAccount() preferring a home-tenant match, and falling back to a + * partitioned-cache (guest/B2B) candidate to attempt silent — with the + * authoritative wrong-tenant guard on the ISSUED TOKEN (isWrongTenantToken), + * - setAuthConfig() tenant change dropping stale in-memory state. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { + AccountInfo, + AuthenticationResult, + DeviceCodeRequest, + PublicClientApplication, +} from "@azure/msal-node"; +import { + __testing, + formatLoginSuccessMessage, + getAccessToken, + getCacheFilePath, + getCachedAccount, + homeTenantOf, + isOwningAppConfigured, + isWrongTenantToken, + OWNING_APP_REQUIRED_MESSAGE, + renderAuthErrorHtml, + renderAuthSuccessHtml, + selectAccountForTenant, + setAuthConfig, +} from "./auth.js"; +import { AppError } from "./errors.js"; +import { guid, GUID_REGEX } from "./tooling/fields.js"; + +const TENANT_A = "475485dd-63d4-4f8c-af70-60f7a6c74940"; +const TENANT_B = "99999999-9999-9999-9999-999999999999"; +const CLIENT_ID = "11111111-2222-3333-4444-555555555555"; + +/** + * Build a minimal AccountInfo with a given HOME tenant. + * + * The home tenant is encoded here in `homeAccountId` (".") — it + * is what `homeTenantOf()` parses, and it is NOT set by `setAuthConfig()`. + * `setAuthConfig()` only records the CONFIGURED/resource tenant the server + * targets; the two legitimately differ for guest/B2B accounts (home tenant ≠ + * resource tenant), which is why the tests supply them independently. + */ +function account(homeTenant: string, username: string, oid = "oid-0000"): AccountInfo { + return { + homeAccountId: `${oid}.${homeTenant}`, + environment: "login.microsoftonline.com", + tenantId: homeTenant, + username, + localAccountId: oid, + } as AccountInfo; +} + +/** A fake PCA whose token cache returns the supplied accounts. */ +function fakePca(accounts: AccountInfo[]): PublicClientApplication { + // getTokenCache carries an explicit return-type annotation. Without it, its + // type is inferred through the outer `as unknown as PublicClientApplication` + // cast and IntelliSense reports `getTokenCache` as implicitly `any` (ts7022, + // "referenced directly or indirectly in its own initializer"). + return { + getTokenCache: (): { getAllAccounts: () => Promise } => ({ + getAllAccounts: async () => accounts, + }), + } as unknown as PublicClientApplication; +} + +afterEach(() => { + __testing.reset(); +}); + +describe("test identity constants", () => { + // Tenant and client IDs are Entra directory GUIDs. Enforce the canonical GUID + // shape on the fixtures so the auth tests cannot drift onto placeholder values + // that would never occur in a real token/config. Scoped to tenant/client IDs + // (container/containerType IDs are validated as opaque non-empty strings). + const guidSchema = guid("id"); + + it.each([ + ["TENANT_A", TENANT_A], + ["TENANT_B", TENANT_B], + ["CLIENT_ID", CLIENT_ID], + ])("%s is a canonical GUID", (_name, value) => { + expect(value).toMatch(GUID_REGEX); + expect(guidSchema.safeParse(value).success).toBe(true); + }); + + it("the shared guid builder rejects a non-GUID id", () => { + expect(guidSchema.safeParse("not-a-guid").success).toBe(false); + }); +}); + +describe("homeTenantOf", () => { + it("extracts the home tenant from .", () => { + expect(homeTenantOf({ homeAccountId: `abc.${TENANT_A}` })).toBe(TENANT_A); + }); + + it("returns undefined for missing or malformed ids", () => { + expect(homeTenantOf({ homeAccountId: undefined as unknown as string })).toBeUndefined(); + expect(homeTenantOf({ homeAccountId: "no-dot" })).toBeUndefined(); + expect(homeTenantOf({ homeAccountId: "trailing." })).toBeUndefined(); + }); +}); + +describe("getCacheFilePath — partitioning", () => { + it("derives different cache files for different tenants (no co-mingling)", () => { + const pathA = getCacheFilePath({ clientId: CLIENT_ID, tenantId: TENANT_A }); + const pathB = getCacheFilePath({ clientId: CLIENT_ID, tenantId: TENANT_B }); + expect(pathA).not.toBe(pathB); + expect(pathA).toContain(TENANT_A); + expect(pathB).toContain(TENANT_B); + }); + + it("partitions by client id as well", () => { + const p1 = getCacheFilePath({ clientId: "client-one", tenantId: TENANT_A }); + const p2 = getCacheFilePath({ clientId: "client-two", tenantId: TENANT_A }); + expect(p1).not.toBe(p2); + }); + + it("falls back to the legacy single-file path when no tenant is configured", () => { + expect(getCacheFilePath(null).endsWith("token-cache.json")).toBe(true); + }); + + it("uses the currently configured tenant when called with no argument", () => { + setAuthConfig({ clientId: CLIENT_ID, tenantId: TENANT_A }); + expect(getCacheFilePath()).toContain(TENANT_A); + }); +}); + +describe("selectAccountForTenant — wrong-tenant hardening", () => { + it("returns null when the only cached account belongs to a different tenant", () => { + const accounts = [account(TENANT_A, "user@a.com")]; + expect(selectAccountForTenant(accounts, TENANT_B)).toBeNull(); + }); + + it("returns the home-tenant match among multiple accounts (incl. a guest)", () => { + const guest = account(TENANT_A, "guest@corp.com", "guest-oid"); + const member = account(TENANT_B, "member@b.com", "member-oid"); + const chosen = selectAccountForTenant([guest, member], TENANT_B); + expect(chosen).toBe(member); + }); + + it("returns null when no cached account matches the configured tenant", () => { + const accounts = [account(TENANT_A, "a@a.com"), account("99999999-0000-0000-0000-000000000000", "x@x.com")]; + expect(selectAccountForTenant(accounts, TENANT_B)).toBeNull(); + }); + + it("returns the first account only when no tenant is configured (legacy path)", () => { + const accounts = [account(TENANT_A, "a@a.com"), account(TENANT_B, "b@b.com")]; + expect(selectAccountForTenant(accounts, undefined)).toBe(accounts[0]); + }); + + it("returns null for an empty cache", () => { + expect(selectAccountForTenant([], TENANT_A)).toBeNull(); + }); +}); + +describe("getCachedAccount — end to end with injected cache", () => { + it("falls back to a partitioned-cache (guest/B2B) candidate to attempt silent when no home-tenant match", async () => { + // BUG-1 regression: the cache file is partitioned by (tenant, client), so an + // account whose HOME tenant differs (a guest signed into the resource tenant) + // is still valid to TRY. getCachedAccount must return it for a silent attempt + // rather than returning null and forcing interactive sign-in. The real + // wrong-tenant protection is the issued-token check (see isWrongTenantToken). + setAuthConfig({ clientId: CLIENT_ID, tenantId: TENANT_B }); + const guest = account(TENANT_A, "guest@corp.com", "guest-oid"); + __testing.setPca(fakePca([guest])); + expect(await getCachedAccount()).toBe(guest); + }); + + it("prefers the home-tenant match when one exists among guests", async () => { + setAuthConfig({ clientId: CLIENT_ID, tenantId: TENANT_B }); + const member = account(TENANT_B, "member@b.com", "member-oid"); + __testing.setPca(fakePca([account(TENANT_A, "guest@corp.com", "guest-oid"), member])); + expect(await getCachedAccount()).toBe(member); + }); + + it("returns null for an empty cache", async () => { + setAuthConfig({ clientId: CLIENT_ID, tenantId: TENANT_A }); + __testing.setPca(fakePca([])); + expect(await getCachedAccount()).toBeNull(); + }); +}); + +describe("isWrongTenantToken — authoritative issued-token guard", () => { + it("accepts a guest token whose issued tenant matches the configured tenant", () => { + // Why the home and issued tenants differ here (a normal B2B flow, not a corner + // case): a user whose HOME tenant is A can be invited as a guest into tenant B. + // MSAL then mints a token ISSUED for the configured resource tenant B while the + // account's home tenant stays A. Because the tenants legitimately differ, the + // guard keys off the ISSUED tenant and MUST accept this token. + const result = { + tenantId: TENANT_B, + account: account(TENANT_A, "guest@corp.com", "guest-oid"), + }; + expect(isWrongTenantToken(result, TENANT_B)).toBe(false); + }); + + it("rejects a token actually minted for a different tenant", () => { + const result = { + tenantId: TENANT_A, + account: account(TENANT_A, "stale@a.com"), + }; + expect(isWrongTenantToken(result, TENANT_B)).toBe(true); + }); + + it("falls back to the account tenant when result.tenantId is absent", () => { + const result = { tenantId: "", account: account(TENANT_A, "a@a.com") }; + expect(isWrongTenantToken(result, TENANT_B)).toBe(true); + expect(isWrongTenantToken({ tenantId: "", account: account(TENANT_B, "b@b.com") }, TENANT_B)).toBe(false); + }); + + it("does not block when the configured or issued tenant is unknown", () => { + expect(isWrongTenantToken({ tenantId: TENANT_A, account: undefined }, undefined)).toBe(false); + expect(isWrongTenantToken({ tenantId: "", account: undefined }, TENANT_B)).toBe(false); + }); +}); + +describe("setAuthConfig — tenant switch resets stale in-memory state", () => { + it("drops the in-memory PublicClientApplication on a tenant change", () => { + setAuthConfig({ clientId: CLIENT_ID, tenantId: TENANT_A }); + __testing.setPca(fakePca([account(TENANT_A, "a@a.com")])); + expect(__testing.getPca()).not.toBeNull(); + + setAuthConfig({ clientId: CLIENT_ID, tenantId: TENANT_B }); + expect(__testing.getPca()).toBeNull(); + }); + + it("drops the in-memory PublicClientApplication on a client change", () => { + setAuthConfig({ clientId: "client-one", tenantId: TENANT_A }); + __testing.setPca(fakePca([account(TENANT_A, "a@a.com")])); + setAuthConfig({ clientId: "client-two", tenantId: TENANT_A }); + expect(__testing.getPca()).toBeNull(); + }); + + it("keeps in-memory state when the config is unchanged", () => { + setAuthConfig({ clientId: CLIENT_ID, tenantId: TENANT_A }); + const pca = fakePca([account(TENANT_A, "a@a.com")]); + __testing.setPca(pca); + setAuthConfig({ clientId: CLIENT_ID, tenantId: TENANT_A }); + expect(__testing.getPca()).toBe(pca); + }); +}); + +describe("resetInMemoryAuthState — settles in-flight readiness (WI-06 hang fix)", () => { + // Race a promise against a timeout so a REGRESSION surfaces as a clear test + // failure instead of a suite-wide hang. On the fixed code the awaiter settles + // in a microtask, so this resolves effectively instantly. + function withTimeout(p: Promise, ms: number, label: string): Promise { + return Promise.race([ + p, + new Promise((_, reject) => setTimeout(() => reject(new Error(`timeout: ${label}`)), ms)), + ]); + } + + it("rejects a previously-captured in-flight readiness promise with AUTH_RESET after a switch", async () => { + // Model an initializeAuth() that is mid-flight (acquisition pending) against + // the OLD authority. + setAuthConfig({ clientId: CLIENT_ID, tenantId: TENANT_A }); + const inflight = __testing.primeInflightReadiness(); + expect(__testing.getAuthReadyPromise()).not.toBeNull(); + + // A tenant switch drops in-memory MSAL state via resetInMemoryAuthState(). + setAuthConfig({ clientId: CLIENT_ID, tenantId: TENANT_B }); + + // The captured promise MUST settle (reject) rather than be abandoned unsettled — + // rejected because the pending init was against the old authority. + await expect(inflight).rejects.toBeInstanceOf(AppError); + await expect(inflight).rejects.toMatchObject({ code: "AUTH_RESET" }); + // Handles are dropped only AFTER the settle, and the module reference is cleared. + expect(__testing.getAuthReadyPromise()).toBeNull(); + }); + + it("rejects the in-flight readiness promise on a client change too", async () => { + setAuthConfig({ clientId: "client-one", tenantId: TENANT_A }); + const inflight = __testing.primeInflightReadiness(); + setAuthConfig({ clientId: "client-two", tenantId: TENANT_A }); + await expect(inflight).rejects.toMatchObject({ code: "AUTH_RESET" }); + }); + + it("releases a concurrent getAccessToken-style awaiter on a tenant switch (no hang)", async () => { + // Concurrency regression: initializeAuth() is in flight, a concurrent caller + // is parked on `await authReadyPromise` (getAccessToken's readiness guard), + // and a tenant/client switch resets in-memory state. Before the fix the + // resolve/reject handles were nulled WITHOUT settling the pending promise, so + // this awaiter hung forever. We assert at the exact await site — hermetically, + // without real MSAL interactive sign-in — that it is released. + setAuthConfig({ clientId: CLIENT_ID, tenantId: TENANT_A }); + const inflight = __testing.primeInflightReadiness(); + + let released = false; + const awaiter = (async () => { + // Mirror getAccessToken's guard: await readiness, swallow a rejection, then + // fall through to on-demand acquisition against the NEW config. + try { + await inflight; + } catch { + // getAccessToken proceeds to acquire a token on demand here. + } + released = true; + })(); + + // Switch tenants mid-init -> resetInMemoryAuthState() settles the promise. + setAuthConfig({ clientId: CLIENT_ID, tenantId: TENANT_B }); + + await withTimeout(awaiter, 1000, "awaiter did not settle after reset (hang regression)"); + expect(released).toBe(true); + }); +}); + +describe("owning-app precondition guidance (UX)", () => { + it("isOwningAppConfigured() is true once auth is configured", () => { + setAuthConfig({ clientId: CLIENT_ID, tenantId: TENANT_A }); + expect(isOwningAppConfigured()).toBe(true); + }); + + it("OWNING_APP_REQUIRED_MESSAGE carries the actionable remediation guidance", () => { + // Assert on the actionable substrings the user needs, not the exact prose + // (which stays free to be reworded). Cover BOTH remediation paths plus the + // restart-free promise so a regression that drops either path is caught: + // 1. the primary fix — the project_app_create tool, + expect(OWNING_APP_REQUIRED_MESSAGE).toMatch(/project_app_create/); + // 2. that it takes effect with no restart, + expect(OWNING_APP_REQUIRED_MESSAGE).toMatch(/no restart/i); + // 3. the alternative for an already-provisioned app — the CLI flags. + expect(OWNING_APP_REQUIRED_MESSAGE).toMatch(/--client-id/); + expect(OWNING_APP_REQUIRED_MESSAGE).toMatch(/--tenant-id/); + }); + + it("getAccessToken() throws a typed OWNING_APP_REQUIRED error when no owning app is configured", async () => { + // authConfig=null regardless of on-disk state — getConfig() gates on it, so + // this is deterministic and independent of the dev machine's ~/.spe-mcp state. + __testing.reset(); + await expect(getAccessToken()).rejects.toMatchObject({ + code: "OWNING_APP_REQUIRED", + }); + await expect(getAccessToken()).rejects.toBeInstanceOf(AppError); + }); +}); + +// ─── WI-14: login result messaging & next-step guidance ───────────────────── +describe("login result messaging (WI-14)", () => { + const base = { + clientId: CLIENT_ID, + tenantId: TENANT_A, + scopes: ["FileStorageContainer.Selected", "User.Read"], + }; + + it("success summary states the app, account, tenant, scopes and a real next tool", () => { + const msg = formatLoginSuccessMessage({ ...base, account: "alice@contoso.com" }); + // Assert on stable structure/substrings, not the full brittle string. + expect(msg).toMatch(/logged into your owning spe app successfully/i); + expect(msg).toContain(CLIENT_ID); + expect(msg).toContain(TENANT_A); + expect(msg).toContain("alice@contoso.com"); + expect(msg).toContain("FileStorageContainer.Selected"); + expect(msg).toContain("User.Read"); + // Points the user at a REAL MCP tool rather than a vague instruction. + expect(msg).toMatch(/status_get|project_provision/); + }); + + it("success summary omits the account line when the account is not yet known", () => { + const msg = formatLoginSuccessMessage(base); + expect(msg).not.toMatch(/Account:/); + // Still reports what it does know. + expect(msg).toContain(CLIENT_ID); + expect(msg).toContain(TENANT_A); + }); + + it("browser success page includes app, tenant, scopes and a concrete next step", () => { + const html = renderAuthSuccessHtml(base); + expect(html).toMatch(/logged into your owning spe app successfully/i); + expect(html).toContain(CLIENT_ID); + expect(html).toContain(TENANT_A); + expect(html).toContain("FileStorageContainer.Selected"); + expect(html).toMatch(/status_get|project_provision/); + }); + + it("browser success page HTML-escapes interpolated values", () => { + const html = renderAuthSuccessHtml({ ...base, scopes: ['a&"c'] }); + expect(html).toContain("a<b>&"c"); + expect(html).not.toContain('a&"c'); + }); + + it("browser error page gives actionable next steps, not a bare 'try again'", () => { + const html = renderAuthErrorHtml(); + // The reviewer questioned whether a blind retry is the right guidance. + expect(html.toLowerCase()).not.toContain("try again"); + // Actionable causes/fixes instead. + expect(html).toMatch(/consent/i); + expect(html).toMatch(/tenant/i); + expect(html).toMatch(/redirect/i); + // And a concrete terminal fallback. + expect(html).toContain("spe-mcp auth"); + }); +}); + +// ─── Device-code sign-in timeout (PR #3 review) ───────────────────────────── +// The Azure AD device code is valid ~15 minutes; MSAL polls the token endpoint +// until the user signs in or the code expires. A client-side cancel MUST NOT +// fire before that horizon — the previous fixed 10-min headless / 2-min TTY +// timeouts were SHORTER than the real code lifetime and would cancel a +// still-valid sign-in. +describe("device-code sign-in timeout", () => { + const { DEVICE_CODE_LIFETIME_SECONDS, DEVICE_CODE_TIMEOUT_MS, deviceCodeCancelDelayMs } = + __testing; + + it("bounds the cancel horizon at the ~15-min AAD device-code lifetime (never the old 600s/120s)", () => { + // ~15 min = 900 s — the official AAD device-code lifetime. + expect(DEVICE_CODE_LIFETIME_SECONDS).toBe(900); + expect(DEVICE_CODE_TIMEOUT_MS).toBe(DEVICE_CODE_LIFETIME_SECONDS * 1000); + // The horizon is at least the full code lifetime — the regression was a + // shorter, premature bound. + expect(DEVICE_CODE_TIMEOUT_MS).toBeGreaterThanOrEqual(DEVICE_CODE_LIFETIME_SECONDS * 1000); + // Explicitly NOT the old premature values (2 min TTY / 10 min headless). + expect(DEVICE_CODE_TIMEOUT_MS).not.toBe(600_000); + expect(DEVICE_CODE_TIMEOUT_MS).not.toBe(120_000); + }); + + it("derives the cancel delay from the STS-reported expiresIn (seconds → ms)", () => { + // expiresIn drives it: the real per-request lifetime flows straight through. + expect(deviceCodeCancelDelayMs(900)).toBe(900_000); + expect(deviceCodeCancelDelayMs(1200)).toBe(1_200_000); + // …and it is never shorter than a valid expiresIn implies. + expect(deviceCodeCancelDelayMs(900)).toBeGreaterThanOrEqual( + DEVICE_CODE_LIFETIME_SECONDS * 1000, + ); + }); + + it("falls back to the ~15-min default when expiresIn is unknown or invalid", () => { + // The timer is armed before the callback fires, so the default must equal the + // full code lifetime (not a shorter value). + expect(deviceCodeCancelDelayMs()).toBe(DEVICE_CODE_TIMEOUT_MS); + expect(deviceCodeCancelDelayMs(undefined)).toBe(DEVICE_CODE_TIMEOUT_MS); + expect(deviceCodeCancelDelayMs(0)).toBe(DEVICE_CODE_TIMEOUT_MS); + expect(deviceCodeCancelDelayMs(-5)).toBe(DEVICE_CODE_TIMEOUT_MS); + expect(deviceCodeCancelDelayMs(Number.NaN)).toBe(DEVICE_CODE_TIMEOUT_MS); + }); + + it("does not cancel a still-valid device code before it expires (expiresIn-driven, fake timers)", async () => { + vi.useFakeTimers(); + // Silence the device-code prompt the flow prints to stderr. + const consoleErr = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const EXPIRES_IN = 900; // seconds — what the STS returns + let captured: DeviceCodeRequest | undefined; + let resolvePca!: (r: AuthenticationResult) => void; + const pcaPending = new Promise((res) => { + resolvePca = res; + }); + + const fake = { + acquireTokenByDeviceCode: (req: DeviceCodeRequest): Promise => { + captured = req; + // STS responds → callback fires with the real code lifetime. + req.deviceCodeCallback({ + userCode: "ABCD-EFGH", + deviceCode: "device-code-value", + verificationUri: "https://microsoft.com/devicelogin", + expiresIn: EXPIRES_IN, + interval: 5, + message: "To sign in, use a web browser to open the page…", + }); + // Mirror MSAL: keep polling (stay pending) until the test settles us. + return pcaPending; + }, + } as unknown as PublicClientApplication; + __testing.setPca(fake); + + const flow = __testing.acquireTokenByDeviceCode(); + // Let the async fn reach its await and the callback re-arm the timer. + await Promise.resolve(); + + // Native MSAL polling timeout is bound to the full code lifetime, not 600s. + expect(captured?.timeout).toBe(DEVICE_CODE_LIFETIME_SECONDS); + + // Past the OLD 600s premature-cancel point, the code is still NOT cancelled. + vi.advanceTimersByTime(601_000); + expect(captured?.cancel).toBe(false); + + // Just shy of the real expiry — still valid. + vi.advanceTimersByTime((EXPIRES_IN - 1) * 1000 - 601_000); + expect(captured?.cancel).toBe(false); + + // At the code lifetime the safety-net cancel finally fires. + vi.advanceTimersByTime(2_000); + expect(captured?.cancel).toBe(true); + + // Let the flow settle so no promise is left dangling. + resolvePca({ accessToken: "token" } as AuthenticationResult); + await expect(flow).resolves.toMatchObject({ accessToken: "token" }); + } finally { + consoleErr.mockRestore(); + vi.useRealTimers(); + } + }); +}); diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..919e17d --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,1026 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Authentication module for the SPE MCP Server. + * + * Auth strategy waterfall: + * 1. Silent (cached refresh token via MSAL + file cache) + * 2. Interactive browser (PKCE with localhost redirect) + * 3. Device code flow (headless fallback) + * + * Tokens are persisted to a local file that is PARTITIONED by tenant + client + * (~/.spe-mcp/token-cache...json) so that accounts from + * different tenants never co-mingle. This prevents a stale account cached from a + * prior tenant from interfering on a tenant switch. + */ + +import { existsSync } from "node:fs"; +import { basename } from "node:path"; +import { getCacheDir, getCacheFile, getLegacyCacheFile } from "./paths.js"; +import { ensureSecureDir, readSecureFile, writeSecureFile } from "./secure-fs.js"; +import { + type AccountInfo, + type AuthenticationResult, + type Configuration, + type DeviceCodeRequest, + type ICachePlugin, + InteractionRequiredAuthError, + LogLevel, + PublicClientApplication, + type SilentFlowRequest, +} from "@azure/msal-node"; +import open from "open"; +import { AppError } from "./errors.js"; +import { createLogger } from "./logger.js"; +import { readState } from "./state.js"; +import type { AuthConfig } from "./types.js"; + +// ─── Constants ────────────────────────────────────────────────────────────── + +// Microsoft Graph scopes needed for SPE operations. +// FileStorageContainer.Manage.All — container read/manage as owning app +// FileStorageContainer.Selected — selected-container delegated access +// FileStorageContainerType.Manage.All — create/manage container types (app role, owning tenant) +// FileStorageContainerTypeReg.Manage.All — register container types +// +// Least-privilege note (PR #3 review): least privilege is enforced where it +// actually grants standing authority — at the app's requiredResourceAccess +// (registration Layer 2, intent-driven by ownerScope) and at the container-type +// app-permission grant (Layer 3, app-only defaults to none). The interactive +// sign-in scope set below is deliberately left as the bounded "manage-all" +// superset rather than narrowed per ownerScope: it always keeps +// FileStorageContainerType.Manage.All (delegated-only, required to create and +// enumerate container types, and the signal the staleness flag reads), and +// requesting a scope here only lets the user consent to it — it confers no +// authority the app's grants don't already back. Narrowing it per-intent would +// add token/consent churn (getScopes reads authConfig?.scopes ?? DEFAULT_SCOPES) +// for no real privilege reduction. Never request beyond this manage-all set. +const DEFAULT_SCOPES = [ + "https://graph.microsoft.com/FileStorageContainer.Manage.All", + "https://graph.microsoft.com/FileStorageContainer.Selected", + "https://graph.microsoft.com/FileStorageContainerType.Manage.All", + "https://graph.microsoft.com/FileStorageContainerTypeReg.Manage.All", +]; + +/** + * Interpret an environment-variable string as a boolean opt-in flag. The + * values `1`, `true`, `yes`, and `on` (case-insensitive, surrounding + * whitespace ignored) are treated as true; everything else — including + * `undefined` and the empty string — is false. + * + * @param value raw environment variable value (may be undefined) + * @returns true when the value denotes an enabled/opt-in flag + */ +function envTruthy(value: string | undefined): boolean { + return !!value && /^(1|true|yes|on)$/i.test(value.trim()); +} + +// Best-effort detection of an environment where opening a browser will fail or +// be pointless: CI, or a Linux box with no display server (headless server / SSH +// / dev container). Used only to flip the DEFAULT for interactive sign-in; an +// explicit SPE_INTERACTIVE / SPE_NON_INTERACTIVE always wins. +function isLikelyHeadlessEnv(): boolean { + if (envTruthy(process.env.CI)) return true; + if ( + process.platform === "linux" && + !process.env.DISPLAY && + !process.env.WAYLAND_DISPLAY + ) { + return true; + } + return false; +} + +// Interactive sign-in is ENABLED BY DEFAULT, even over stdio (no TTY): a LOCAL +// MCP server can open a browser on the user's machine for consent — far better +// than forcing them to run `spe-mcp auth` in a terminal and restart. It is +// turned OFF by default in obvious automation/headless environments (CI, Linux +// with no DISPLAY) so a tool call never silently blocks on a browser that can't +// open. Explicit overrides always win: SPE_INTERACTIVE=1 forces it on, +// SPE_NON_INTERACTIVE=1 forces it off. +let interactiveEnabled = + envTruthy(process.env.SPE_INTERACTIVE) || + (!envTruthy(process.env.SPE_NON_INTERACTIVE) && !isLikelyHeadlessEnv()); + +// Whether the device-code prompt (printed to stderr) can actually be seen by a +// human. Over stdio in an MCP host, stderr is usually NOT visible, so a +// device-code wait would hang invisibly — we only offer device code on a TTY and +// otherwise fail fast with an actionable error after the browser attempt. +function deviceCodeIsVisible(): boolean { + return process.stderr.isTTY === true; +} + +/** Force interactive mode (used by `spe-mcp auth` CLI command). */ +export function setInteractiveMode(): void { + interactiveEnabled = true; +} + +// Device-code lifetime. The Azure AD device code that MSAL polls against is +// valid for ~15 minutes; MSAL keeps polling the token endpoint until the user +// completes sign-in OR the code expires (per the DeviceCodeResponse the STS +// returns). Our own client-side cancel MUST therefore be bounded to that same +// ~15-min horizon and never shorter: the previous fixed values (2 min on a TTY, +// 10 min headless) were BELOW the code's real lifetime, so they cancelled a +// device code that was still valid and aborted a sign-in the user could still +// have completed. The authoritative per-request lifetime is read from +// DeviceCodeResponse.expiresIn (seconds) in the callback below; this constant is +// the default/upper safety bound used until that value is known. MSAL's own +// polling `timeout` (see the request) already honours code-expiry precedence, so +// this JS timer is only a belt-and-suspenders net at the same horizon. (PR #3 +// review.) +const DEVICE_CODE_LIFETIME_SECONDS = 900; // ~15 min — official AAD device-code lifetime +const DEVICE_CODE_TIMEOUT_MS = DEVICE_CODE_LIFETIME_SECONDS * 1000; + +/** + * Derive the client-side cancel delay (ms) for the device-code polling loop from + * the code's real lifetime. MSAL already stops polling when the code expires, and + * its native `timeout` honours code-expiry precedence, so this JS safety-net timer + * must never fire EARLIER than the code lifetime. Uses the server-reported + * `expiresIn` (seconds, from DeviceCodeResponse) when available; otherwise falls + * back to the ~15-min AAD default. Never returns less than a valid `expiresIn` + * would imply, which is exactly what fixes the premature-cancel bug. (PR #3 + * review.) + * + * @param expiresInSeconds DeviceCodeResponse.expiresIn (seconds), if known + * @returns cancel delay in milliseconds + */ +function deviceCodeCancelDelayMs(expiresInSeconds?: number): number { + const seconds = + typeof expiresInSeconds === "number" && + Number.isFinite(expiresInSeconds) && + expiresInSeconds > 0 + ? expiresInSeconds + : DEVICE_CODE_LIFETIME_SECONDS; + // Clamp to a sane upper bound (4× the ~15-min AAD lifetime). AAD never issues a + // code longer-lived than ~15 min, but an absurd expiresIn would otherwise exceed + // Node's max setTimeout delay (~24.85 days) and be clamped to 1ms — cancelling + // almost immediately. MSAL's native timeout stays authoritative regardless. + const boundedSeconds = Math.min(seconds, DEVICE_CODE_LIFETIME_SECONDS * 4); + return boundedSeconds * 1000; +} + +// ─── Configuration ────────────────────────────────────────────────────────── + +let authConfig: AuthConfig | null = null; +let pca: PublicClientApplication | null = null; + +export function setAuthConfig(config: AuthConfig): void { + const prev = authConfig; + const tenantChanged = !!prev && prev.tenantId !== config.tenantId; + const clientChanged = !!prev && prev.clientId !== config.clientId; + authConfig = config; + + // (C) On a tenant/client switch, drop any in-memory MSAL state so a stale + // account from the previous tenant cannot be reused. On-disk tokens are + // already isolated by the per-tenant cache file (see getCacheFilePath), but + // the in-process PublicClientApplication + readiness promise must also be + // reset so the next acquisition rebuilds against the new authority. + if (tenantChanged || clientChanged) { + log( + `Auth config changed (tenant ${prev?.tenantId} -> ${config.tenantId}); ` + + "resetting in-memory MSAL state to avoid stale-account reuse", + ); + resetInMemoryAuthState(); + } +} + +/** + * Reset in-memory MSAL state (PublicClientApplication + readiness promise). + * Does NOT touch on-disk token caches. + */ +function resetInMemoryAuthState(): void { + // Settle any in-flight readiness promise FIRST so concurrent awaiters + // (getAccessToken's `await authReadyPromise`, or a second initializeAuth + // caller) don't hang on a promise that could never settle once the + // resolve/reject handles are dropped below. + // + // Reject (not resolve): the pending init was started against the OLD + // authority, so completing it as "ready" would be wrong after a tenant/client + // switch. getAccessToken's catch around the await falls through to on-demand + // acquisition against the NEW config, so a rejection here degrades gracefully. + // The in-flight promise already has a no-op `.catch()` attached in + // initializeAuth, so rejecting it cannot raise an unhandled rejection. + authReadyReject?.( + new AppError("AUTH_RESET", "Auth state was reset (tenant/client switch) while initializing.", { + safeMessage: "Authentication was reset because the tenant or app changed; retry.", + suggestion: + "Retry the operation; the server will re-initialize auth against the new tenant/app.", + }), + ); + pca = null; + authReadyPromise = null; + authReadyResolve = null; + authReadyReject = null; +} + +/** + * Actionable message shown when a control-plane SPE operation is attempted + * before an owning Entra app is configured. SPE container-type / container / + * billing operations need a delegated token from an owning app that holds the + * SPE Graph permissions — the Azure CLI bootstrap token cannot carry those + * scopes. This message tells the agent/user exactly how to proceed. + * + * Once an owning app IS configured, the server acquires a delegated token AS + * that owning app (through the sign-in waterfall in this module) and uses it to + * call the SharePoint Embedded control-plane Graph APIs — creating and managing + * container types, containers, and billing/registration — on the user's behalf. + */ +export const OWNING_APP_REQUIRED_MESSAGE = + "No owning SharePoint Embedded app is configured yet. SharePoint Embedded " + + "operations need an owning Entra app with the SPE Graph permissions. Run the " + + "`project_app_create` tool to create (or reuse) one — the server then signs in " + + "as that app automatically, no restart needed. Alternatively, start the server " + + "with `--client-id --tenant-id ` for an existing owning app."; + +/** + * Whether a previously-provisioned or explicitly-configured owning SPE app + * exists to acquire a delegated token with: either auth is already configured + * in-process, or an owning app is persisted in state (the server primes from it + * at startup / on demand). + * + * NOTE: this reports that an owning app is AVAILABLE, not that the auth module is + * already primed (`getConfig()` gates on the in-process `authConfig`). Use it for + * readiness/guidance messaging, not as a precondition that token acquisition will + * succeed without sign-in. + */ +export function isOwningAppConfigured(): boolean { + if (authConfig) return true; + const persisted = readState(); + return !!(persisted.appId && persisted.tenantId); +} + +function getConfig(): AuthConfig { + if (!authConfig) { + // Typed, actionable precondition (not the old internal "call setAuthConfig" + // message). Flows through toSafeError/clientSafeMessage so every control-plane + // SPE tool surfaces the same "create an owning app first" guidance. + throw new AppError("OWNING_APP_REQUIRED", "Auth not configured (no owning app).", { + safeMessage: OWNING_APP_REQUIRED_MESSAGE, + suggestion: "Run project_app_create, then retry.", + }); + } + return authConfig; +} + +function getScopes(): string[] { + return authConfig?.scopes ?? DEFAULT_SCOPES; +} + +function getAuthority(): string { + return `https://login.microsoftonline.com/${getConfig().tenantId}`; +} + +// ─── Logging ──────────────────────────────────────────────────────────────── + +// All auth diagnostics go to STDERR (never stdout): a stdio MCP server must keep +// stdout reserved for the JSON-RPC stream. Each line carries an explicit +// severity so a reader can tell expected, handled flow (debug/info) apart from +// conditions that genuinely warrant attention (warn/error). In particular, +// "silent acquisition failed — interaction required" is a NORMAL step of the +// interactive sign-in waterfall, not an error, so it is logged at debug. +// +// Backed by the shared stderr logger (src/logger.ts). `severity: true` keeps the +// exact `[] [Auth] [] ` format this module has always +// emitted; the thin wrappers below preserve the local call-site names. +const authLogger = createLogger("Auth", { severity: true }); + +/** Default-severity (info) auth log line. */ +function log(message: string, data?: unknown): void { + authLogger.log(message, data); +} + +/** Expected, handled flow — not actionable (e.g. silent auth needing interaction). */ +function logDebug(message: string, data?: unknown): void { + authLogger.debug(message, data); +} + +/** Handled but noteworthy (e.g. a guard rejecting a wrong-tenant token). */ +function logWarn(message: string, data?: unknown): void { + authLogger.warn(message, data); +} + +/** Genuine, unexpected failure. */ +function logError(message: string, data?: unknown): void { + authLogger.error(message, data); +} + +// ─── File-Based Cache Plugin ──────────────────────────────────────────────── +// Persists the MSAL token cache to a tenant+client-partitioned file under +// ~/.spe-mcp/. Partitioning by tenant (and client) guarantees accounts from +// different tenants never co-mingle, which is the root cause of the +// tenant-switch silent-auth failures and wrong-tenant-token hazard. + +// The token-cache directory + partitioned file paths come from the resolve-once +// seam in paths.ts, so they honor a --data-dir / SPE_DATA_DIR override. Legacy +// single-file cache (pre-partitioning) is only cleaned up by clearCachedToken(). + +/** + * Derive the token-cache file path for a given auth config. Partitioned by + * tenantId and clientId so two different tenants (or client apps) always resolve + * to distinct files. Falls back to the legacy single-file path only when no + * tenant is configured yet (should not happen on normal auth paths). + * + * Exported for unit testing. + */ +export function getCacheFilePath(config: AuthConfig | null = authConfig): string { + if (config?.tenantId) { + return getCacheFile(config.tenantId, config.clientId ?? "default"); + } + return getLegacyCacheFile(); +} + +/** + * MSAL cache plugin that persists the in-memory token cache to disk so refresh + * tokens survive process restarts, enabling silent (no-prompt) re-authentication + * on the next run. + * + * - `beforeCacheAccess` hydrates MSAL from the tenant+client-partitioned cache + * file (when one exists) before each token operation. + * - `afterCacheAccess` serializes the cache back to that file whenever MSAL + * reports a change, writing it owner-only (0o600) because it holds refresh + * tokens (SEC-003). + * + * Read/write failures are logged and swallowed: a missing or unreadable cache + * simply forces a fresh interactive sign-in rather than breaking the flow. + */ +const fileCachePlugin: ICachePlugin = { + beforeCacheAccess: async (cacheContext) => { + const cacheFile = getCacheFilePath(); + try { + // O_NOFOLLOW + owner check: a planted symlink is refused (throws) rather + // than followed; a missing cache returns null and forces a fresh sign-in. + const cached = readSecureFile(cacheFile); + if (cached !== null) { + cacheContext.tokenCache.deserialize(cached); + log(`Token cache loaded from file (${basename(cacheFile)})`); + } + } catch (error) { + log("Failed to read cache file:", error); + } + }, + afterCacheAccess: async (cacheContext) => { + if (cacheContext.cacheHasChanged) { + const cacheFile = getCacheFilePath(); + try { + ensureSecureDir(getCacheDir()); + const serialized = cacheContext.tokenCache.serialize(); + // SEC-003: token cache holds refresh tokens — owner-only (0o600). + writeSecureFile(cacheFile, serialized); + log(`Token cache written to file (${basename(cacheFile)})`); + } catch (error) { + log("Failed to write cache file:", error); + } + } + }, +}; + +// ─── Auth Readiness Guard ─────────────────────────────────────────────────── + +let authReadyResolve: (() => void) | null = null; +let authReadyReject: ((err: Error) => void) | null = null; +let authReadyPromise: Promise | null = null; + +function ensurePcaInitialized(): PublicClientApplication { + if (!pca) { + throw new Error( + "MSAL PublicClientApplication not initialized. Call initializeAuth() first.", + ); + } + return pca; +} + +// ─── Helper: Cached Account ───────────────────────────────────────────────── + +/** + * Extract the HOME tenant id from an MSAL homeAccountId. + * Format is "." (both GUIDs). The home tenant is the segment + * after the final '.'. Returns undefined if it cannot be parsed. + * + * Exported for unit testing. + */ +export function homeTenantOf(account: Pick): string | undefined { + const id = account.homeAccountId; + if (!id) return undefined; + const dot = id.lastIndexOf("."); + if (dot < 0 || dot === id.length - 1) return undefined; + return id.slice(dot + 1); +} + +/** + * Choose the cached account that belongs to the configured tenant. + * + * Hardened: the cache can hold accounts from MULTIPLE tenants — + * including GUEST records. Under a given authority, AccountInfo.tenantId is the + * AUTHORITY tenant for ALL of them, so it cannot discriminate; the HOME tenant + * (homeAccountId ".") is authoritative. We ONLY return an + * account whose home tenant matches the configured tenant. If there is no match + * we return null (forcing interactive auth) instead of falling back to the + * first cached account, which previously risked handing back a WRONG-TENANT + * token on a tenant switch. + * + * When no tenant is configured we cannot discriminate, so the first account is + * returned (legacy behavior; off the normal configured-auth path). + * + * Exported for unit testing. + */ +export function selectAccountForTenant( + accounts: AccountInfo[], + configuredTenant: string | undefined, +): AccountInfo | null { + if (accounts.length === 0) return null; + if (!configuredTenant) return accounts[0]; + return accounts.find((a) => homeTenantOf(a) === configuredTenant) ?? null; +} + +export async function getCachedAccount(): Promise { + const cache = ensurePcaInitialized().getTokenCache(); + const accounts = await cache.getAllAccounts(); + if (accounts.length === 0) { + log("No cached accounts found"); + return null; + } + + const configuredTenant = authConfig?.tenantId; + const chosen = selectAccountForTenant(accounts, configuredTenant); + + if (chosen) { + log( + `Selected cached account ${chosen.username} ` + + `(homeAccountId=${chosen.homeAccountId}, homeTenant=${homeTenantOf(chosen)}; ` + + `${accounts.length} cached; home-tenant match)`, + ); + return chosen; + } + + // No account whose HOME tenant equals the configured tenant. This is the + // normal guest / B2B case: e.g. signing into an SPE resource/test tenant with + // a corporate identity whose home tenant differs. The on-disk cache is + // partitioned by (tenant, client) — see getCacheFilePath — so every account in + // it was obtained under the configured authority and is valid to TRY. We + // therefore attempt silent acquisition for such an account rather than forcing + // interactive. The authoritative wrong-tenant protection is the check on the + // ISSUED TOKEN's tenant (see isWrongTenantToken, enforced in acquireTokenSilent) + // — not the account's home tenant, which is only a heuristic and wrongly + // excludes guests. + const candidate = accounts[0]; + log( + `No home-tenant match for configured tenant ${configuredTenant}; ` + + `attempting silent acquisition for partitioned-cache account ${candidate.username} ` + + `(homeTenant=${homeTenantOf(candidate)}; guest/B2B) — issued-token tenant will be verified`, + ); + return candidate; +} + +// ─── Auth Flow: Silent ────────────────────────────────────────────────────── + +/** + * Authoritative wrong-tenant guard. + * + * Returns true when an issued token's tenant does NOT match the configured + * tenant. We read the tenant from the MSAL result (`tenantId`, falling back to + * the bound account's `tenantId`) — this is the tenant the token was actually + * minted for, which is the correct signal for "is this the right tenant," unlike + * the account's HOME tenant (a guest's home tenant legitimately differs from the + * resource tenant they hold a valid token for). When the configured tenant or + * the issued tenant is unknown we do not block (cannot prove a mismatch). + * + * Exported for unit testing. + */ +export function isWrongTenantToken( + result: Pick, + configuredTenant: string | undefined, +): boolean { + if (!configuredTenant) return false; + const issuedTenant = result.tenantId || result.account?.tenantId; + if (!issuedTenant) return false; + return issuedTenant !== configuredTenant; +} + +async function acquireTokenSilent(account: AccountInfo): Promise { + try { + logDebug("Attempting silent token acquisition..."); + const silentRequest: SilentFlowRequest = { + scopes: getScopes(), + account, + }; + const result = await ensurePcaInitialized().acquireTokenSilent(silentRequest); + + // Authoritative wrong-tenant protection: verify the tenant the token was + // issued for, not the account's home tenant. This both (a) allows guest/B2B + // accounts whose home tenant differs from the configured resource tenant and + // (b) still refuses a token that was actually minted for the wrong tenant. + const configuredTenant = authConfig?.tenantId; + if (isWrongTenantToken(result, configuredTenant)) { + const issuedTenant = result.tenantId || result.account?.tenantId; + // Handled security guard: we successfully got a token but reject it. Worth + // a warn (not error) so it stands out, but it is not a failure of the flow. + logWarn( + `Silent token tenant ${issuedTenant} does not match configured tenant ` + + `${configuredTenant} — rejecting to avoid a wrong-tenant token`, + ); + return null; + } + + logDebug("Silent token acquisition succeeded"); + return result; + } catch (error) { + if (error instanceof InteractionRequiredAuthError) { + // EXPECTED, handled outcome — not an error. The token could not be renewed + // silently, so the caller falls through to interactive sign-in. Logged at + // debug so it does not read as a failure. (Review comment r3515134473.) + const accountHomeTenant = homeTenantOf(account); + const configuredTenant = authConfig?.tenantId; + if (configuredTenant && accountHomeTenant && accountHomeTenant !== configuredTenant) { + logDebug( + `Silent acquisition failed — interaction required (account home tenant ` + + `${accountHomeTenant} does not match configured tenant ${configuredTenant})`, + ); + } else { + logDebug("Silent acquisition failed — interaction required"); + } + return null; + } + // A non-interaction-required failure IS a genuine, unexpected error + // (network, MSAL/config fault): surface it at error level and rethrow. + logError("Silent acquisition failed with an unexpected error:", error); + throw error instanceof Error ? error : new Error(String(error)); + } +} + +// ─── Auth Flow: Device Code ───────────────────────────────────────────────── + +async function acquireTokenByDeviceCode(): Promise { + log("Starting device code flow..."); + + // JS safety-net cancel timer. Declared up-front so the callback can RE-ARM it + // to the real code lifetime once the STS reports `expiresIn`. + let timeoutId: ReturnType | undefined; + const armCancelTimer = (delayMs: number): void => { + if (timeoutId) clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + deviceCodeRequest.cancel = true; + }, delayMs); + }; + + const deviceCodeRequest: DeviceCodeRequest = { + scopes: getScopes(), + // Native MSAL polling timeout (seconds), bound to the device-code lifetime. + // Per MSAL, "the device code expiration window will always take precedence + // over this set period", so this caps the wait at ~15 min WITHOUT ever + // cancelling a code that is still valid. + timeout: DEVICE_CODE_LIFETIME_SECONDS, + deviceCodeCallback: (response) => { + console.error(`\n${"=".repeat(60)}`); + console.error(" AUTHENTICATION REQUIRED"); + console.error("=".repeat(60)); + console.error(`\n To sign in, open your browser to:\n`); + console.error(` ${response.verificationUri}\n`); + console.error(` And enter the code:\n`); + console.error(` ${response.userCode}\n`); + console.error(` ${response.message}`); + console.error(`${"=".repeat(60)}\n`); + // Re-arm the JS safety net to the REAL lifetime the STS reported so it can + // never fire before the code actually expires (fixes the premature-cancel + // bug where a fixed 10-min headless timeout < the ~15-min code lifetime). + armCancelTimer(deviceCodeCancelDelayMs(response.expiresIn)); + }, + cancel: false, + }; + + // Arm the initial safety net at the default ~15-min lifetime; the callback + // above re-arms it with the exact `expiresIn` as soon as the STS responds. + armCancelTimer(deviceCodeCancelDelayMs()); + + try { + const result = await ensurePcaInitialized().acquireTokenByDeviceCode(deviceCodeRequest); + if (!result) { + throw new Error("Device code flow returned no result"); + } + log("Device code flow succeeded"); + return result; + } finally { + if (timeoutId) clearTimeout(timeoutId); + } +} + +// ─── Sign-in Result Messaging ─────────────────────────────────────────────── +// Concise, accurate success/error text shared by the terminal (`spe-mcp auth`) +// and the post-redirect browser page. We state only what the code actually +// knows — owning app (client) ID, configured tenant, granted scopes, and (when +// available) the signed-in account — and point at a REAL next MCP tool rather +// than over-claiming. (Review comment r3515151182.) + +const NEXT_STEP_HINT = + "run `status_get` to confirm the server sees your owning app, then " + + "`project_provision` to set up a container type + container " + + "(or `container_type_list` to inspect what already exists)"; + +export interface LoginSuccessInfo { + clientId: string; + tenantId: string; + scopes: string[]; + /** + * Signed-in account (UPN/email). Omitted for the browser page, where the + * account is not yet known at the time the template is rendered. + */ + account?: string; +} + +/** Plain-text sign-in success summary for the terminal / agent transcript. */ +export function formatLoginSuccessMessage(info: LoginSuccessInfo): string { + const lines = [ + "Logged into your owning SPE app successfully.", + ` • App (client) ID: ${info.clientId}`, + ]; + if (info.account) { + lines.push(` • Account: ${info.account}`); + } + lines.push(` • Tenant: ${info.tenantId}`); + lines.push(` • Scopes: ${info.scopes.join(", ")}`); + lines.push(`Next: ${NEXT_STEP_HINT}.`); + return lines.join("\n"); +} + +/** Minimal HTML escaping for values interpolated into the browser templates. */ +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +/** Browser page shown after a SUCCESSFUL interactive redirect. */ +export function renderAuthSuccessHtml(info: LoginSuccessInfo): string { + const scopes = info.scopes.map(escapeHtml).join(", "); + return [ + 'Signed in', + '', + "

Logged into your owning SPE app successfully

", + "

You can close this window and return to your terminal or MCP client.

", + "
    ", + `
  • App (client) ID: ${escapeHtml(info.clientId)}
  • `, + `
  • Tenant: ${escapeHtml(info.tenantId)}
  • `, + `
  • Scopes: ${scopes}
  • `, + "
", + "

Next: run status_get to confirm access, then " + + "project_provision to set up SPE resources " + + "(or container_type_list to inspect what already exists).

", + "", + ].join(""); +} + +/** + * Browser page shown when the interactive redirect FAILED. Gives actionable + * causes/fixes instead of a bare "try again" — a blind retry rarely helps when + * interactive sign-in itself failed (declined consent, wrong tenant, or a + * misconfigured app/redirect URI). + */ +export function renderAuthErrorHtml(): string { + return [ + 'Sign-in didn\'t complete', + '', + "

Sign-in didn't complete

", + "

You can close this window — your terminal / MCP client has the full error.

", + "

Common causes and how to fix them:

", + "
    ", + "
  • Consent was declined or cancelled — start sign-in again and approve the requested SPE permissions.
  • ", + "
  • Signed in with the wrong account or tenant — choose an account in the tenant this app is registered in.
  • ", + "
  • App registration or redirect URI misconfigured — confirm the app allows the local redirect used for interactive sign-in.
  • ", + "
", + "

If browser sign-in keeps failing, run " + + "spe-mcp auth --client-id <appId> --tenant-id <tenantId> in a terminal.

", + "", + ].join(""); +} + +// ─── Auth Flow: Interactive Browser ───────────────────────────────────────── + +async function acquireTokenInteractive(): Promise { + log("Starting interactive browser flow..."); + try { + const cfg = getConfig(); + const result = await ensurePcaInitialized().acquireTokenInteractive({ + scopes: getScopes(), + openBrowser: async (url) => { + log(`Opening browser for auth: ${url}`); + await open(url); + }, + successTemplate: renderAuthSuccessHtml({ + clientId: cfg.clientId, + tenantId: cfg.tenantId, + scopes: getScopes(), + }), + errorTemplate: renderAuthErrorHtml(), + }); + if (!result) { + throw new Error("Interactive flow returned no result"); + } + log("Interactive flow succeeded"); + return result; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + throw new Error(`Interactive authentication failed: ${message}`, { cause: error }); + } +} + +// ─── Interactive Waterfall ────────────────────────────────────────────────── + +async function acquireTokenInteractiveWithFallbacks(): Promise { + const strategies: Array<{ name: string; fn: () => Promise }> = []; + + // NOTE: Azure CLI is intentionally NOT in the waterfall here. + // `az account get-access-token` returns a token under Azure CLI's own + // client ID, which won't carry the SPE delegated scopes + // (FileStorageContainer.Selected, etc.) that are registered on OUR app. + // SPE requires tokens obtained via our specific app registration. + + // Interactive sign-in is attempted by default (browser first, device code as + // fallback) — even over stdio, since a local server can open the user's + // browser. SPE_NON_INTERACTIVE disables this for automation/CI. + if (interactiveEnabled) { + strategies.push({ name: "interactive browser", fn: acquireTokenInteractive }); + // Only fall back to device code when its stderr prompt is actually visible + // (a TTY). Over stdio the code would print where no one can see it and the + // call would block for the code's full ~15-min lifetime — so we skip it and + // fail fast below. + if (deviceCodeIsVisible()) { + strategies.push({ name: "device code", fn: acquireTokenByDeviceCode }); + } + } + + let lastError: Error | undefined; + for (const strategy of strategies) { + try { + const result = await strategy.fn(); + log(`Authentication succeeded (${strategy.name})`); + return result; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + log(`${strategy.name} failed, trying next...`, lastError.message); + } + } + + if (!interactiveEnabled) { + throw new AppError( + "AUTH_REQUIRED", + "No cached credentials and interactive sign-in is disabled (SPE_NON_INTERACTIVE).", + { + safeMessage: + "No cached credentials and interactive sign-in is disabled (SPE_NON_INTERACTIVE). " + + "Run `spe-mcp auth --client-id --tenant-id ` in a terminal to " + + "pre-cache a token, then retry.", + suggestion: "Pre-cache a token with `spe-mcp auth`, or unset SPE_NON_INTERACTIVE.", + }, + ); + } + + throw new AppError("AUTH_FAILED", `Authentication failed. All sign-in methods exhausted. ${lastError?.message}`, { + safeMessage: + "Sign-in did not complete. A browser should have opened for you to consent to the SPE " + + "app — complete it and retry. If no browser opened (headless/remote), run " + + "`spe-mcp auth --client-id --tenant-id ` in a terminal, then retry.", + suggestion: "Complete the browser consent and retry.", + }); +} + +// ─── Public API ───────────────────────────────────────────────────────────── + +/** + * Initialize authentication at server startup. + */ +export async function initializeAuth(): Promise { + if (authReadyPromise) { + return authReadyPromise; + } + + log("Initializing authentication..."); + const config = getConfig(); + + authReadyPromise = new Promise((resolve, reject) => { + authReadyResolve = resolve; + authReadyReject = reject; + }); + authReadyPromise.catch(() => {}); + + try { + const msalConfig: Configuration = { + auth: { + clientId: config.clientId, + authority: getAuthority(), + }, + cache: { + cachePlugin: fileCachePlugin, + }, + system: { + loggerOptions: { + logLevel: LogLevel.Warning, + loggerCallback: (_level, message) => log(`[MSAL] ${message}`), + piiLoggingEnabled: false, + }, + }, + }; + + pca = new PublicClientApplication(msalConfig); + + const account = await getCachedAccount(); + if (account) { + const result = await acquireTokenSilent(account); + if (result) { + log("Authentication ready (silent)"); + authReadyResolve?.(); + return; + } + log("Cached account found but silent acquisition failed, need interactive login"); + } + + await acquireTokenInteractiveWithFallbacks(); + authReadyResolve?.(); + } catch (error) { + authReadyReject?.(error instanceof Error ? error : new Error(String(error))); + authReadyResolve = null; + authReadyReject = null; + authReadyPromise = null; + throw error; + } +} + +/** + * Get a valid access token for Microsoft Graph. + * Called on every HTTP request. Uses silent acquisition with fallback. + */ +export async function getAccessToken(): Promise { + log("getAccessToken called"); + + if (authReadyPromise) { + try { + await authReadyPromise; + } catch { + log("Auth init promise was rejected — proceeding to acquire token directly"); + } + } + + if (!pca) { + log("PCA not initialized — creating now for on-demand auth"); + const config = getConfig(); + pca = new PublicClientApplication({ + auth: { + clientId: config.clientId, + authority: getAuthority(), + }, + cache: { cachePlugin: fileCachePlugin }, + system: { + loggerOptions: { + logLevel: LogLevel.Warning, + loggerCallback: (_level, message) => log(`[MSAL] ${message}`), + piiLoggingEnabled: false, + }, + }, + }); + } + + const account = await getCachedAccount(); + if (account) { + const result = await acquireTokenSilent(account); + if (result) { + return result.accessToken; + } + } + + log("Silent acquisition unavailable, falling back to interactive methods..."); + const result = await acquireTokenInteractiveWithFallbacks(); + return result.accessToken; +} + +/** + * Authenticate interactively (for `spe-mcp auth` CLI command). + */ +export async function authenticateInteractively(): Promise { + const config = getConfig(); + pca = new PublicClientApplication({ + auth: { + clientId: config.clientId, + authority: getAuthority(), + }, + cache: { cachePlugin: fileCachePlugin }, + system: { + loggerOptions: { + logLevel: LogLevel.Warning, + loggerCallback: (_level, message) => log(`[MSAL] ${message}`), + piiLoggingEnabled: false, + }, + }, + }); + + const account = await getCachedAccount(); + if (account) { + const result = await acquireTokenSilent(account); + if (result) { + console.log( + "Already signed in — no browser needed.\n" + + formatLoginSuccessMessage({ + clientId: config.clientId, + tenantId: config.tenantId, + scopes: getScopes(), + account: account.username, + }), + ); + return; + } + } + + const result = await acquireTokenInteractiveWithFallbacks(); + console.log( + formatLoginSuccessMessage({ + clientId: config.clientId, + tenantId: config.tenantId, + scopes: getScopes(), + account: result.account?.username, + }), + ); +} + +/** + * Clear cached tokens (logout). Removes the current tenant's partitioned cache + * file as well as the legacy single-file cache, and evicts any in-memory + * accounts. Safe to call when files do not exist. + */ +export async function clearCachedToken(): Promise { + try { + log("Clearing cached tokens..."); + const { unlinkSync } = await import("node:fs"); + const filesToRemove = new Set([getCacheFilePath(), getLegacyCacheFile()]); + for (const file of filesToRemove) { + try { + if (existsSync(file)) { + unlinkSync(file); + log(`Removed cache file (${basename(file)})`); + } + } catch { /* ignore */ } + } + + if (pca) { + const tokenCache = pca.getTokenCache(); + const accounts = await tokenCache.getAllAccounts(); + for (const account of accounts) { + await tokenCache.removeAccount(account); + } + } + + log("Cached tokens cleared"); + } catch (error) { + log("Failed to clear cached tokens:", error); + throw error; + } +} + +// ─── Test seam ────────────────────────────────────────────────────────────── +// Internal hooks used ONLY by unit tests to inject/inspect in-memory state +// without performing real interactive authentication. Not part of the public API. + +export const __testing = { + /** Inject a (possibly fake) PublicClientApplication so getCachedAccount can run. */ + setPca(value: PublicClientApplication | null): void { + pca = value; + }, + /** The ~15-min AAD device-code lifetime (seconds) used as the default/upper bound. */ + DEVICE_CODE_LIFETIME_SECONDS, + /** The device-code cancel horizon in ms (derived from the lifetime, never below it). */ + DEVICE_CODE_TIMEOUT_MS, + /** Pure derivation of the cancel delay (ms) from a DeviceCodeResponse.expiresIn. */ + deviceCodeCancelDelayMs, + /** The device-code acquisition flow, exposed so timeout behavior can be driven with fake timers. */ + acquireTokenByDeviceCode, + /** Read the current in-memory PublicClientApplication (null after a reset). */ + getPca(): PublicClientApplication | null { + return pca; + }, + /** + * Reproduce the in-flight readiness state that initializeAuth() establishes + * while an acquisition is still pending (see initializeAuth): a pending + * authReadyPromise with its resolve/reject handles captured into module state, + * plus the same no-op `.catch()` so a later reset-driven rejection can never + * surface as an unhandled rejection. Returns the pending promise so a test can + * assert how it settles (e.g. that resetInMemoryAuthState rejects rather than + * abandons it). Kept as a test seam so the hang can be asserted hermetically, + * without invoking real MSAL interactive sign-in. + */ + primeInflightReadiness(): Promise { + authReadyPromise = new Promise((resolve, reject) => { + authReadyResolve = resolve; + authReadyReject = reject; + }); + authReadyPromise.catch(() => {}); + return authReadyPromise; + }, + /** Read the current in-memory readiness promise (null after a reset). */ + getAuthReadyPromise(): Promise | null { + return authReadyPromise; + }, + /** Fully reset module auth state between tests. */ + reset(): void { + authConfig = null; + resetInMemoryAuthState(); + }, +}; diff --git a/src/az-errors.test.ts b/src/az-errors.test.ts new file mode 100644 index 0000000..b839bbd --- /dev/null +++ b/src/az-errors.test.ts @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for the shared Azure CLI Conditional Access / claims error + * classifier and guidance builder. Pure functions — no I/O. + */ + +import { describe, it, expect } from "vitest"; +import { + isConditionalAccessOrClaimsError, + conditionalAccessGuidance, + asConditionalAccessError, + ConditionalAccessError, + enrichConditionalAccess, + ARM_LOGIN_SCOPE, +} from "./az-errors.js"; + +describe("isConditionalAccessOrClaimsError", () => { + const caMessages = [ + "AADSTS50076: Due to a configuration change made by your administrator, you must use multi-factor authentication.", + "AADSTS50079: Due to a configuration change, the user is required to enroll in multifactor authentication.", + "AADSTS50005: Device authentication is required.", + "AADSTS53003: Access has been blocked by Conditional Access policies.", + "AADSTS53000: Device is not in required device state.", + "interaction_required: The resource requires user interaction.", + "Interaction required to acquire token (InteractionRequired)", + "Continuous access evaluation resulted in a claims challenge", + "WWW-Authenticate: Bearer error=\"insufficient_claims\", claims=\"...\"", + "The request requires Conditional Access step-up authentication", + "Multi-Factor authentication is required for this operation", + "MFA is required", + ]; + + it.each(caMessages)("returns true for CA/claims/MFA message: %s", (msg) => { + expect(isConditionalAccessOrClaimsError(msg)).toBe(true); + }); + + it("is case-insensitive", () => { + expect(isConditionalAccessOrClaimsError("CONDITIONAL ACCESS POLICY")).toBe(true); + expect(isConditionalAccessOrClaimsError("aadsts50076 mfa")).toBe(true); + }); + + const nonCaMessages = [ + "Please run 'az login' to setup account.", + "ERROR: Please run 'az login' to access your accounts.", + "You are not logged in. Run az login.", + "No subscription found", + "spawn az ENOENT", + "'az' is not recognized as an internal or external command", + "Microsoft.Syntex billing account did not reach 'Succeeded'", + "AADSTS70011: The provided value for scope is not valid.", + "Some unrelated network timeout error", + ]; + + it.each(nonCaMessages)("returns false for non-CA message: %s", (msg) => { + expect(isConditionalAccessOrClaimsError(msg)).toBe(false); + }); + + it("does NOT classify a plain not-logged-in / az login message as CA", () => { + // This is the critical regression guard: CA must be a narrower, additional + // branch — plain not-logged-in must fall through to the existing guidance. + expect(isConditionalAccessOrClaimsError("az login required, please run 'az login'")).toBe(false); + }); +}); + +describe("conditionalAccessGuidance", () => { + it("interpolates the tenant id and the exact remediation command", () => { + const text = conditionalAccessGuidance("tenant-abc-123"); + expect(text).toContain("Conditional Access requires step-up authentication"); + expect(text).toContain(`az login --scope ${ARM_LOGIN_SCOPE} --tenant tenant-abc-123`); + expect(text).toContain("SharePoint admin center"); + expect(text).toContain("out of scope"); + }); + + it("uses a placeholder when the tenant id is unknown", () => { + const text = conditionalAccessGuidance(); + expect(text).toContain("--tenant "); + }); +}); + +describe("asConditionalAccessError", () => { + it("produces a ConditionalAccessError carrying tenant + guidance", () => { + const err = asConditionalAccessError("tenant-xyz"); + expect(err).toBeInstanceOf(ConditionalAccessError); + expect(err.tenantId).toBe("tenant-xyz"); + expect(err.message).toContain("az login --scope"); + expect(err.message).toContain("tenant-xyz"); + }); +}); + +describe("enrichConditionalAccess", () => { + it("returns the original error unchanged when not a CA failure", async () => { + const original = new Error("plain not logged in: run az login"); + const result = await enrichConditionalAccess(original, async () => "t1"); + expect(result).toBe(original); + }); + + it("converts a CA error and resolves the tenant id via the resolver", async () => { + const original = new Error("AADSTS50076: multi-factor authentication required"); + const result = await enrichConditionalAccess(original, async () => "tenant-from-resolver"); + expect(result).toBeInstanceOf(ConditionalAccessError); + expect((result as ConditionalAccessError).message).toContain("tenant-from-resolver"); + }); + + it("prefers an already-known tenant id on a ConditionalAccessError", async () => { + const original = new ConditionalAccessError("ca with claims challenge", "known-tenant"); + const result = await enrichConditionalAccess(original, async () => "should-not-be-used"); + expect((result as ConditionalAccessError).message).toContain("known-tenant"); + }); + + it("falls back to placeholder when the resolver throws", async () => { + const original = new Error("conditional access blocked"); + const result = await enrichConditionalAccess(original, async () => { + throw new Error("az account show failed"); + }); + expect((result as Error).message).toContain(""); + }); +}); diff --git a/src/az-errors.ts b/src/az-errors.ts new file mode 100644 index 0000000..c16b947 --- /dev/null +++ b/src/az-errors.ts @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Shared Azure CLI error classification for Conditional Access (CA) / + * claims-challenge / interaction-required failures. + * + * Used by both the bootstrap token path (bootstrap.ts) and the ARM control-plane + * operations (azure-cli.ts: Syntex provider registration + Microsoft.Syntex + * billing-account create) so that a CA step-up failure surfaces a single, + * actionable remediation instead of a generic "command failed" or the plain + * "not logged in" guidance. + * + * SCOPE NOTE (intentional): we do NOT attempt to automate a Conditional Access + * claims-challenge step-up here. `az` cannot cleanly redeem a claims challenge + * non-interactively, and the MCP server must not drive an interactive + * `az login`. The supported behaviour is DETECT → SURFACE → DOCUMENT: detect the + * CA/claims failure, tell the user exactly how to re-authenticate in their own + * terminal (or via the SharePoint admin center step-up), then let them retry. + * Full claims-challenge automation is future work and explicitly out of scope. + */ + +/** + * The ARM control-plane scope the SPE Builder requests for `az` token + * acquisition and `az rest` ARM writes. Surfaced in the remediation command so + * the user re-authenticates for the same resource that hit the CA policy. + */ +export const ARM_LOGIN_SCOPE = "https://management.core.windows.net//.default"; + +/** Placeholder used in remediation text when the tenant id cannot be resolved. */ +const TENANT_PLACEHOLDER = ""; + +/** + * Heuristically classify whether an `az` failure message indicates a + * Conditional Access / claims-challenge / interaction-required condition + * (i.e. the user must perform an interactive step-up to satisfy policy). + * + * Matches, case-insensitively: + * - `interaction_required` / "interaction required" (OAuth/MSAL signal) + * - "claims" / "claim challenge" (CA claims challenge) + * - "conditional access" + * - "multifactor" / "multi-factor" / "MFA" (step-up auth) + * - WWW-Authenticate "insufficient_claims" + * - representative AADSTS step-up/CA codes: 50076, 50079, 50005, 53000-series + * + * Deliberately NARROWER than {@link isNotLoggedInError}: a plain "az login" / + * "not logged in" message must NOT match here so callers can classify CA first + * and fall back to the not-logged-in guidance. + */ +export function isConditionalAccessOrClaimsError(message: string): boolean { + const m = message.toLowerCase(); + + // Textual signals (provider-agnostic). + if ( + m.includes("interaction_required") || + m.includes("interaction required") || + m.includes("interactionrequired") || + m.includes("conditional access") || + m.includes("insufficient_claims") || + m.includes("insufficient claims") || + m.includes("claim challenge") || + m.includes("claims challenge") || + m.includes("claims-challenge") || + m.includes("multifactor") || + m.includes("multi-factor") || + m.includes("mfa") + ) { + return true; + } + + // A bare "claims" token (e.g. "...requires additional claims...") — kept + // separate from the AADSTS check below so we still catch claims wording. + if (m.includes("claims")) { + return true; + } + + // Representative AADSTS step-up / Conditional Access error codes. + // - AADSTS50076: MFA required for the resource. + // - AADSTS50079: user must enrol for MFA (proof-up). + // - AADSTS50005: device authentication / Conditional Access. + // - AADSTS53000–53003: device not compliant / blocked by CA policy. + if ( + m.includes("aadsts50076") || + m.includes("aadsts50079") || + m.includes("aadsts50005") || + /aadsts5300[0-9]/.test(m) + ) { + return true; + } + + return false; +} + +/** + * Error raised when an `az` token or ARM operation fails because Conditional + * Access requires an interactive step-up. Distinct type so callers/tests can + * identify it and so the high-level ARM helpers can enrich it with the tenant id + * without re-classifying. + */ +export class ConditionalAccessError extends Error { + readonly tenantId?: string; + constructor(message: string, tenantId?: string) { + super(message); + this.name = "ConditionalAccessError"; + this.tenantId = tenantId; + } +} + +/** + * Build the actionable remediation text for a Conditional Access step-up. + * Pure/synchronous so it is trivially unit-testable; `tenantId` is interpolated + * into the exact `az login` command (placeholder when unknown). + */ +export function conditionalAccessGuidance(tenantId?: string): string { + const tenant = tenantId && tenantId.length > 0 ? tenantId : TENANT_PLACEHOLDER; + return ( + "Conditional Access requires step-up authentication to complete this Azure (ARM) operation. " + + "Re-authenticate interactively in your own terminal, then retry the operation:\n\n" + + ` az login --scope ${ARM_LOGIN_SCOPE} --tenant ${tenant}\n\n` + + "If interactive browser sign-in still fails the policy (e.g. an auth-context / \"p1\" step-up that " + + "silent token acquisition reports as InteractionRequired with a claims challenge), complete the " + + "step-up via the SharePoint admin center, then retry. See the \"Conditional Access / step-up\" note " + + "in the standard-billing setup docs (mcp-server/README.md). " + + "Note: full claims-challenge automation is not supported by the Azure CLI and is intentionally out of scope." + ); +} + +/** Construct a {@link ConditionalAccessError} carrying the remediation guidance. */ +export function asConditionalAccessError(tenantId?: string): ConditionalAccessError { + return new ConditionalAccessError(conditionalAccessGuidance(tenantId), tenantId); +} + +/** + * If `error` indicates a Conditional Access / claims step-up, return a + * {@link ConditionalAccessError} with actionable guidance (enriched with the + * tenant id when available); otherwise return `error` unchanged. + * + * `resolveTenantId` is an optional best-effort async lookup (e.g. `az account + * show`) used only when the tenant id is not already known. Any failure to + * resolve it falls back to the placeholder — we never mask the original CA + * signal just because the tenant id is unavailable. + */ +export async function enrichConditionalAccess( + error: unknown, + resolveTenantId?: () => Promise, +): Promise { + const message = error instanceof Error ? error.message : String(error); + if (!isConditionalAccessOrClaimsError(message)) { + return error; + } + let tenantId: string | undefined; + if (error instanceof ConditionalAccessError && error.tenantId) { + tenantId = error.tenantId; + } else if (resolveTenantId) { + try { + tenantId = await resolveTenantId(); + } catch { + tenantId = undefined; + } + } + return asConditionalAccessError(tenantId); +} diff --git a/src/azure-cli.test.ts b/src/azure-cli.test.ts new file mode 100644 index 0000000..7815d9b --- /dev/null +++ b/src/azure-cli.test.ts @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for azure-cli helpers that expose injectable seams (no real shell). + * + * Covers two concerns, both exercised through the functions' injectable seams so + * no `az` is shelled out and no real tenant is contacted: + * - createSyntexAccount region validation — Microsoft.Syntex/ + * accounts can only be provisioned in a fixed set of regions; an unsupported + * region (e.g. westus2) must fail fast with an actionable message BEFORE any + * ARM PUT, rather than a raw `LocationNotAvailableForResourceType` 400. + * - Conditional Access / claims step-up handling in the ARM control-plane + * helpers. + */ + +import { describe, it, expect, vi } from "vitest"; +import { + createSyntexAccount, + ensureSyntexProviderRegistered, + isSyntexRegionSupported, + assertSyntexRegionSupported, + type CreateSyntexAccountOptions, +} from "./azure-cli.js"; +import { ConditionalAccessError } from "./az-errors.js"; + +const noopSleep = async (): Promise => undefined; + +// A representative Conditional Access claims-challenge failure as `az` would +// surface it (matches the 2026-06-22 transcript: InteractionRequired + claims). +const CA_MESSAGE = + "InteractionRequired: AADSTS50076: Due to a configuration change made by your administrator, " + + "you must use multi-factor authentication. A claims challenge was returned by Conditional Access."; + +describe("Syntex region helpers (pre-flight validation)", () => { + it("isSyntexRegionSupported accepts supported regions and normalizes case/spaces", () => { + expect(isSyntexRegionSupported("eastus")).toBe(true); + expect(isSyntexRegionSupported("East US")).toBe(true); + expect(isSyntexRegionSupported(" westeurope ")).toBe(true); + }); + + it("isSyntexRegionSupported rejects unsupported regions (e.g. westus2)", () => { + expect(isSyntexRegionSupported("westus2")).toBe(false); + expect(isSyntexRegionSupported("nowhere")).toBe(false); + }); + + it("assertSyntexRegionSupported throws an actionable error for an unsupported region", () => { + expect(() => assertSyntexRegionSupported("westus2")).toThrow(/not available for Microsoft\.Syntex/i); + expect(() => assertSyntexRegionSupported("eastus")).not.toThrow(); + }); +}); + +describe("createSyntexAccount — region validation", () => { + it("rejects a region that cannot host Microsoft.Syntex/accounts before any ARM PUT", async () => { + const putAccount = vi.fn(async () => ({ id: "acc", properties: { provisioningState: "Succeeded" } })); + + await expect( + createSyntexAccount("sub-1", "rg-1", "westus2", "ct-1", { putAccount } as CreateSyntexAccountOptions), + ).rejects.toThrow(/not available for Microsoft\.Syntex/i); + + expect(putAccount).not.toHaveBeenCalled(); + }); + + it("accepts a supported region (normalizing case + spaces) and proceeds to the PUT", async () => { + const putAccount = vi.fn(async (_url: string, body: { location: string }) => { + // Region is normalized to canonical lower/no-space form for ARM. + expect(body.location).toBe("eastus"); + return { + id: "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Syntex/accounts/acc-1", + properties: { provisioningState: "Succeeded" }, + }; + }); + + const id = await createSyntexAccount( + "sub-1", "rg-1", "East US", "ct-1", { putAccount } as CreateSyntexAccountOptions, + ); + + expect(putAccount).toHaveBeenCalledTimes(1); + expect(id).toContain("acc-1"); + }); +}); + +describe("ensureSyntexProviderRegistered — Conditional Access", () => { + it("throws an actionable CA step-up error (with tenant id) when registration hits a claims challenge", async () => { + const err = await ensureSyntexProviderRegistered("sub-1", { + sleep: noopSleep, + showProvider: async () => ({ namespace: "Microsoft.Syntex", registrationState: "NotRegistered" }), + registerProvider: async () => { + throw new Error(CA_MESSAGE); + }, + resolveTenantId: async () => "tenant-abc", + }).catch((e: unknown) => e as Error); + + expect(err).toBeInstanceOf(ConditionalAccessError); + expect(err.message).toMatch(/Conditional Access requires step-up authentication/i); + expect(err.message).toContain( + "az login --scope https://management.core.windows.net//.default --tenant tenant-abc", + ); + }); + + it("passes non-CA failures through unchanged (timeout guidance preserved)", async () => { + const err = await ensureSyntexProviderRegistered("sub-1", { + timeoutMs: 0, // force immediate timeout + sleep: noopSleep, + showProvider: async () => ({ namespace: "Microsoft.Syntex", registrationState: "Registering" }), + registerProvider: async () => undefined, + resolveTenantId: async () => "tenant-abc", + }).catch((e: unknown) => e as Error); + + expect(err).not.toBeInstanceOf(ConditionalAccessError); + expect(err.message).toMatch(/did not finish registering/i); + }); +}); + +describe("createSyntexAccount — Conditional Access", () => { + it("throws an actionable CA step-up error including tenant id + remediation command", async () => { + const err = await createSyntexAccount("sub-1", "rg-1", "eastus", "ct-1", { + sleep: noopSleep, + newAccountName: () => "11111111-1111-1111-1111-111111111111", + putAccount: async () => { + throw new Error(CA_MESSAGE); + }, + resolveTenantId: async () => "tenant-abc", + }).catch((e: unknown) => e as Error); + + expect(err).toBeInstanceOf(ConditionalAccessError); + expect(err.message).toContain( + "az login --scope https://management.core.windows.net//.default --tenant tenant-abc", + ); + expect(err.message).toContain("SharePoint admin center"); + expect(err.message).toContain("out of scope"); + }); + + it("prefers an explicitly-provided tenant id over the resolver", async () => { + const err = await createSyntexAccount("sub-1", "rg-1", "eastus", "ct-1", { + sleep: noopSleep, + newAccountName: () => "11111111-1111-1111-1111-111111111111", + putAccount: async () => { + throw new Error(CA_MESSAGE); + }, + tenantId: "explicit-tenant", + resolveTenantId: async () => "should-not-be-used", + }).catch((e: unknown) => e as Error); + + expect(err.message).toContain("--tenant explicit-tenant"); + expect(err.message).not.toContain("should-not-be-used"); + }); + + it("passes a non-CA provisioning failure through unchanged", async () => { + const err = await createSyntexAccount("sub-1", "rg-1", "eastus", "ct-1", { + sleep: noopSleep, + newAccountName: () => "11111111-1111-1111-1111-111111111111", + putAccount: async () => ({ + id: "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Syntex/accounts/x", + properties: { provisioningState: "Provisioning" }, + }), + getAccount: async () => ({ + id: "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Syntex/accounts/x", + properties: { provisioningState: "Failed" }, + }), + deleteAccount: async () => undefined, + resolveTenantId: async () => "tenant-abc", + }).catch((e: unknown) => e as Error); + + expect(err).not.toBeInstanceOf(ConditionalAccessError); + expect(err.message).toMatch(/provisioning Failed/i); + }); +}); \ No newline at end of file diff --git a/src/azure-cli.ts b/src/azure-cli.ts new file mode 100644 index 0000000..dfc3b42 --- /dev/null +++ b/src/azure-cli.ts @@ -0,0 +1,620 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Azure CLI helpers for control-plane operations that are not on Microsoft + * Graph: listing subscriptions / resource groups and registering the + * `Microsoft.Syntex` resource provider for SPE billing. + * + * All commands shell out to `az`, which is cross-platform (Windows/macOS/Linux) + * and uses the developer's existing `az login` session — no Microsoft + * first-party app or pre-authorization required. + */ + +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getSignedInIdentity } from "./bootstrap.js"; +import { + isConditionalAccessOrClaimsError, + asConditionalAccessError, + enrichConditionalAccess, +} from "./az-errors.js"; + +const AZ_TIMEOUT_MS = 30_000; + +function azNeedsShell(): boolean { + return process.platform === "win32"; +} + +/** + * Best-effort tenant id from the current `az` sign-in, used to interpolate the + * exact re-auth command into Conditional Access guidance. Never throws — a + * missing tenant just yields a placeholder in the remediation text. `az account + * show` reads the cached account and does not itself require a CA step-up. + */ +async function resolveTenantIdBestEffort(): Promise { + try { + const identity = await getSignedInIdentity(); + return identity?.tenantId; + } catch { + return undefined; + } +} + +function isNotInstalled(message: string): boolean { + return ( + message.includes("ENOENT") || + message.includes("not found") || + message.includes("not recognized") || + message.includes("is not recognized") + ); +} + +const NOT_INSTALLED_MSG = + "Azure CLI ('az') is not installed. Install it from https://aka.ms/install-azure-cli, then run `az login --allow-no-subscriptions`."; + +function execFileAsync( + cmd: string, + args: string[], + opts: { timeout: number; shell?: boolean }, +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + execFile(cmd, args, opts, (err, stdout, stderr) => { + if (err) reject(err); + else resolve({ stdout, stderr }); + }); + }); +} + +/** Run an `az` command with `--output json` appended and parse the result. */ +export async function azJson(args: string[]): Promise { + try { + const { stdout } = await execFileAsync("az", [...args, "--output", "json"], { + timeout: AZ_TIMEOUT_MS, + shell: azNeedsShell(), + }); + return JSON.parse(stdout) as T; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (isNotInstalled(message)) { + throw new Error(NOT_INSTALLED_MSG); + } + // Conditional Access / claims step-up: surface the actionable re-auth path + // instead of a generic failure. Tenant id is enriched by the high-level ARM + // helpers; here we emit the guidance with a placeholder. Out of scope: full + // claims-challenge automation (the CLI cannot redeem it non-interactively). + if (isConditionalAccessOrClaimsError(message)) { + throw asConditionalAccessError(); + } + throw new Error(`Azure CLI command failed (az ${args.join(" ")}): ${message}`, { cause: error }); + } +} + +export interface AzureSubscription { + id: string; + name: string; + state: string; + tenantId?: string; + isDefault?: boolean; +} + +export interface AzureResourceGroup { + name: string; + location: string; + id: string; +} + +/** List Azure subscriptions the signed-in user can access. */ +export async function listSubscriptions(): Promise { + const subs = await azJson(["account", "list", "--all"]); + return subs.filter((s) => s.state === "Enabled"); +} + +/** + * Whether the Azure CLI currently has an active sign-in. + * + * `az account list` returns `[]` with exit code 0 when the user is NOT signed + * in, which is indistinguishable from a signed-in user who genuinely has zero + * subscriptions. Callers probe `az account show` (which fails when not signed + * in) to tell the two apart and surface `az login` guidance. + */ +export async function isSignedIn(): Promise { + try { + await execFileAsync("az", ["account", "show", "--output", "json"], { + timeout: AZ_TIMEOUT_MS, + shell: azNeedsShell(), + }); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (isNotInstalled(message)) { + throw new Error(NOT_INSTALLED_MSG); + } + // Any other failure (e.g. "Please run 'az login' to setup account") means + // there is no usable sign-in. + return false; + } +} + +/** List resource groups in a subscription. */ +export async function listResourceGroups(subscriptionId: string): Promise { + return azJson(["group", "list", "--subscription", subscriptionId]); +} + +/** + * Best-effort existence probe for a resource group, so a user-entered name can be + * verified BEFORE any container-type / billing resource is created (fail + * cost-free). Non-throwing by design. + * + * Returns: + * - `true` — the resource group exists in the subscription. + * - `false` — Azure definitively reports it does not exist. + * - `undefined` — indeterminate (az not installed, not signed in, Conditional + * Access step-up, timeout, or any other error). Callers should + * degrade gracefully and proceed with the name rather than block + * on a probe that could not run. + * (PR #3 review.) + */ +export async function resourceGroupExists( + name: string, + subscriptionId: string, +): Promise { + try { + await execFileAsync( + "az", + ["group", "show", "--name", name, "--subscription", subscriptionId, "--output", "json"], + { timeout: AZ_TIMEOUT_MS, shell: azNeedsShell() }, + ); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // az missing / not signed in / recognized-command failure — indeterminate. + // Checked FIRST because `isNotInstalled` also matches a bare "not found". + if (isNotInstalled(message)) return undefined; + // Definitive negative from ARM: `ResourceGroupNotFound` / + // "Resource group 'X' could not be found." + if (/resourcegroupnotfound|could not be found|does not exist/i.test(message)) { + return false; + } + // Anything else (auth, throttling, transient) is inconclusive → proceed. + return undefined; + } +} + +export interface ProviderRegistration { + namespace: string; + registrationState: string; +} + +const SYNTEX_NAMESPACE = "Microsoft.Syntex"; + +// Bound the registration wait the same way the VS Code extension does: +// poll every ~20s for up to 5 minutes (CreateStandardContainerType.ts uses a +// 30s interval / 5 min timeout). The official SPE docs likewise say to "wait +// 5–10 minutes ... until the cmdlet succeeds". +const SYNTEX_POLL_INTERVAL_MS = 20_000; +const SYNTEX_POLL_TIMEOUT_MS = 5 * 60 * 1000; + +function defaultSleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Read the current registration state of the Syntex RP (null if unavailable). */ +export async function showSyntexProvider( + subscriptionId: string, +): Promise { + return azJson([ + "provider", "show", "--namespace", SYNTEX_NAMESPACE, "--subscription", subscriptionId, + ]).catch(() => null); +} + +/** Trigger registration of the Syntex RP (async on the Azure side). */ +export async function registerSyntexProvider(subscriptionId: string): Promise { + try { + await execFileAsync( + "az", + ["provider", "register", "--namespace", SYNTEX_NAMESPACE, "--subscription", subscriptionId], + { timeout: AZ_TIMEOUT_MS, shell: azNeedsShell() }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (isNotInstalled(message)) { + throw new Error(NOT_INSTALLED_MSG); + } + if (isConditionalAccessOrClaimsError(message)) { + throw asConditionalAccessError(); + } + throw new Error( + `Azure CLI command failed (az provider register --namespace ${SYNTEX_NAMESPACE}): ${message}`, + { cause: error }, + ); + } +} + +/** Injectable seams so the polling loop is unit-testable without shelling out. */ +export interface EnsureSyntexRegisteredOptions { + /** Total time to wait for `Registered` before failing. Default: 5 min. */ + timeoutMs?: number; + /** Delay between status polls. Default: 20s. */ + intervalMs?: number; + /** Override the sleep used between polls (tests inject a no-op). */ + sleep?: (ms: number) => Promise; + /** Monotonic clock source (tests inject a fake). */ + now?: () => number; + /** Override the status read (tests inject a stub). */ + showProvider?: (subscriptionId: string) => Promise; + /** Override the register trigger (tests inject a stub). */ + registerProvider?: (subscriptionId: string) => Promise; + /** Known tenant id, interpolated into Conditional Access step-up guidance. */ + tenantId?: string; + /** Best-effort tenant resolver used for CA guidance (tests inject a stub). */ + resolveTenantId?: () => Promise; +} + +/** + * Ensure the `Microsoft.Syntex` resource provider is **Registered** on the + * subscription — the Azure-side prerequisite for SPE standard billing. + * + * Idempotent: returns immediately when already `Registered`. Otherwise it + * triggers registration and POLLS until the provider reports `Registered`, + * bounded by `timeoutMs`. If the timeout elapses while still `Registering`, + * it throws an actionable error so callers do not race the billing PATCH + * against an incomplete registration. + */ +export async function ensureSyntexProviderRegistered( + subscriptionId: string, + options: EnsureSyntexRegisteredOptions = {}, +): Promise { + const { + timeoutMs = SYNTEX_POLL_TIMEOUT_MS, + intervalMs = SYNTEX_POLL_INTERVAL_MS, + sleep = defaultSleep, + now = Date.now, + showProvider = showSyntexProvider, + registerProvider = registerSyntexProvider, + tenantId, + resolveTenantId = resolveTenantIdBestEffort, + } = options; + + try { + const current = await showProvider(subscriptionId); + if (current?.registrationState === "Registered") { + return current; + } + + await registerProvider(subscriptionId); + + const deadline = now() + timeoutMs; + let lastState = current?.registrationState ?? "NotRegistered"; + + // Poll until Registered or the deadline passes. We sleep first because + // registration was just (re)triggered and won't be instantaneous. + while (now() < deadline) { + await sleep(intervalMs); + const latest = await showProvider(subscriptionId); + if (latest?.registrationState) { + lastState = latest.registrationState; + if (lastState === "Registered") { + return latest; + } + } + } + + throw new Error( + `Microsoft.Syntex resource provider did not finish registering on subscription ` + + `${subscriptionId} within ${Math.round(timeoutMs / 1000)}s (last state: ${lastState}). ` + + `Registration can take a few minutes — wait and retry, or run ` + + `\`az provider register --namespace Microsoft.Syntex --subscription ${subscriptionId}\` ` + + `and check \`az provider show --namespace Microsoft.Syntex\` until it reports Registered.`, + ); + } catch (error) { + // If the ARM write hit a Conditional Access step-up, re-throw the enriched + // actionable error (with the resolved tenant id). Non-CA errors pass through + // unchanged. Full claims-challenge automation is intentionally out of scope. + throw await enrichConditionalAccess(error, async () => tenantId ?? (await resolveTenantId())); + } +} + +// ─── Microsoft.Syntex/accounts (RaaS) ARM billing account ─────────────────── +// +// Standard SPE billing is attached by creating a `Microsoft.Syntex/accounts` +// ARM resource on the chosen subscription/RG (the RaaS billing account). The +// token plane is ARM (management.azure.com), which `az rest` provides from the +// existing `az login`. Mirrors the VS Code extension's ARMProvider exactly +// (api-version 2023-01-04-preview; assert provisioningState === "Succeeded"). + +const ARM_BASE = "https://management.azure.com"; +const SYNTEX_ACCOUNT_API_VERSION = "2023-01-04-preview"; +const SYNTEX_ACCOUNT_POLL_INTERVAL_MS = 10_000; +const SYNTEX_ACCOUNT_POLL_TIMEOUT_MS = 5 * 60 * 1000; + +// Azure regions where Microsoft.Syntex/accounts (the RaaS billing account for +// SharePoint Embedded standard billing) can be provisioned. ARM rejects any +// other region with `LocationNotAvailableForResourceType` (e.g. westus2), so +// validate up front and fail with an actionable message instead of a raw ARM +// 400. Sourced from the ARM error's "List of available regions". +const SYNTEX_SUPPORTED_REGIONS = new Set([ + "eastus", "eastus2", "centralus", "northcentralus", "southcentralus", "westcentralus", + "westus", "canadacentral", "canadaeast", "brazilsouth", "northeurope", "westeurope", + "norwayeast", "norwaywest", "francecentral", "francesouth", "switzerlandnorth", + "switzerlandwest", "uksouth", "ukwest", "germanynorth", "australiaeast", + "australiasoutheast", "centralindia", "southindia", "westindia", "japaneast", + "eastasia", "southeastasia", "koreacentral", "uaenorth", "southafricanorth", + "southafricawest", +]); + +/** Normalize a region string the way ARM compares locations (lower-case, no spaces). */ +function normalizeRegion(region: string): string { + return region.trim().toLowerCase().replace(/\s+/g, ""); +} + +/** True if `region` can host a Microsoft.Syntex/accounts (SPE standard billing) account. */ +export function isSyntexRegionSupported(region: string): boolean { + return SYNTEX_SUPPORTED_REGIONS.has(normalizeRegion(region)); +} + +/** + * Throw an actionable error if `region` cannot host a Microsoft.Syntex account. + * Callers on the standard-billing path MUST run this BEFORE creating the + * container type: a standard container type cannot be deleted (Graph 422 + * "Cannot delete container type for non trial"), so an invalid region caught + * only at billing-account creation time leaves an un-rollback-able orphan CT. + * Validating up front keeps the failure cost-free and reversible. + */ +export function assertSyntexRegionSupported(region: string): void { + if (!isSyntexRegionSupported(region)) { + throw new Error( + `Azure region '${region}' is not available for Microsoft.Syntex/accounts ` + + "(SharePoint Embedded standard billing). Choose a supported region, e.g. " + + `eastus, westus, westeurope, uksouth. Full list: ${[...SYNTEX_SUPPORTED_REGIONS].join(", ")}.`, + ); + } +} + +interface SyntexAccountProperties { + friendlyName?: string; + service?: string; + identityType?: string; + identityId?: string; + feature?: string; + scope?: string; + provisioningState?: string; +} + +export interface SyntexAccount { + id: string; + name?: string; + location?: string; + properties?: SyntexAccountProperties; +} + +interface SyntexAccountRequestBody { + location: string; + properties: { + friendlyName: string; + service: "SPO"; + identityType: "ContainerType"; + identityId: string; + feature: "RaaS"; + scope: "Global"; + }; +} + +/** Run `az rest ... --output json` and parse the response body. */ +async function azRestJson(args: string[]): Promise { + try { + const { stdout } = await execFileAsync("az", ["rest", ...args, "--output", "json"], { + timeout: AZ_TIMEOUT_MS, + shell: azNeedsShell(), + }); + const out = stdout.trim(); + return (out ? JSON.parse(out) : {}) as T; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (isNotInstalled(message)) { + throw new Error(NOT_INSTALLED_MSG); + } + if (isConditionalAccessOrClaimsError(message)) { + throw asConditionalAccessError(); + } + throw new Error(`Azure CLI command failed (az rest ${args.join(" ")}): ${message}`, { cause: error }); + } +} + +/** + * Default PUT seam: writes the JSON body to an OS temp FILE (avoids shell + * quoting of inline JSON) and PUTs the ARM account. + */ +async function putSyntexAccountViaAz( + url: string, + body: SyntexAccountRequestBody, +): Promise { + const dir = mkdtempSync(join(tmpdir(), "spe-syntex-")); + const bodyFile = join(dir, "account.json"); + try { + writeFileSync(bodyFile, JSON.stringify(body), "utf-8"); + const { stdout } = await execFileAsync( + "az", + [ + "rest", + "--method", "put", + "--url", url, + "--headers", "Content-Type=application/json", + "--body", `@${bodyFile}`, + "--output", "json", + ], + { timeout: AZ_TIMEOUT_MS, shell: azNeedsShell() }, + ); + const out = stdout.trim(); + return (out ? JSON.parse(out) : { id: "" }) as SyntexAccount; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (isNotInstalled(message)) { + throw new Error(NOT_INSTALLED_MSG); + } + if (isConditionalAccessOrClaimsError(message)) { + throw asConditionalAccessError(); + } + throw new Error(`Azure CLI command failed (az rest put ${url}): ${message}`, { cause: error }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +/** Default GET seam used to poll a single account by its ARM resource id. */ +function showSyntexAccount(resourceId: string): Promise { + return azRestJson([ + "--method", "get", "--url", `${ARM_BASE}${resourceId}?api-version=${SYNTEX_ACCOUNT_API_VERSION}`, + ]); +} + +/** List the Microsoft.Syntex accounts in a resource group (idempotency probe). */ +export async function getSyntexAccounts( + subscriptionId: string, + resourceGroup: string, +): Promise { + const url = + `${ARM_BASE}/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}` + + `/providers/Microsoft.Syntex/accounts?api-version=${SYNTEX_ACCOUNT_API_VERSION}`; + const result = await azRestJson<{ value?: SyntexAccount[] }>(["--method", "get", "--url", url]); + return result.value ?? []; +} + +/** Delete a Microsoft.Syntex account by its ARM resource id (partial-account cleanup). */ +export async function deleteSyntexAccount(resourceId: string): Promise { + try { + await execFileAsync( + "az", + ["rest", "--method", "delete", "--url", `${ARM_BASE}${resourceId}?api-version=${SYNTEX_ACCOUNT_API_VERSION}`], + { timeout: AZ_TIMEOUT_MS, shell: azNeedsShell() }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (isNotInstalled(message)) { + throw new Error(NOT_INSTALLED_MSG); + } + if (isConditionalAccessOrClaimsError(message)) { + throw asConditionalAccessError(); + } + throw new Error(`Azure CLI command failed (az rest delete ${resourceId}): ${message}`, { cause: error }); + } +} + +/** Injectable seams so the PUT + bounded poll are unit-testable without shelling out. */ +export interface CreateSyntexAccountOptions { + timeoutMs?: number; + intervalMs?: number; + sleep?: (ms: number) => Promise; + now?: () => number; + /** Override the new account name/UUID (tests inject a deterministic value). */ + newAccountName?: () => string; + /** Override the PUT (tests capture url + body here). */ + putAccount?: (url: string, body: SyntexAccountRequestBody) => Promise; + /** Override the polling GET (tests inject a stub). */ + getAccount?: (resourceId: string) => Promise; + /** Override the partial-account cleanup delete (tests inject a stub). */ + deleteAccount?: (resourceId: string) => Promise; + /** Known tenant id, interpolated into Conditional Access step-up guidance. */ + tenantId?: string; + /** Best-effort tenant resolver used for CA guidance (tests inject a stub). */ + resolveTenantId?: () => Promise; +} + +/** + * Create the `Microsoft.Syntex/accounts` (RaaS) ARM billing account for a + * container type and assert it reaches `provisioningState === "Succeeded"`, + * polling if the PUT returns a non-terminal state. Returns the ARM resource id. + * + * Transactional for the ARM account only: if provisioning ends Failed/Canceled + * or times out, the partially-created account is deleted (best-effort) before + * throwing. It NEVER touches the container type — create-time rollback of the + * CT is the caller's responsibility. + */ +export async function createSyntexAccount( + subscriptionId: string, + resourceGroup: string, + region: string, + containerTypeId: string, + options: CreateSyntexAccountOptions = {}, +): Promise { + const { + timeoutMs = SYNTEX_ACCOUNT_POLL_TIMEOUT_MS, + intervalMs = SYNTEX_ACCOUNT_POLL_INTERVAL_MS, + sleep = defaultSleep, + now = Date.now, + newAccountName = () => randomUUID(), + putAccount = putSyntexAccountViaAz, + getAccount = showSyntexAccount, + deleteAccount = deleteSyntexAccount, + tenantId, + resolveTenantId = resolveTenantIdBestEffort, + } = options; + + // Fail fast with an actionable message if the region can't host a Syntex + // account, instead of surfacing a raw ARM `LocationNotAvailableForResourceType`. + // NOTE: callers on the provisioning path validate this BEFORE creating the + // container type (see assertSyntexRegionSupported) so a bad region never + // orphans a non-deletable standard CT; this is the last-line guard. + assertSyntexRegionSupported(region); + const normalizedRegion = normalizeRegion(region); + + try { + const accountName = newAccountName(); + const resourcePath = + `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}` + + `/providers/Microsoft.Syntex/accounts/${accountName}`; + const url = `${ARM_BASE}${resourcePath}?api-version=${SYNTEX_ACCOUNT_API_VERSION}`; + const body: SyntexAccountRequestBody = { + location: normalizedRegion, + properties: { + friendlyName: `CT_${containerTypeId}`, + service: "SPO", + identityType: "ContainerType", + identityId: containerTypeId, + feature: "RaaS", + scope: "Global", + }, + }; + + + const created = await putAccount(url, body); + const resourceId = created.id || resourcePath; + + if (created.properties?.provisioningState === "Succeeded") { + return resourceId; + } + + const deadline = now() + timeoutMs; + let lastState = created.properties?.provisioningState ?? "unknown"; + while (now() < deadline) { + await sleep(intervalMs); + const latest = await getAccount(resourceId); + lastState = latest.properties?.provisioningState ?? lastState; + if (lastState === "Succeeded") { + return resourceId; + } + if (lastState === "Failed" || lastState === "Canceled") { + await deleteAccount(resourceId).catch(() => undefined); + throw new Error( + `Microsoft.Syntex billing account ${resourceId} provisioning ${lastState}; ` + + `the partially-created account was cleaned up.`, + ); + } + } + + await deleteAccount(resourceId).catch(() => undefined); + throw new Error( + `Microsoft.Syntex billing account ${resourceId} did not reach 'Succeeded' within ` + + `${Math.round(timeoutMs / 1000)}s (last state: ${lastState}); the partial account was cleaned up.`, + ); + } catch (error) { + // A Conditional Access step-up on the ARM PUT/GET surfaces as the enriched + // actionable error (with the resolved tenant id); all other errors (Failed/ + // Canceled/timeout) pass through unchanged. Full claims-challenge automation + // is intentionally out of scope. + throw await enrichConditionalAccess(error, async () => tenantId ?? (await resolveTenantId())); + } +} diff --git a/src/bootstrap.test.ts b/src/bootstrap.test.ts new file mode 100644 index 0000000..793387c --- /dev/null +++ b/src/bootstrap.test.ts @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for the Azure CLI bootstrap module. + * `node:child_process.execFile` is mocked so these run offline. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("node:child_process", () => ({ execFile: vi.fn() })); + +import { execFile } from "node:child_process"; +import { assertAzCli, getSignedInIdentity, getBootstrapToken } from "./bootstrap.js"; + +type ExecCb = (err: Error | null, stdout: string, stderr: string) => void; + +function mockExec(result: { stdout?: string; error?: Error }): void { + vi.mocked(execFile).mockImplementation((( + _cmd: string, + _args: string[], + _opts: unknown, + cb: ExecCb, + ) => { + if (result.error) cb(result.error, "", ""); + else cb(null, result.stdout ?? "", ""); + return {} as never; + }) as never); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("assertAzCli", () => { + it("resolves when az is installed", async () => { + mockExec({ stdout: '{"azure-cli":"2.60.0"}' }); + await expect(assertAzCli()).resolves.toBeUndefined(); + }); + + it("throws a friendly not-installed error on ENOENT", async () => { + mockExec({ error: new Error("spawn az ENOENT") }); + await expect(assertAzCli()).rejects.toThrow(/not installed/i); + }); +}); + +describe("getSignedInIdentity", () => { + it("returns tenant and user when signed in", async () => { + mockExec({ stdout: JSON.stringify({ tenantId: "tenant-123", user: { name: "dev@contoso.com" } }) }); + const id = await getSignedInIdentity(); + expect(id).toEqual({ tenantId: "tenant-123", username: "dev@contoso.com" }); + }); + + it("returns null when not signed in (az exits non-zero)", async () => { + mockExec({ error: new Error("Please run 'az login' to setup account.") }); + const id = await getSignedInIdentity(); + expect(id).toBeNull(); + }); + + it("throws not-installed error on ENOENT", async () => { + mockExec({ error: new Error("'az' is not recognized") }); + await expect(getSignedInIdentity()).rejects.toThrow(/not installed/i); + }); +}); + +describe("getBootstrapToken", () => { + it("returns an access token for Graph", async () => { + mockExec({ + stdout: JSON.stringify({ + accessToken: "tok-abc", + expiresOn: "2026-06-17 18:00:00.000000", + tenantId: "tenant-123", + }), + }); + const token = await getBootstrapToken(); + expect(token.accessToken).toBe("tok-abc"); + expect(token.tenantId).toBe("tenant-123"); + expect(token.expiresOn).toBeInstanceOf(Date); + }); + + it("throws a friendly not-signed-in error", async () => { + mockExec({ error: new Error("Please run 'az login' to setup account.") }); + await expect(getBootstrapToken()).rejects.toThrow(/not signed in/i); + }); + + it("throws an actionable Conditional Access step-up error (not the plain not-signed-in path)", async () => { + // CA/claims is a MORE specific branch than not-logged-in and must win. + mockExec({ + error: new Error( + "AADSTS50076: Due to a configuration change made by your administrator, you must use " + + "multi-factor authentication to access the resource. Trace ID: ...", + ), + }); + const err = await getBootstrapToken().catch((e: unknown) => e as Error); + expect(err.message).toMatch(/Conditional Access requires step-up authentication/i); + expect(err.message).toContain("az login --scope https://management.core.windows.net//.default --tenant"); + // tenant cannot be resolved under the simulated CA failure, so a placeholder is used. + expect(err.message).toContain(""); + expect(err.message).not.toMatch(/--allow-no-subscriptions/); + }); + + it("throws not-installed error on ENOENT", async () => { + mockExec({ error: new Error("spawn az ENOENT") }); + await expect(getBootstrapToken()).rejects.toThrow(/not installed/i); + }); + + it("throws when az returns no token", async () => { + mockExec({ stdout: JSON.stringify({ expiresOn: "x" }) }); + await expect(getBootstrapToken()).rejects.toThrow(/no access token/i); + }); +}); + +describe("cross-platform az invocation", () => { + // `az` is a native binary on macOS/Linux but a `.cmd` shim on Windows that + // must be resolved through a shell. bootstrap.ts sets `shell: true` only on + // win32; this asserts the invocation adapts to the current platform so the + // command works on both Windows and Linux. + it("passes shell:true on Windows and falsy elsewhere", async () => { + mockExec({ stdout: '{"azure-cli":"2.60.0"}' }); + await assertAzCli(); + + const opts = vi.mocked(execFile).mock.calls[0]?.[2] as { shell?: boolean }; + if (process.platform === "win32") { + expect(opts.shell).toBe(true); + } else { + expect(opts.shell).toBeFalsy(); + } + }); + + // Regardless of platform, args are passed as an array (never a concatenated + // shell string), so paths/values with spaces aren't word-split by the shell. + it("invokes az with an argv array, not a concatenated command string", async () => { + mockExec({ stdout: '{"azure-cli":"2.60.0"}' }); + await assertAzCli(); + + const [cmd, args] = vi.mocked(execFile).mock.calls[0] as unknown as [string, string[]]; + expect(cmd).toBe("az"); + expect(Array.isArray(args)).toBe(true); + expect(args).toContain("version"); + }); +}); diff --git a/src/bootstrap.ts b/src/bootstrap.ts new file mode 100644 index 0000000..ac0f296 --- /dev/null +++ b/src/bootstrap.ts @@ -0,0 +1,208 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Bootstrap (control-plane) authentication via the Azure CLI. + * + * This is the FIRST token in the SPE Builder two-token model. The developer is + * already signed into `az`, whose first-party CLI app carries + * `Application.ReadWrite.All` + Graph basics. We use that token to bootstrap — + * create the owning Entra app, read `/me`, etc. — WITHOUT requiring any + * Microsoft-owned first-party app or pre-authorization. + * + * The SECOND token (SPE-scoped, acquired via MSAL device-code AS the + * newly-created owning app) lives in auth.ts and is wired in Phase 1. + * + * Cross-platform: shells out to `az`, which is available on Windows/macOS/Linux. + */ + +import { execFile } from "node:child_process"; +import { + isConditionalAccessOrClaimsError, + asConditionalAccessError, +} from "./az-errors.js"; + +const GRAPH_RESOURCE = "https://graph.microsoft.com"; +const AZ_TIMEOUT_MS = 20_000; + +function log(message: string, data?: unknown): void { + const line = `[${new Date().toISOString()}] [Bootstrap] ${message}`; + if (data !== undefined) { + console.error(line, typeof data === "string" ? data : JSON.stringify(data)); + } else { + console.error(line); + } +} + +function execFileAsync( + cmd: string, + args: string[], + opts: { timeout: number; shell?: boolean }, +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + execFile(cmd, args, opts, (err, stdout, stderr) => { + if (err) reject(err); + else resolve({ stdout, stderr }); + }); + }); +} + +/** True on Windows, where `az` is a `.cmd` shim that needs a shell to resolve. */ +function azNeedsShell(): boolean { + return process.platform === "win32"; +} + +function isNotInstalledError(message: string): boolean { + return ( + message.includes("ENOENT") || + message.includes("not found") || + message.includes("not recognized") || + message.includes("is not recognized") + ); +} + +function isNotLoggedInError(message: string): boolean { + const m = message.toLowerCase(); + return ( + m.includes("az login") || + m.includes("please run") || + m.includes("no subscription") || + m.includes("not logged in") || + m.includes("aadsts") + ); +} + +const NOT_INSTALLED_MSG = + "Azure CLI ('az') is not installed. Install it from https://aka.ms/install-azure-cli, then run `az login --allow-no-subscriptions`."; +const NOT_LOGGED_IN_MSG = + "Azure CLI is not signed in. Run `az login --allow-no-subscriptions` (the `--allow-no-subscriptions` flag is required for M365-only tenants with no Azure subscription)."; + +export interface SignedInIdentity { + tenantId: string; + username: string; +} + +export interface BootstrapToken { + accessToken: string; + expiresOn: Date | null; + tenantId: string; +} + +/** + * Verify the Azure CLI is installed. Throws a friendly, actionable error if not. + */ +export async function assertAzCli(): Promise { + try { + await execFileAsync("az", ["version", "--output", "json"], { + timeout: AZ_TIMEOUT_MS, + shell: azNeedsShell(), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (isNotInstalledError(message)) { + throw new Error(NOT_INSTALLED_MSG); + } + throw new Error(`Failed to invoke Azure CLI: ${message}`, { cause: error }); + } +} + +/** + * Get the currently signed-in `az` identity (tenant + user), or `null` if the + * CLI is installed but not signed in. + */ +export async function getSignedInIdentity(): Promise { + try { + // NOTE: do NOT use `--query` here. On Windows `az` is a `.cmd` shim that + // requires shell:true, and a `--query` value containing spaces/braces gets + // word-split by the shell. Fetch the full JSON and parse it in JS instead. + const { stdout } = await execFileAsync("az", ["account", "show", "--output", "json"], { + timeout: AZ_TIMEOUT_MS, + shell: azNeedsShell(), + }); + const parsed = JSON.parse(stdout) as { + tenantId?: string; + user?: { name?: string }; + }; + if (!parsed.tenantId) return null; + return { tenantId: parsed.tenantId, username: parsed.user?.name ?? "unknown" }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (isNotInstalledError(message)) { + throw new Error(NOT_INSTALLED_MSG); + } + // `az account show` exits non-zero when not logged in — treat as "no identity". + log("No signed-in az identity found"); + return null; + } +} + +/** + * Best-effort tenant id from the current `az` sign-in, or `undefined`. Used to + * interpolate the exact re-auth command into Conditional Access guidance; never + * throws (a missing tenant just yields a placeholder in the remediation text). + */ +async function resolveTenantIdBestEffort(): Promise { + try { + const identity = await getSignedInIdentity(); + return identity?.tenantId; + } catch { + return undefined; + } +} + +/** + * Acquire a bootstrap access token for the given resource (default: Microsoft + * Graph) from the Azure CLI. Throws friendly errors for not-installed / + * not-signed-in. + */ +export async function getBootstrapToken(resource: string = GRAPH_RESOURCE): Promise { + log(`Acquiring bootstrap token for ${resource}`); + try { + const { stdout } = await execFileAsync( + "az", + ["account", "get-access-token", "--resource", resource, "--output", "json"], + { timeout: AZ_TIMEOUT_MS, shell: azNeedsShell() }, + ); + const parsed = JSON.parse(stdout) as { + accessToken?: string; + expiresOn?: string; + tenant?: string; + tenantId?: string; + }; + if (!parsed.accessToken) { + throw new Error("Azure CLI returned no access token"); + } + return { + accessToken: parsed.accessToken, + expiresOn: parsed.expiresOn ? new Date(parsed.expiresOn) : null, + tenantId: parsed.tenantId ?? parsed.tenant ?? "", + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (isNotInstalledError(message)) { + throw new Error(NOT_INSTALLED_MSG); + } + // Classify Conditional Access / claims step-up FIRST: it is a more specific + // condition than plain "not logged in" (which would otherwise also match the + // AADSTS signal). We resolve the tenant best-effort via `az account show` + // (which does not require a step-up) to interpolate the exact remediation + // command. Full claims-challenge automation is intentionally out of scope. + if (isConditionalAccessOrClaimsError(message)) { + const tenantId = await resolveTenantIdBestEffort(); + throw asConditionalAccessError(tenantId); + } + if (isNotLoggedInError(message)) { + throw new Error(NOT_LOGGED_IN_MSG); + } + throw new Error(`Azure CLI bootstrap token acquisition failed: ${message}`, { cause: error }); + } +} + +/** + * Token-provider form of {@link getBootstrapToken} for passing to graph-client + * functions that accept a `getToken` callback (e.g. owning-app creation). + */ +export async function bootstrapTokenProvider(): Promise { + const { accessToken } = await getBootstrapToken(); + return accessToken; +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..0e3e73d --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,172 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + + +/** + * SPE MCP Server CLI. + * + * Commands: + * spe-mcp start — Start the MCP server (stdio transport) + * spe-mcp auth — Authenticate interactively (pre-cache tokens; --reset clears first) + * spe-mcp logout — Clear cached tokens + */ + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Command } from "commander"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const packageJson = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8")); + +/** Treat common truthy spellings of an env var as `true` (1/true/yes/on). */ +function isTruthyEnv(value: string | undefined): boolean { + if (!value) return false; + return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase()); +} + +/** Shared `--data-dir` option description (start / auth / logout). */ +const DATA_DIR_OPTION = + "Directory for the token cache + provisioning state (default ~/.spe-mcp). " + + "Point each instance at a unique path to run multiple servers without clobbering state. " + + "Must be absolute (or ~/...). Can also be set via SPE_DATA_DIR."; + +/** + * Resolve the data directory from `--data-dir` (falling back to SPE_DATA_DIR), + * record it as the process-wide override BEFORE any state/auth module reads the + * location, propagate it via SPE_DATA_DIR for defense-in-depth, and log the + * resolved path to stderr only (path-only; stdout is the MCP JSON-RPC channel). + * Throws AppError on an invalid (e.g. CWD-relative) path — caught by each + * command's try/catch so the failure is loud, not silent. + */ +async function applyDataDir(dataDir?: string): Promise { + const { setDataDirOverride } = await import("./paths.js"); + const resolved = setDataDirOverride(dataDir || process.env.SPE_DATA_DIR); + process.env.SPE_DATA_DIR = resolved; + console.error(`[SPE MCP Server] Data directory: ${resolved}`); +} + +const program = new Command(); + +program + .name("spe-mcp") + .description("SharePoint Embedded MCP Server — manage SPE resources via any MCP client") + .version(packageJson.version); + +program + .command("start") + .description("Start the SPE MCP server") + .option( + "--client-id ", + "Owning Entra app Client ID (OPTIONAL). Omit to run in bootstrap mode (Azure CLI control plane). Can also be set via SPE_CLIENT_ID.", + ) + .option( + "--tenant-id ", + "Entra ID Tenant ID (OPTIONAL). Discovered from the Azure CLI when omitted. Can also be set via SPE_TENANT_ID.", + ) + .option( + "--read-only", + "Read-only mode: advertise and allow only read/list/get/search tools; reject every mutating call. Can also be set via SPE_READ_ONLY (truthy).", + ) + .option( + "--tools ", + "Restrict exposed tools: a built-in profile (readOnly, docsOnly, provisioning, content, admin) or a comma-separated list of tool names. Can also be set via SPE_TOOLS.", + ) + .option("--data-dir ", DATA_DIR_OPTION) + .action(async (options: { clientId?: string; tenantId?: string; readOnly?: boolean; tools?: string; dataDir?: string }) => { + try { + // Resolve + record the data dir FIRST, before importing ./index.js (which + // pulls in state.ts/auth.ts) so every entry point resolves the same dir. + await applyDataDir(options.dataDir); + const clientId = options.clientId || process.env.SPE_CLIENT_ID; + const tenantId = options.tenantId || process.env.SPE_TENANT_ID; + // Read-only: CLI flag wins; otherwise a truthy SPE_READ_ONLY env value. + const readOnly = options.readOnly === true || isTruthyEnv(process.env.SPE_READ_ONLY); + // Tool allowlist/profile: CLI flag wins; otherwise SPE_TOOLS env. + const tools = options.tools || process.env.SPE_TOOLS; + + // Both are optional. With no client-id the server runs in bootstrap mode: + // the Azure CLI provides the control-plane token and the owning app is + // provisioned on demand. + const { startServer } = await import("./index.js"); + await startServer({ clientId, tenantId, readOnly, tools }); + } catch (error) { + console.error("Failed to start SPE MCP server:"); + if (error instanceof Error) { + console.error(error.stack ?? error.message); + } else { + console.error(error); + } + process.exitCode = 1; + } + }); + +program + .command("auth") + .description("Authenticate with Microsoft Graph interactively (pre-cache tokens for headless use)") + .option("--client-id ", "Entra ID Application (Client) ID. Can also be set via SPE_CLIENT_ID env var.") + .option("--tenant-id ", "Entra ID Tenant ID. Can also be set via SPE_TENANT_ID env var.") + .option("--reset", "Clear any cached tokens for this tenant before authenticating (useful when switching tenants).") + .option("--data-dir ", DATA_DIR_OPTION) + .action(async (options: { clientId?: string; tenantId?: string; reset?: boolean; dataDir?: string }) => { + try { + // Resolve + record the data dir FIRST so auth caches tokens to the SAME + // directory `start` will later read from (else silent "not authenticated"). + await applyDataDir(options.dataDir); + const clientId = options.clientId || process.env.SPE_CLIENT_ID; + const tenantId = options.tenantId || process.env.SPE_TENANT_ID; + + if (!clientId || !tenantId) { + console.error("Error: --client-id and --tenant-id are required"); + process.exitCode = 1; + return; + } + + const { setAuthConfig, setInteractiveMode, authenticateInteractively, clearCachedToken } = + await import("./auth.js"); + setAuthConfig({ clientId, tenantId }); + setInteractiveMode(); + if (options.reset) { + await clearCachedToken(); + console.log("Cleared cached tokens before authenticating."); + } + await authenticateInteractively(); + console.log("Authenticated successfully. You can now start the MCP server."); + } catch (error) { + console.error("Authentication failed:"); + if (error instanceof Error) { + console.error(error.stack ?? error.message); + } else { + console.error(error); + } + process.exitCode = 1; + } + }); + +program + .command("logout") + .description("Clear cached authentication tokens") + .option("--data-dir ", DATA_DIR_OPTION) + .action(async (options: { dataDir?: string }) => { + try { + // Resolve + record the data dir FIRST so logout clears tokens from the + // SAME directory the matching `auth`/`start` used. + await applyDataDir(options.dataDir); + const { clearCachedToken } = await import("./auth.js"); + await clearCachedToken(); + console.log("Logged out. Cached tokens have been cleared."); + } catch (error) { + console.error("Failed to clear cached tokens:"); + if (error instanceof Error) { + console.error(error.stack ?? error.message); + } else { + console.error(error); + } + process.exitCode = 1; + } + }); + +program.parse(); diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..d0ca78e --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Shared SPE MCP constants — single source of truth for values that MUST agree + * across otherwise-independent modules. + * + * the local dev-server port lived in two places that had to match by + * hand — the SPA redirect URI registered on the owning app (graph-client.ts) and + * the generated app's Vite `server.port` (react-spa-template.ts). If one changed, + * browser sign-in silently broke (AADSTS9002326). Both now derive from the single + * `LOCAL_DEV_PORT` below so the registered redirect URI and the served dev port + * can never drift. + */ + +/** + * Port the scaffolded React SPA's Vite dev server listens on during local dev. + * Change this in ONE place to move the local dev origin everywhere that matters + * (the registered SPA redirect URI and the emitted Vite config). + */ +export const LOCAL_DEV_PORT = 5173; + +/** + * Local origin of the scaffolded React SPA's Vite dev server, derived from + * {@link LOCAL_DEV_PORT}. The generated app authenticates with MSAL.js using + * `redirectUri: window.location.origin` (auth-code + PKCE), which Entra only + * honours for a redirect URI registered under the app's `spa` platform — so this + * exact value is registered on the owning app at create/reuse time. + */ +export const LOCAL_SPA_REDIRECT_URI = `http://localhost:${LOCAL_DEV_PORT}`; diff --git a/src/container-retry.test.ts b/src/container-retry.test.ts new file mode 100644 index 0000000..687ada3 --- /dev/null +++ b/src/container-retry.test.ts @@ -0,0 +1,207 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for container-create retry classification. + * + * The strings here are the REAL wrapped forms produced by `graphRequest` + * (graph-client.ts): 403 → "Access denied: ...", 404 → "Resource not found: ...", + * 400/other → "Graph API error (): ...". The classifier must: + * • fail fast on a 404 typo'd/unknown containerTypeId, + * • STILL retry a genuine registration-propagation 403 (which the over-broad + * "everything 403/404 is permanent" fix had silently disabled), + * • honor an explicit propagation phrase even when wrapped in a 403/404. + */ + +import { describe, it, expect } from "vitest"; +import { + CONTAINER_CREATE_MAX_ATTEMPTS, + containerCreateBackoffMs, + isContainerPropagationError, + toClassifiableError, +} from "./container-retry.js"; +import { AppError } from "./errors.js"; + +describe("isContainerPropagationError — classification", () => { + // Permanent — must fail fast (return false). These are the real 404/400/403 forms. + it.each([ + // repro: a wrong/typo'd containerTypeId → 404 Resource not found. + "Resource not found: ItemNotFound - The container type does not exist", + "Resource not found: itemNotFound", + "Graph API error (404): ItemNotFound", + "Graph API error (400): invalidRequest — malformed containerTypeId", + "Graph API error (400): Bad Request", + // a bare 403 (wrong app / missing registration) now fails fast + // instead of burning the full ~150s propagation backoff. + "Access denied: AccessDenied", + "Access denied: The caller is not authorized to perform the operation", + ])("classifies a permanent 404/400/403 error as NON-retryable: %s", (msg) => { + expect(isContainerPropagationError(msg)).toBe(false); + }); + + // Transient — genuine registration propagation / infra blips (return true). + it.each([ + // 403 that DOES carry a propagation phrase still retries (phrase priority). + "Access denied: container type is not registered on this tenant yet", + // Explicit propagation/replication signals (any wrapping). + "Container type registration is still propagating", + "Grant is replicating across the content tier", + "Service is temporarily unavailable, please try again", + "Graph API error (503): request timed out", + // Genuinely-transient infrastructure signals (5xx / 429 / network). + "Graph API error (503): service unavailable", + "Graph API error (429): too many requests", + "Graph API error (500): server error", + "ECONNRESET: network error", + ])("classifies a propagation/transient signal as retryable: %s", (msg) => { + expect(isContainerPropagationError(msg)).toBe(true); + }); + + it("prioritizes a propagation phrase even when wrapped in a 404", () => { + // A 404 that explicitly says it is still propagating should retry, not + // fail fast — body-phrase priority over bare status. + expect( + isContainerPropagationError("Resource not found: type not yet registered, propagating"), + ).toBe(true); + }); + + it("treats an unknown/empty error as NON-retryable (fail fast)", () => { + expect(isContainerPropagationError("")).toBe(false); + expect(isContainerPropagationError("Some unexpected failure")).toBe(false); + }); + + it("exposes a bounded attempt cap and increasing backoff", () => { + expect(CONTAINER_CREATE_MAX_ATTEMPTS).toBe(5); + expect(containerCreateBackoffMs(1)).toBe(15_000); + expect(containerCreateBackoffMs(2)).toBe(30_000); + expect(containerCreateBackoffMs(4)).toBe(60_000); + }); +}); + +describe("isContainerPropagationError — HTTP-status-first classification (WI-08)", () => { + // Transient by STATUS: 429 throttling or any 5xx → retry, regardless of the + // message wording. + it.each([429, 500, 502, 503, 504])( + "classifies status %i as retryable (transient)", + (status) => { + const err = new AppError("UPSTREAM", "Graph API error", { status }); + expect(isContainerPropagationError(toClassifiableError(err))).toBe(true); + }, + ); + + // Permanent by STATUS: client errors fail fast — no phrase present. + it.each([400, 403, 404, 409])( + "classifies status %i as NON-retryable (permanent) when no propagation phrase", + (status) => { + const err = new AppError("FORBIDDEN", "Access denied: AccessDenied", { status }); + expect(isContainerPropagationError(toClassifiableError(err))).toBe(false); + }, + ); + + it("retries a 403 that CARRIES a propagation phrase (phrase overrides status)", () => { + // The real "propagation wrapped in a 403" case — must NOT regress. + const err = new AppError("FORBIDDEN", "Access denied: not registered yet", { status: 403 }); + expect(isContainerPropagationError(toClassifiableError(err))).toBe(true); + }); + + it("retries a 404 that CARRIES a propagation phrase (phrase overrides status)", () => { + const err = new AppError("NOT_FOUND", "Resource not found: still propagating", { status: 404 }); + expect(isContainerPropagationError(toClassifiableError(err))).toBe(true); + }); + + // Statusless (raw network/library error) → fall back to the transient-infra + // string allowlist. + it.each(["econnreset", "max retries exceeded", "socket hang up: etimedout"])( + "retries a statusless network error via the string allowlist: %s", + (message) => { + const err = new Error(message); + expect(isContainerPropagationError(toClassifiableError(err))).toBe(true); + }, + ); + + it("fails fast on a statusless, non-infra error", () => { + const err = new Error("Access denied: AccessDenied"); + expect(isContainerPropagationError(toClassifiableError(err))).toBe(false); + }); + + it("toClassifiableError extracts status from AppError and message-only from Error", () => { + expect(toClassifiableError(new AppError("X", "boom", { status: 503 }))).toEqual({ + status: 503, + message: "boom", + }); + expect(toClassifiableError(new Error("plain"))).toEqual({ message: "plain" }); + expect(toClassifiableError("weird")).toEqual({ message: "weird" }); + }); +}); + +/** + * Drive the SAME retry loop the tools use, counting attempts, to prove: + * • the 404 typo path makes exactly ONE attempt (fast-fail), and + * • a genuine propagation 403 retries and then succeeds. + * We replicate the loop here (no timers) to assert call-counts deterministically. + */ +async function runRetryLoop( + create: () => Promise, +): Promise<{ attempts: number; ok: boolean; lastError: string }> { + let attempts = 0; + let lastError = ""; + for (let attempt = 1; attempt <= CONTAINER_CREATE_MAX_ATTEMPTS; attempt++) { + attempts = attempt; + try { + await create(); + return { attempts, ok: true, lastError: "" }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + if (attempt < CONTAINER_CREATE_MAX_ATTEMPTS && isContainerPropagationError(lastError)) { + continue; // backoff omitted in test + } + return { attempts, ok: false, lastError }; + } + } + return { attempts, ok: false, lastError }; +} + +describe("container-create retry loop — call-count behavior", () => { + it("a 404 typo'd containerTypeId fails fast in ONE attempt", async () => { + let calls = 0; + const result = await runRetryLoop(async () => { + calls += 1; + throw new Error("Resource not found: ItemNotFound - container type does not exist"); + }); + expect(calls).toBe(1); + expect(result.attempts).toBe(1); + expect(result.ok).toBe(false); + }); + + it("a bare 403 (access denied) fails fast in ONE attempt", async () => { + let calls = 0; + const result = await runRetryLoop(async () => { + calls += 1; + throw new Error("Access denied: AccessDenied"); + }); + expect(calls).toBe(1); + expect(result.attempts).toBe(1); + expect(result.ok).toBe(false); + }); + + it("a genuine propagation 403 (phrase-bearing) retries, then succeeds once the grant lands", async () => { + let calls = 0; + const result = await runRetryLoop(async () => { + calls += 1; + if (calls < 3) throw new Error("Access denied: not registered yet"); + return "container-id"; + }); + expect(calls).toBe(3); // failed twice (phrase-bearing 403), succeeded on the 3rd + expect(result.ok).toBe(true); + }); + + it("a persistent propagation 403 retries up to the bounded cap, then fails", async () => { + let calls = 0; + const result = await runRetryLoop(async () => { + calls += 1; + throw new Error("Access denied: not registered yet"); + }); + expect(calls).toBe(CONTAINER_CREATE_MAX_ATTEMPTS); // bounded, not unbounded + expect(result.ok).toBe(false); + }); +}); diff --git a/src/container-retry.ts b/src/container-retry.ts new file mode 100644 index 0000000..fb6c547 --- /dev/null +++ b/src/container-retry.ts @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Shared classification for SharePoint Embedded container-creation retries. + * + * Right after a container type is registered on a tenant, the grant takes + * ~10–30s to propagate to the content tier, so the first `createContainer` + * calls can fail transiently. Those — and only those — should be retried. + * + * ── The empirical shapes we must separate ────────────────────────────────── + * `graphRequest` (graph-client.ts) wraps Graph failures into prefixed strings: + * 403 → "Access denied: " + * 404 → "Resource not found: " (e.g. "...: ItemNotFound ...") + * 400 → "Graph API error (400): " + * other → "Graph API error (): " + * + * Two real-world cases look superficially alike but need OPPOSITE handling: + * • a WRONG/typo'd containerTypeId. The content tier reports the + * type does not exist → 404 "Resource not found". Retrying can NEVER fix a + * typo, yet the original heuristic (which matched 404/"not found" as + * propagation) burned ~150s of backoff (15+30+45+60s) before surfacing the + * same error. This MUST fail fast. + * • Genuine registration-propagation — a correctly-registered grant that has + * not yet replicated to the content tier. Empirically this surfaces as a + * 403 "Access denied" (the content tier does not yet see the grant). This + * MUST retry through the propagation window. + * + * The original `isPropagationError` treated 403 AND 404 (and "access denied" / + * "not found") all as transient — the root cause of the ~150s hang + * on a 404 typo. An intermediate fix failed fast on 404 but still retried a bare + * 403, so a genuinely-unauthorized caller (wrong app / missing registration) + * still burned the full ~150s backoff window before surfacing the 403. + * + * ── Classification (HTTP-STATUS-FIRST, phrase override, string fallback) ───── + * The thrown `AppError` already carries the numeric HTTP `status` + * (`graphErrorForStatus` in graph-client.ts). We classify on that status rather + * than sniffing substrings of the (localizable, format-drifting) message: + * 1. An explicit propagation/replication PHRASE ("not registered", "not yet", + * "propagat", "replicat", "try again", "temporarily", "timeout") ALWAYS + * means transient — regardless of the 403/404 it is wrapped in. This keeps + * a genuine, correctly-registered grant retrying through replication (the + * real "propagation wrapped in a 403" case). + * 2. By STATUS: `429` or any `5xx` → transient (retry); `400`/`403`/`404`/ + * `409` → PERMANENT (fail fast). A wrong containerTypeId or an unauthorized + * caller can never be fixed by waiting, so we surface it on the FIRST + * attempt instead of burning ~150s of backoff. Any other explicit status + * with no phrase → fail fast. + * 3. NO status (a raw network/library error such as ECONNRESET, or a bare + * thrown Error) → fall back to the explicit transient-infrastructure + * allowlist on the message string. + * + * Tradeoff (documented intentionally): a 403 emitted DURING genuine propagation + * must carry one of the rule-1 phrases to be retried. Empirically the content + * tier surfaces propagation as a phrase-bearing or 5xx response, so this is the + * safe default; if a future bare-403-means-propagating signature appears, add it + * to rule 1. + * + * Used by both `container_create` and `project_provision` (step 5) so the two + * code paths classify retries identically. + */ + +import { AppError } from "./errors.js"; + +/** Maximum container-create attempts (1 initial + up to 4 propagation retries). */ +export const CONTAINER_CREATE_MAX_ATTEMPTS = 5; + +/** Backoff before the next container-create attempt: 15s, 30s, 45s, 60s. */ +export function containerCreateBackoffMs(attempt: number): number { + return attempt * 15_000; +} + +/** + * Explicit propagation/replication phrases. These take priority over any HTTP + * status: the content tier may wrap a "still propagating" condition behind a + * 403/404, but the phrase is the authoritative transient signal. + */ +function hasPropagationPhrase(m: string): boolean { + return ( + m.includes("not registered") || + m.includes("notregistered") || + m.includes("not yet") || // "not yet registered" / "not yet available" + m.includes("propagat") || // propagating / propagation + m.includes("replicat") || // replicating / replication + m.includes("try again") || + m.includes("temporarily") || + m.includes("timeout") || + m.includes("timed out") + ); +} + +/** + * Definitively-permanent shapes that retrying cannot fix: a bare authorization + * 403, a + * 404 "Resource not found" (typo'd/unknown containerTypeId), or a 400 malformed + * request. These fail fast — they are reached only AFTER rule 1 (phrase) has had + * the chance to reclassify a genuinely-propagating case as transient. + */ +function isDefinitelyPermanent(m: string): boolean { + return ( + m.includes("403") || + m.includes("access denied") || + m.includes("accessdenied") || + m.includes("unauthorized") || + m.includes("404") || + m.includes("not found") || + m.includes("notfound") || + m.includes("itemnotfound") || + m.includes("400") || + m.includes("invalidrequest") || + m.includes("bad request") + ); +} + +/** + * Genuinely-transient infrastructure signals — server errors, throttling, + * timeouts and network blips — that are safe to retry through the propagation + * window. This is the ONLY non-phrase path that retries (a bare 403 no longer + * qualifies; see the file-level note for the fix). + */ +function isTransientInfraError(m: string): boolean { + return ( + m.includes("500") || + m.includes("502") || + m.includes("503") || + m.includes("504") || + m.includes("429") || + m.includes("server error") || + m.includes("service unavailable") || + m.includes("network error") || + m.includes("econnreset") || + m.includes("etimedout") || + m.includes("max retries exceeded") + ); +} + +/** + * The minimal shape the classifier needs: the numeric HTTP `status` carried by + * an {@link AppError} (set by `graphErrorForStatus`) plus the raw `message`. A + * raw network/library error has no status; a Graph failure always does. + */ +export interface ClassifiableError { + status?: number; + message: string; +} + +/** Extract the classifiable `{status, message}` shape from an unknown throw. */ +export function toClassifiableError(error: unknown): ClassifiableError { + if (error instanceof AppError) return { status: error.status, message: error.message }; + if (error instanceof Error) return { message: error.message }; + return { message: String(error) }; +} + +/** + * Legacy string-only classification (no HTTP status available): phrase → + * transient-infra allowlist → otherwise permanent. Retained for the statusless + * path and for back-compat string callers. + */ +function classifyByMessage(m: string): boolean { + if (hasPropagationPhrase(m)) return true; // propagation/replication phrase + if (isTransientInfraError(m)) return true; // 5xx / 429 / network blip + if (isDefinitelyPermanent(m)) return false; // bare 403 / 404 / 400 + return false; // unknown → fail fast +} + +/** + * True for conditions that should be retried through the registration- + * propagation window. Classification is HTTP-STATUS-FIRST (see the file-level + * comment for the full rationale and the deliberate phrase-over-status override): + * 1. An explicit propagation/replication PHRASE always wins — a genuine, + * correctly-registered grant that is still replicating can surface wrapped + * in a 403/404 and MUST keep retrying. + * 2. By status: 429 or any 5xx → transient (retry); 400/403/404/409 → + * permanent (fail fast). Any other explicit status with no phrase → fail + * fast. + * 3. No status (network/library error) → fall back to the string allowlist of + * transient infrastructure signals. + * + * Accepts either the error object (preferred — carries `status`) or a bare + * message string (back-compat with older string callers/tests). + */ +export function isContainerPropagationError(error: ClassifiableError | string): boolean { + if (typeof error === "string") return classifyByMessage(error.toLowerCase()); + + const m = (error.message ?? "").toLowerCase(); + // 1. Propagation phrase overrides any wrapped status (403/404 propagation). + if (hasPropagationPhrase(m)) return true; + + if (typeof error.status === "number") { + const status = error.status; + // 2a. Throttling / server errors are transient → retry. + if (status === 429 || status >= 500) return true; + // 2b. Client errors (bad request / unauthorized / not-found / conflict) are + // permanent → fail fast; the phrase override above already rescued a + // genuine propagation case wrapped in a 403/404. + if (status === 400 || status === 403 || status === 404 || status === 409) return false; + // Any other explicit status with no phrase → fail fast. + return false; + } + + // 3. Statusless (raw network error) → fall back to the transient-infra allowlist. + return isTransientInfraError(m); +} diff --git a/src/docs-client.ts b/src/docs-client.ts new file mode 100644 index 0000000..dc5e4cb --- /dev/null +++ b/src/docs-client.ts @@ -0,0 +1,240 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Microsoft Learn MCP client wrapper. + * + * The SPE MCP server proxies documentation queries to the official + * **Microsoft Learn MCP server** (https://learn.microsoft.com/api/mcp) rather + * than reimplementing doc search. This keeps answers grounded in current, + * first-party SharePoint Embedded / Microsoft Graph documentation. + * + * Design notes: + * - Transport is Streamable HTTP, anonymous (no auth) — per Learn MCP docs. + * - The Learn MCP docs explicitly warn that tool input/output schemas may + * change over time, so we DISCOVER the tool list at connect time and build + * arguments from each tool's advertised inputSchema instead of hardcoding + * parameter names. + * - Endpoint is overridable via SPE_LEARN_MCP_URL (used by tests to + * point at a local mock). + */ + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +const DEFAULT_LEARN_MCP_URL = "https://learn.microsoft.com/api/mcp"; +const ALLOWED_DOCS_HOST = "learn.microsoft.com"; +const SEARCH_TOOL = "microsoft_docs_search"; +const FETCH_TOOL = "microsoft_docs_fetch"; +const CALL_TIMEOUT_MS = 25_000; + +function isTruthyEnv(value: string | undefined): boolean { + if (!value) return false; + return /^(1|true|yes|on)$/i.test(value.trim()); +} + +/** + * Resolve and validate the Learn MCP endpoint (SEC-007). + * + * The docs proxy defaults to the first-party Microsoft Learn MCP host. An + * `SPE_LEARN_MCP_URL` override that points at any other host is refused unless + * the operator explicitly opts in via `SPE_ALLOW_INSECURE_DOCS_ENDPOINT`, so a + * stray/hostile env var cannot silently redirect documentation traffic + * off-Microsoft. + */ +export function resolveDocsEndpoint( + override: string | undefined = process.env.SPE_LEARN_MCP_URL, + allowInsecure: boolean = isTruthyEnv(process.env.SPE_ALLOW_INSECURE_DOCS_ENDPOINT), +): string { + const trimmed = override?.trim(); + if (!trimmed) return DEFAULT_LEARN_MCP_URL; + + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + throw new Error( + `SPE_LEARN_MCP_URL is not a valid URL: ${trimmed}. ` + + `Provide a full URL such as ${DEFAULT_LEARN_MCP_URL}.`, + ); + } + const host = parsed.hostname.toLowerCase(); + + const isAllowedHost = host === ALLOWED_DOCS_HOST || host.endsWith(`.${ALLOWED_DOCS_HOST}`); + if (isAllowedHost) { + // Allowed Microsoft Learn host must still be reached over https unless the + // operator explicitly opts into an insecure endpoint (e.g. a local mock). + if (parsed.protocol !== "https:" && !allowInsecure) { + throw new Error( + `Refusing to use SPE_LEARN_MCP_URL over ${parsed.protocol} — https is required for ${ALLOWED_DOCS_HOST}. ` + + `Set SPE_ALLOW_INSECURE_DOCS_ENDPOINT=1 to override (use with caution).`, + ); + } + return trimmed; + } + if (allowInsecure) return trimmed; + + throw new Error( + `Refusing to use SPE_LEARN_MCP_URL host "${host}": only ${ALLOWED_DOCS_HOST} is allowed by default. ` + + `Set SPE_ALLOW_INSECURE_DOCS_ENDPOINT=1 to override (use with caution — this redirects documentation queries off Microsoft Learn).`, + ); +} + +function log(message: string, data?: unknown): void { + const timestamp = new Date().toISOString(); + if (data !== undefined) { + console.error(`[${timestamp}] [LearnMCP] ${message}`, typeof data === "string" ? data : JSON.stringify(data)); + } else { + console.error(`[${timestamp}] [LearnMCP] ${message}`); + } +} + +interface JsonSchemaLike { + properties?: Record; + required?: string[]; +} + +interface DiscoveredTool { + name: string; + inputSchema?: JsonSchemaLike; +} + +let client: Client | null = null; +let toolsByName: Map | null = null; +let connectPromise: Promise | null = null; + +function getEndpoint(): string { + return resolveDocsEndpoint(); +} + +async function connect(): Promise { + if (client && toolsByName) return; + if (connectPromise) return connectPromise; + + connectPromise = (async () => { + const url = getEndpoint(); + log(`Connecting to Microsoft Learn MCP at ${url}`); + const transport = new StreamableHTTPClientTransport(new URL(url)); + const c = new Client({ name: "spe-mcp-server", version: "0.1.0" }); + await c.connect(transport); + + const list = await c.listTools(); + const map = new Map(); + for (const t of list.tools) { + map.set(t.name, { name: t.name, inputSchema: t.inputSchema as JsonSchemaLike }); + } + client = c; + toolsByName = map; + log(`Connected. Discovered ${map.size} Learn tools: ${[...map.keys()].join(", ")}`); + })(); + + try { + await connectPromise; + } catch (error) { + connectPromise = null; + client = null; + toolsByName = null; + const msg = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to connect to Microsoft Learn MCP (${getEndpoint()}): ${msg}`); + } finally { + connectPromise = null; + } +} + +/** + * Resolve a Learn tool by preferred name, with fallbacks for schema drift. + */ +function resolveTool(preferredName: string, fallbackSubstring: string): DiscoveredTool { + if (!toolsByName) { + throw new Error("Learn MCP tool list not initialized"); + } + const exact = toolsByName.get(preferredName); + if (exact) return exact; + // Fallback: the Learn team may rename tools — match by substring. + for (const tool of toolsByName.values()) { + if (tool.name.toLowerCase().includes(fallbackSubstring)) return tool; + } + throw new Error( + `Learn MCP does not expose a '${preferredName}' tool. Available: ${[...toolsByName.keys()].join(", ")}`, + ); +} + +/** + * Build a tool-call arguments object from the tool's advertised inputSchema, + * placing `value` into the most likely parameter. Resilient to param renames. + */ +function buildArgs(tool: DiscoveredTool, value: string, preferredKeys: string[]): Record { + const schema = tool.inputSchema ?? {}; + const props = schema.properties ?? {}; + const required = schema.required ?? []; + + const firstRequiredString = required.find((k) => props[k]?.type === "string"); + const firstPreferredPresent = preferredKeys.find((k) => k in props); + const key = firstRequiredString ?? firstPreferredPresent ?? required[0] ?? preferredKeys[0]; + + return { [key]: value }; +} + +/** + * Extract a plain-text payload from an MCP CallToolResult. + */ +function extractText(result: unknown): string { + const content = (result as { content?: Array<{ type?: string; text?: string }> })?.content; + if (!Array.isArray(content)) return ""; + return content + .filter((c) => c?.type === "text" && typeof c.text === "string") + .map((c) => c.text as string) + .join("\n\n") + .trim(); +} + +/** + * Search Microsoft Learn documentation. Returns the raw text payload from the + * Learn MCP search tool (typically ranked excerpts with titles and URLs). + */ +export async function searchDocs(query: string): Promise { + await connect(); + const tool = resolveTool(SEARCH_TOOL, "search"); + const args = buildArgs(tool, query, ["question", "query", "search", "q"]); + log(`Calling ${tool.name}`, args); + const result = await client!.callTool({ name: tool.name, arguments: args }, undefined, { + timeout: CALL_TIMEOUT_MS, + }); + const text = extractText(result); + if (!text) { + throw new Error("Microsoft Learn search returned no text content"); + } + return text; +} + +/** + * Fetch the full markdown content of a Microsoft Learn documentation page. + */ +export async function fetchDoc(url: string): Promise { + await connect(); + const tool = resolveTool(FETCH_TOOL, "fetch"); + const args = buildArgs(tool, url, ["url", "uri", "link"]); + log(`Calling ${tool.name}`, args); + const result = await client!.callTool({ name: tool.name, arguments: args }, undefined, { + timeout: CALL_TIMEOUT_MS, + }); + const text = extractText(result); + if (!text) { + throw new Error(`Microsoft Learn fetch returned no content for ${url}`); + } + return text; +} + +/** Close the Learn MCP connection (used in tests / shutdown). */ +export async function closeDocsClient(): Promise { + if (client) { + try { + await client.close(); + } catch { + /* ignore */ + } + } + client = null; + toolsByName = null; + connectPromise = null; +} diff --git a/src/docs-endpoint.test.ts b/src/docs-endpoint.test.ts new file mode 100644 index 0000000..ad27079 --- /dev/null +++ b/src/docs-endpoint.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, it, expect } from "vitest"; +import { resolveDocsEndpoint } from "./docs-client.js"; + +describe("resolveDocsEndpoint (SEC-007)", () => { + it("defaults to the first-party Learn MCP endpoint when no override is set", () => { + expect(resolveDocsEndpoint(undefined, false)).toBe("https://learn.microsoft.com/api/mcp"); + expect(resolveDocsEndpoint("", false)).toBe("https://learn.microsoft.com/api/mcp"); + }); + + it("allows an override that stays on learn.microsoft.com (or a subdomain)", () => { + expect(resolveDocsEndpoint("https://learn.microsoft.com/api/mcp", false)).toBe( + "https://learn.microsoft.com/api/mcp", + ); + expect(resolveDocsEndpoint("https://test.learn.microsoft.com/api/mcp", false)).toBe( + "https://test.learn.microsoft.com/api/mcp", + ); + }); + + it("refuses an off-domain override unless explicitly allowed", () => { + expect(() => resolveDocsEndpoint("https://evil.example.com/api/mcp", false)).toThrow( + /only learn\.microsoft\.com is allowed/i, + ); + }); + + it("permits an off-domain override when SPE_ALLOW_INSECURE_DOCS_ENDPOINT is set", () => { + expect(resolveDocsEndpoint("http://127.0.0.1:8080/mcp", true)).toBe("http://127.0.0.1:8080/mcp"); + }); + + it("requires https for the allowed Learn host unless insecure is allowed", () => { + expect(() => resolveDocsEndpoint("http://learn.microsoft.com/api/mcp", false)).toThrow( + /https is required/i, + ); + // The insecure escape hatch still permits http on the allowed host (e.g. a local proxy). + expect(resolveDocsEndpoint("http://learn.microsoft.com/api/mcp", true)).toBe( + "http://learn.microsoft.com/api/mcp", + ); + }); + + it("rejects a malformed override URL", () => { + expect(() => resolveDocsEndpoint("not-a-url", false)).toThrow(/not a valid URL/i); + }); + + it("does not allow a look-alike host that merely contains the allowed host", () => { + expect(() => resolveDocsEndpoint("https://learn.microsoft.com.evil.io/api/mcp", false)).toThrow( + /only learn\.microsoft\.com is allowed/i, + ); + }); +}); diff --git a/src/elicitation.test.ts b/src/elicitation.test.ts new file mode 100644 index 0000000..a699f91 --- /dev/null +++ b/src/elicitation.test.ts @@ -0,0 +1,264 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for the native-vs-fallback elicitation helpers (elicitation.ts) — + * PR #3 review. Verifies that: + * - with NO wired server (or a client without the elicitation capability) the + * helpers fall back to the agent-guided `needChoice` text — identical to the + * pre-native behavior — and never call `elicitInput`; + * - with a wired, capability-advertising client, `elicitChoice` issues a native + * `elicitInput` form request and resolves in-band on accept; + * - decline/cancel/invalid/throw all degrade safely. + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; +import { + elicitChoice, + elicitText, + wireElicitation, + resetElicitationForTests, + type Choice, + type ElicitInputResult, + type ElicitationCapableServer, +} from "./elicitation.js"; + +const OPTIONS: Choice[] = [ + { label: "Reuse", value: "reuse", description: "the remembered app" }, + { label: "Use a different app", value: "new" }, +]; + +type ElicitInputParams = { mode: "form"; message: string; requestedSchema: Record }; + +/** Build a lightweight fake MCP server with a spied `elicitInput`. */ +function fakeServer(opts: { + capabilities: { elicitation?: unknown } | undefined; + elicitInput?: (params: ElicitInputParams) => Promise; +}): ElicitationCapableServer & { elicitInput: ReturnType } { + const impl = opts.elicitInput ?? (async () => ({ action: "accept", content: {} }) as ElicitInputResult); + const elicitInput = vi.fn(impl); + return { + elicitInput, + getClientCapabilities: () => opts.capabilities, + } as ElicitationCapableServer & { elicitInput: ReturnType }; +} + +/** Read the `oneOf` const values off a captured requestedSchema. */ +function oneOfConsts(params: ElicitInputParams, paramName: string): string[] { + const props = (params.requestedSchema as { properties: Record }).properties; + const prop = props[paramName] as { oneOf?: Array<{ const: string }> }; + return (prop.oneOf ?? []).map((e) => e.const); +} + +afterEach(() => { + resetElicitationForTests(); + vi.restoreAllMocks(); +}); + +describe("elicitChoice — fallback (no native elicitation)", () => { + it("with NO wired server, resolves false with the agent-guided needChoice text", async () => { + const r = await elicitChoice("Reuse or use a different app?", OPTIONS, "appSelection"); + + expect(r.resolved).toBe(false); + if (!r.resolved) { + expect(r.result.isError).toBe(false); + const text = r.result.content[0].text; + // needChoice encodes the paramName and every value as `paramName=value`. + expect(text).toContain("appSelection"); + expect(text).toContain("appSelection=reuse"); + expect(text).toContain("appSelection=new"); + } + }); + + it("when the client does NOT advertise elicitation, falls back WITHOUT calling elicitInput", async () => { + const server = fakeServer({ + capabilities: {}, // no `elicitation` key + elicitInput: async () => ({ action: "accept", content: { appSelection: "reuse" } }), + }); + wireElicitation(server); + + const r = await elicitChoice("q", OPTIONS, "appSelection"); + + expect(server.elicitInput).not.toHaveBeenCalled(); + expect(r.resolved).toBe(false); + if (!r.resolved) expect(r.result.content[0].text).toContain("appSelection=reuse"); + }); + + it("when the client advertises ONLY url-mode elicitation (no form), falls back WITHOUT calling elicitInput", async () => { + const server = fakeServer({ + capabilities: { elicitation: { url: {} } }, // url-only: form not supported + elicitInput: async () => ({ action: "accept", content: { appSelection: "reuse" } }), + }); + wireElicitation(server); + + const r = await elicitChoice("q", OPTIONS, "appSelection"); + + expect(server.elicitInput).not.toHaveBeenCalled(); + expect(r.resolved).toBe(false); + if (!r.resolved) expect(r.result.content[0].text).toContain("appSelection=reuse"); + }); + + it("when getClientCapabilities() returns undefined, falls back WITHOUT calling elicitInput", async () => { + const server = fakeServer({ + capabilities: undefined, + elicitInput: async () => ({ action: "accept", content: { appSelection: "reuse" } }), + }); + wireElicitation(server); + + const r = await elicitChoice("q", OPTIONS, "appSelection"); + + expect(server.elicitInput).not.toHaveBeenCalled(); + expect(r.resolved).toBe(false); + }); +}); + +describe("elicitChoice — native elicitation", () => { + it("on accept with a valid value, resolves in-band and calls elicitInput with a oneOf schema", async () => { + const server = fakeServer({ + capabilities: { elicitation: {} }, + elicitInput: async () => ({ action: "accept", content: { appSelection: "new" } }), + }); + wireElicitation(server); + + const r = await elicitChoice("Reuse or use a different app?", OPTIONS, "appSelection"); + + expect(r).toEqual({ resolved: true, value: "new" }); + expect(server.elicitInput).toHaveBeenCalledTimes(1); + const params = server.elicitInput.mock.calls[0][0] as ElicitInputParams; + expect(params.mode).toBe("form"); + expect(params.message).toBe("Reuse or use a different app?"); + expect(oneOfConsts(params, "appSelection")).toEqual(["reuse", "new"]); + }); + + it("on accept with an UNKNOWN value, returns a friendly no-op (isError:false)", async () => { + const server = fakeServer({ + capabilities: { elicitation: {} }, + elicitInput: async () => ({ action: "accept", content: { appSelection: "bogus" } }), + }); + wireElicitation(server); + + const r = await elicitChoice("q", OPTIONS, "appSelection"); + + expect(r.resolved).toBe(false); + if (!r.resolved) { + expect(r.result.isError).toBe(false); + expect(r.result.content[0].text).toContain("No selection made"); + } + }); + + it("on decline, returns a friendly no-op (isError:false), NOT the needChoice text", async () => { + const server = fakeServer({ + capabilities: { elicitation: {} }, + elicitInput: async () => ({ action: "decline" }), + }); + wireElicitation(server); + + const r = await elicitChoice("q", OPTIONS, "appSelection"); + + expect(r.resolved).toBe(false); + if (!r.resolved) { + expect(r.result.isError).toBe(false); + expect(r.result.content[0].text).toContain("No selection made"); + } + }); + + it("on cancel, returns a friendly no-op (isError:false)", async () => { + const server = fakeServer({ + capabilities: { elicitation: {} }, + elicitInput: async () => ({ action: "cancel" }), + }); + wireElicitation(server); + + const r = await elicitChoice("q", OPTIONS, "appSelection"); + + expect(r.resolved).toBe(false); + if (!r.resolved) expect(r.result.content[0].text).toContain("No selection made"); + }); + + it("when elicitInput THROWS, falls back to the agent-guided needChoice text", async () => { + const server = fakeServer({ + capabilities: { elicitation: {} }, + elicitInput: async () => { + throw new Error("Client does not support form elicitation."); + }, + }); + wireElicitation(server); + + const r = await elicitChoice("q", OPTIONS, "appSelection"); + + expect(r.resolved).toBe(false); + if (!r.resolved) { + // fallback needChoice text — encodes `paramName=value` + expect(r.result.content[0].text).toContain("appSelection=reuse"); + expect(r.result.content[0].text).toContain("appSelection=new"); + } + }); +}); + +describe("elicitText", () => { + it("on native accept with a non-empty string, resolves true (trimmed) and passes the title", async () => { + const server = fakeServer({ + capabilities: { elicitation: {} }, + elicitInput: async () => ({ action: "accept", content: { displayName: " My App " } }), + }); + wireElicitation(server); + + const r = await elicitText("Name for the new owning app?", "displayName", { title: "New app name" }); + + expect(r).toEqual({ resolved: true, value: "My App" }); + const params = server.elicitInput.mock.calls[0][0] as ElicitInputParams; + const props = (params.requestedSchema as { properties: Record }).properties; + expect(props.displayName.title).toBe("New app name"); + }); + + it("on decline, resolves false with a null result (caller keeps its default)", async () => { + const server = fakeServer({ + capabilities: { elicitation: {} }, + elicitInput: async () => ({ action: "decline" }), + }); + wireElicitation(server); + + const r = await elicitText("Name?", "displayName"); + + expect(r).toEqual({ resolved: false, result: null }); + }); + + it("on native accept with an EMPTY string, resolves false with a null result", async () => { + const server = fakeServer({ + capabilities: { elicitation: {} }, + elicitInput: async () => ({ action: "accept", content: { displayName: " " } }), + }); + wireElicitation(server); + + const r = await elicitText("Name?", "displayName"); + + expect(r).toEqual({ resolved: false, result: null }); + }); + + it("with NO native capability, resolves false/null WITHOUT calling elicitInput (silent fallback)", async () => { + const server = fakeServer({ + capabilities: {}, + elicitInput: async () => ({ action: "accept", content: { displayName: "X" } }), + }); + wireElicitation(server); + + const r = await elicitText("Name?", "displayName"); + + expect(server.elicitInput).not.toHaveBeenCalled(); + expect(r).toEqual({ resolved: false, result: null }); + }); + + it("when elicitInput THROWS, resolves false with a null result (silent fallback)", async () => { + const server = fakeServer({ + capabilities: { elicitation: {} }, + elicitInput: async () => { + throw new Error("nope"); + }, + }); + wireElicitation(server); + + const r = await elicitText("Name?", "displayName"); + + expect(r).toEqual({ resolved: false, result: null }); + }); +}); diff --git a/src/elicitation.ts b/src/elicitation.ts new file mode 100644 index 0000000..ef2e37c --- /dev/null +++ b/src/elicitation.ts @@ -0,0 +1,241 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Elicitation helper — prefers NATIVE MCP elicitation, falls back to agent-guided. + * + * The Model Context Protocol defines an **elicitation** capability + * (`elicitation/create`) that lets the SERVER ask the CLIENT to prompt the USER + * for structured input. When the connected client advertises it, the SPE Builder + * uses it (via `server.elicitInput`) so the ask reaches a HUMAN. + * + * Why this matters (PR #3 review): the reuse-vs-new-app and trial-vs-standard + * asks were previously ONLY agent-guided — a text tool result asking the + * orchestrating model to re-invoke with the chosen arg. In practice the host + * model auto-answered without surfacing the question, so the ask never reached + * the user. Native elicitation puts the decision in front of the person. + * + * Not every host supports elicitation, so this module degrades gracefully: when + * the client does NOT advertise the capability — or the native request throws or + * is declined — it falls back to **agent-guided elicitation** via `needChoice`, + * which returns a structured "choose one" tool result the agent re-invokes with + * the chosen arg. On hosts without elicitation, behavior is identical to before. + * + * Elicitation is used only for non-sensitive choices (billing model, reuse/new, + * confirm/switch) and a new-app display name — never secrets or tokens, per the + * spec's rule that servers MUST NOT request sensitive information this way. + */ + +import type { McpToolResult } from "./types.js"; + +export interface Choice { + label: string; + value: string; + description?: string; +} + +/** + * Build a tool result that asks the user to choose among options. The agent + * relays this to the user and re-invokes the tool with the chosen value. This is + * the fallback used whenever native elicitation is unavailable. + */ +export function needChoice(question: string, options: Choice[], paramName: string): McpToolResult { + let text = `### ${question}\n\n`; + for (const o of options) { + text += `- **${o.label}** — \`${paramName}=${o.value}\`${o.description ? ` · ${o.description}` : ""}\n`; + } + text += `\n> Choose one and re-run with \`${paramName}\` set to the selected value.`; + return { content: [{ type: "text", text }], isError: false }; +} + +// ─── Native MCP elicitation ─────────────────────────────────────────────────── + +/** + * The subset of the MCP SDK's `ElicitResult` this module consumes. `action` + * mirrors the spec's accept/decline/cancel; `content` (present on accept) is the + * user's answer keyed by the requested-schema property name. + */ +export type ElicitInputResult = { + action: "accept" | "decline" | "cancel"; + content?: Record; +}; + +/** + * The minimal surface of the MCP `Server` the elicitation helpers need. Declared + * locally (rather than importing the SDK `Server`) so tool handlers — which never + * receive the server instance — can reach `elicitInput` through this module, and + * so tests can wire a lightweight fake. The real `Server` satisfies this shape. + */ +export interface ElicitationCapableServer { + elicitInput(params: { + mode: "form"; + message: string; + requestedSchema: Record; + }): Promise; + getClientCapabilities(): { elicitation?: unknown } | undefined; +} + +let wired: ElicitationCapableServer | null = null; + +/** + * Wire the live MCP server so the elicitation helpers can issue native + * `elicitation/create` requests. Called once at startup (index.ts). Until it is + * called — e.g. in unit tests — every helper uses the agent-guided fallback. + */ +export function wireElicitation(server: ElicitationCapableServer): void { + wired = server; +} + +/** Test hook: clear the wired server between cases. */ +export function resetElicitationForTests(): void { + wired = null; +} + +/** + * True only when a client that advertises FORM elicitation is wired. The SDK's + * form mode requires `elicitation.form`; a client advertising only URL-mode + * elicitation would make the form request throw, so gate on `.form` here to + * avoid a wasted native attempt (the throw is still caught by callers as a + * belt-and-suspenders fallback). Capabilities are read lazily (always + * post-`initialize`), so this reflects the live client. + */ +function nativeElicitationAvailable(): boolean { + const elicitation = wired?.getClientCapabilities()?.elicitation as + | { form?: unknown } + | undefined; + // Per the MCP spec an empty `elicitation: {}` is equivalent to form support, + // so accept either an explicit `.form` or an (empty) capability object. + return !!wired && !!elicitation && (elicitation.form !== undefined || Object.keys(elicitation).length === 0); +} + +/** + * Build the restricted form-mode `requestedSchema` for a single-select choice. + * Form elicitation permits only a FLAT object of primitives; an enum with + * human-readable labels is expressed with `oneOf` const/title entries. + */ +function choiceSchema(question: string, options: Choice[], paramName: string): Record { + return { + type: "object", + properties: { + [paramName]: { + type: "string", + title: paramName, + description: question, + oneOf: options.map((o) => ({ + const: o.value, + title: o.description ? `${o.label} — ${o.description}` : o.label, + })), + }, + }, + required: [paramName], + }; +} + +/** Friendly no-op result when the user declines/cancels (or accepts an invalid value). */ +function declinedChoiceResult(options: Choice[]): McpToolResult { + const values = options.map((o) => o.value).join(", "); + return { + content: [ + { + type: "text", + text: `No selection made — no changes were applied. Re-run and choose one of: ${values}.`, + }, + ], + isError: false, + }; +} + +export type ChoiceResolution = + | { resolved: true; value: string } + | { resolved: false; result: McpToolResult }; + +/** + * Ask the user to pick one of `options`, preferring native MCP elicitation. + * + * - Native (client advertises elicitation): issue an `elicitation/create` form + * request. On `accept` with a value matching one of the options → resolve + * in-band (`{ resolved: true }`) so the caller CONTINUES without a re-invoke. + * On `decline`/`cancel` (or accept-but-invalid) → a friendly no-op result. On + * ANY thrown error (e.g. client lacks form support) → fall through to the + * agent-guided fallback. + * - Fallback (not wired / no capability / threw): return `needChoice(...)` — the + * agent-guided ask the orchestrator re-invokes with the chosen arg. Identical + * to the pre-native behavior. + */ +export async function elicitChoice( + question: string, + options: Choice[], + paramName: string, +): Promise { + if (nativeElicitationAvailable() && wired) { + try { + const res = await wired.elicitInput({ + mode: "form", + message: question, + requestedSchema: choiceSchema(question, options, paramName), + }); + if (res.action === "accept") { + const picked = res.content?.[paramName]; + if (typeof picked === "string" && options.some((o) => o.value === picked)) { + return { resolved: true, value: picked }; + } + // Accepted but the value did not match a known option — treat as no-op. + // Defensive: the SDK validates accepted `content` against requestedSchema + // and throws on mismatch (caught below), so in production an out-of-enum + // value normally hits the fallback rather than reaching here. + return { resolved: false, result: declinedChoiceResult(options) }; + } + // decline / cancel → the user opted out; make no change. + return { resolved: false, result: declinedChoiceResult(options) }; + } catch { + // Native path unsupported/failed at runtime → agent-guided fallback below. + } + } + return { resolved: false, result: needChoice(question, options, paramName) }; +} + +export type TextResolution = + | { resolved: true; value: string } + | { resolved: false; result: McpToolResult | null }; + +/** + * Ask the user for a short free-text value (e.g. a new app display name), + * preferring native MCP elicitation. Unlike `elicitChoice`, the fallback is + * SILENT: when the client cannot elicit natively (or the user declines), this + * returns `{ resolved: false, result: null }` so the caller keeps its existing + * default rather than emitting a new text prompt for an optional value. + */ +export async function elicitText( + message: string, + paramName: string, + opts?: { title?: string }, +): Promise { + if (nativeElicitationAvailable() && wired) { + try { + const res = await wired.elicitInput({ + mode: "form", + message, + requestedSchema: { + type: "object", + properties: { + [paramName]: { + type: "string", + title: opts?.title ?? paramName, + description: message, + }, + }, + required: [paramName], + }, + }); + if (res.action === "accept") { + const value = res.content?.[paramName]; + if (typeof value === "string" && value.trim() !== "") { + return { resolved: true, value: value.trim() }; + } + } + } catch { + // fall through to the silent no-op below + } + } + return { resolved: false, result: null }; +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..8cf324a --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export interface SafeError { + code: string; + message: string; + suggestion?: string; + /** + * Short random tag (8 chars) generated per failure to correlate the + * client-facing error with the full server-side diagnostics. + * + * How to debug with it: + * - It is appended to the message returned to the MCP client + * (e.g. `... (correlationId: a1b2c3d4)`) and to the `suggestion` for + * unexpected `INTERNAL_ERROR`s. + * - The server also logs it to **stderr** at the point of failure, e.g. + * `[] [MCP] Tool error (a1b2c3d4) { ... }`. stderr is the server's + * log channel (stdout is reserved for the MCP JSON-RPC protocol). + * - To investigate, grep the server's stderr log for the id, e.g. + * `grep a1b2c3d4 spe-mcp.log`, to find the redacted argument preview and + * the sanitized upstream (Graph/ARM) failure that produced it. + * + * It is a client↔log join key only — it is not sent to Graph/ARM and is not + * an `x-ms-client-request-id`. + */ + correlationId: string; +} + +export interface AppErrorOptions { + suggestion?: string; + status?: number; + retryAfter?: string | null; + safeMessage?: string; +} + +export class AppError extends Error { + readonly code: string; + readonly suggestion?: string; + readonly status?: number; + readonly retryAfter?: string | null; + readonly safeMessage?: string; + + constructor(code: string, message: string, options: AppErrorOptions = {}) { + super(message); + this.name = "AppError"; + this.code = code; + this.suggestion = options.suggestion; + this.status = options.status; + this.retryAfter = options.retryAfter; + this.safeMessage = options.safeMessage; + } +} + +export class ValidationError extends AppError { + constructor(message: string, suggestion?: string) { + super("INVALID_ARGS", message, { suggestion, safeMessage: message }); + this.name = "ValidationError"; + } +} + +function correlationId(): string { + return Math.random().toString(36).slice(2, 10); +} + +export function toSafeError(error: unknown): SafeError { + const id = correlationId(); + if (error instanceof AppError) { + const retrySuggestion = error.retryAfter + ? `Retry after ${error.retryAfter} second(s).` + : undefined; + return { + code: error.code, + message: error.safeMessage ?? error.message, + suggestion: error.suggestion ?? retrySuggestion, + correlationId: id, + }; + } + + return { + code: "INTERNAL_ERROR", + message: "The tool failed. See server logs for details.", + suggestion: `Share this correlation ID with the server operator: ${id}.`, + correlationId: id, + }; +} + +/** + * Client-safe message for tool-local `catch` blocks (SEC-002 consistency). + * + * For an `AppError` (e.g. a Graph failure mapped by graph-client) this returns + * the sanitized `safeMessage` so the raw Graph/az response body is never echoed + * to the MCP client. For any other error it returns the plain message, which — + * for our own thrown errors and network/library errors — carries useful local + * diagnostics without leaking upstream payloads. + */ +export function clientSafeMessage(error: unknown): string { + if (error instanceof AppError) return error.safeMessage ?? error.message; + return error instanceof Error ? error.message : String(error); +} diff --git a/src/graph-client.test.ts b/src/graph-client.test.ts new file mode 100644 index 0000000..006a50a --- /dev/null +++ b/src/graph-client.test.ts @@ -0,0 +1,754 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for owning-app permission handling in graph-client.ts + * (addSpePermissions / addSpaRedirectUris / findApplicationByAppId / + * updateContainerType). + * + * Each behavior under test is documented on its own describe/it block below, so + * this header stays a short pointer rather than a per-test index that rots as + * cases are added. The global fetch is mocked so these run fully offline. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + addSpePermissions, + addSpaRedirectUris, + createApplication, + findApplicationByAppId, + findApplicationByName, + updateContainerType, + registerContainerType, + listContainerTypes, + getSignedInUser, + desiredGraphResourceAccess, + LOCAL_SPA_REDIRECT_URI, +} from "./graph-client.js"; + +// updateContainerType uses the default getAccessToken (MSAL); mock it so the +// container-type update tests run fully offline. The other tests here pass an +// explicit getToken and are unaffected. +vi.mock("./auth.js", () => ({ getAccessToken: vi.fn(async () => "test-token") })); + +// graph-client now imports readState/writeState (for the container-type +// staleness flag — PR #3 review). Mock ./state.js so listContainerTypes / +// registerContainerType never touch the real on-disk state file, and so we can +// assert the flag writes. `vi.hoisted` lets the mock factory share one in-memory +// store that the tests read/reset. +const { stateStore, readStateMock, writeStateMock } = vi.hoisted(() => { + const store: Record = {}; + return { + stateStore: store, + readStateMock: vi.fn(() => ({ ...store })), + writeStateMock: vi.fn((patch: Record) => { + Object.assign(store, patch); + }), + }; +}); +vi.mock("./state.js", () => ({ readState: readStateMock, writeState: writeStateMock })); + +const GRAPH_RESOURCE_APP_ID = "00000003-0000-0000-c000-000000000000"; +const IDS = { + fscManage: "527b6d64-cdf5-4b8b-b336-4aa0b8ca2ce5", + fscSelected: "085ca537-6565-41c2-aca7-db852babc212", + fsctManage: "8e6ec84c-5fcd-4cc7-ac8a-2296efc0ed9b", + fsctrManage: "c319a7df-930e-44c0-a43b-7e5e9c7f4f24", + fsctrSelected: "d1e4f63a-1569-475c-b9b2-bdc140405e38", +}; +const DESIRED_IDS = [ + IDS.fscManage, + IDS.fscSelected, + IDS.fsctManage, + IDS.fsctrManage, + IDS.fsctrSelected, +]; + +const getToken = async () => "test-token"; + +interface ResourceAccess { + id: string; + type: string; +} +interface RequiredResourceAccess { + resourceAppId: string; + resourceAccess: ResourceAccess[]; +} + +function okResponse(body: unknown, status = 200): Response { + return { + ok: true, + status, + headers: { get: () => null }, + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response; +} + +function errResponse(status: number, message: string): Response { + return { + ok: false, + status, + headers: { get: () => null }, + json: async () => ({ error: { message } }), + text: async () => message, + } as unknown as Response; +} + +let fetchMock: ReturnType; +// Capture the real global fetch so teardown can restore it. Assigning +// `globalThis.fetch` directly (below) is a raw property mutation that +// vi.restoreAllMocks() does NOT undo, so without this the mock would leak past +// this file. Request shape (method/URL/body) is asserted on fetchMock.mock.calls +// throughout the suite. +const realFetch = globalThis.fetch; + +beforeEach(() => { + fetchMock = vi.fn(); + globalThis.fetch = fetchMock as unknown as typeof fetch; + // Silence (and allow assertions on) the [Graph] logger. + vi.spyOn(console, "error").mockImplementation(() => {}); + // Reset the shared in-memory state store + its mocks between tests. + for (const key of Object.keys(stateStore)) delete stateStore[key]; + readStateMock.mockClear(); + writeStateMock.mockClear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); // restores the console.error spy + globalThis.fetch = realFetch; // restore the directly-mutated global fetch +}); + +/** Parse the requiredResourceAccess PATCH body from the Nth fetch call. */ +function patchedRequiredResourceAccess(callIndex: number): RequiredResourceAccess[] { + const call = fetchMock.mock.calls[callIndex]; + const init = call[1] as RequestInit; + const parsed = JSON.parse(init.body as string) as { + requiredResourceAccess: RequiredResourceAccess[]; + }; + return parsed.requiredResourceAccess; +} + +function graphEntry(rra: RequiredResourceAccess[]): RequiredResourceAccess | undefined { + return rra.find( + (e) => e.resourceAppId.toLowerCase() === GRAPH_RESOURCE_APP_ID.toLowerCase(), + ); +} + +describe("updateContainerType — supplies the required etag", () => { + it("fetches the current etag via Get and includes it in the PATCH body", async () => { + fetchMock + .mockResolvedValueOnce( + okResponse({ id: "ct-1", name: "Old Name", owningAppId: "app-1", etag: "ETAG-123" }), + ) // GET (read current etag) + .mockResolvedValueOnce( + okResponse({ id: "ct-1", name: "New Name", owningAppId: "app-1", etag: "ETAG-124" }), + ); // PATCH + + await updateContainerType("ct-1", { name: "New Name" }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [getUrl, getInit] = fetchMock.mock.calls[0]; + expect((getInit as RequestInit).method).toBe("GET"); + expect(getUrl).toContain("/storage/fileStorage/containerTypes/ct-1"); + const [patchUrl, patchInit] = fetchMock.mock.calls[1]; + expect((patchInit as RequestInit).method).toBe("PATCH"); + expect(patchUrl).toContain("/storage/fileStorage/containerTypes/ct-1"); + // The required etag (read from the Get) is merged into the update body. + expect(JSON.parse((patchInit as RequestInit).body as string)).toEqual({ + name: "New Name", + etag: "ETAG-123", + }); + }); + + it("IGNORES a caller-supplied etag and overwrites it with the fresh server etag (WI-08 hardening)", async () => { + fetchMock + .mockResolvedValueOnce( + okResponse({ id: "ct-1", name: "Old Name", owningAppId: "app-1", etag: "ETAG-123" }), + ) // GET (read current etag — always performed) + .mockResolvedValueOnce( + okResponse({ id: "ct-1", name: "New Name", owningAppId: "app-1", etag: "ETAG-124" }), + ); // PATCH + + // Caller passes a (potentially stale) etag; it must be dropped, and a fresh + // GET must still happen so the PATCH carries the current server etag. + await updateContainerType("ct-1", { name: "New Name", etag: "CALLER-STALE-ETAG" }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [getUrl, getInit] = fetchMock.mock.calls[0]; + expect((getInit as RequestInit).method).toBe("GET"); + expect(getUrl).toContain("/storage/fileStorage/containerTypes/ct-1"); + const [, patchInit] = fetchMock.mock.calls[1]; + expect((patchInit as RequestInit).method).toBe("PATCH"); + const patchBody = JSON.parse((patchInit as RequestInit).body as string); + // The caller etag is discarded; the server etag from the GET wins. + expect(patchBody).toEqual({ name: "New Name", etag: "ETAG-123" }); + expect(patchBody.etag).not.toBe("CALLER-STALE-ETAG"); + }); + + it("normalizes a 204 No Content PATCH response to a populated container type", async () => { + fetchMock + .mockResolvedValueOnce( + okResponse({ id: "ct-1", name: "Old Name", owningAppId: "app-1", etag: "ETAG-123" }), + ) // GET + .mockResolvedValueOnce(okResponse(null, 204)); // PATCH → 204 No Content + + const result = await updateContainerType("ct-1", { name: "New Name" }); + // Should not throw; normalizeContainerType({}) yields a defined object. + expect(result).toBeDefined(); + }); +}); + +describe("addSpePermissions — G1 merge (non-destructive)", () => { + it("preserves an unrelated permission and a pre-existing SPE subset, adds the rest exactly once", async () => { + const existing: RequiredResourceAccess[] = [ + // (a) unrelated resourceApp permission that must survive. + { + resourceAppId: "11111111-2222-3333-4444-555555555555", + resourceAccess: [{ id: "aaaaaaaa-0000-0000-0000-000000000000", type: "Role" }], + }, + // (b) a subset of the SPE scopes already present. + { + resourceAppId: GRAPH_RESOURCE_APP_ID, + resourceAccess: [{ id: IDS.fscManage, type: "Scope" }], + }, + ]; + + fetchMock + .mockResolvedValueOnce(okResponse({ requiredResourceAccess: existing })) // GET + .mockResolvedValueOnce(okResponse({}, 204)); // PATCH + + await addSpePermissions("obj-1", getToken); + + // GET then PATCH. + expect(fetchMock).toHaveBeenCalledTimes(2); + const [getUrl, getInit] = fetchMock.mock.calls[0]; + expect(getInit.method).toBe("GET"); + expect(getUrl).toContain("/applications/obj-1"); + expect(getUrl).toContain("$select=requiredResourceAccess"); + expect(fetchMock.mock.calls[1][1].method).toBe("PATCH"); + + const patched = patchedRequiredResourceAccess(1); + + // Unrelated permission preserved. + const unrelated = patched.find( + (e) => e.resourceAppId === "11111111-2222-3333-4444-555555555555", + ); + expect(unrelated).toBeDefined(); + expect(unrelated!.resourceAccess).toEqual([ + { id: "aaaaaaaa-0000-0000-0000-000000000000", type: "Role" }, + ]); + + // All desired SPE scopes present exactly once on the Graph entry. + const graph = graphEntry(patched)!; + expect(graph).toBeDefined(); + const graphIds = graph.resourceAccess.map((a) => a.id); + for (const id of DESIRED_IDS) { + expect(graphIds.filter((g) => g === id)).toHaveLength(1); + } + // No duplicates overall. + expect(new Set(graphIds).size).toBe(graphIds.length); + // FSCTR.Selected (G2) is included. + expect(graphIds).toContain(IDS.fsctrSelected); + }); + + it("adds a Graph resourceApp entry when the app has none", async () => { + fetchMock + .mockResolvedValueOnce(okResponse({ requiredResourceAccess: [] })) + .mockResolvedValueOnce(okResponse({}, 204)); + + await addSpePermissions("obj-2", getToken); + + const graph = graphEntry(patchedRequiredResourceAccess(1))!; + expect(graph.resourceAccess.map((a) => a.id).sort()).toEqual([...DESIRED_IDS].sort()); + expect(graph.resourceAccess.every((a) => a.type === "Scope")).toBe(true); + }); + + it("is idempotent — re-applying when all scopes already exist adds no duplicates", async () => { + const existing: RequiredResourceAccess[] = [ + { + resourceAppId: GRAPH_RESOURCE_APP_ID, + resourceAccess: DESIRED_IDS.map((id) => ({ id, type: "Scope" })), + }, + ]; + + fetchMock + .mockResolvedValueOnce(okResponse({ requiredResourceAccess: existing })) + .mockResolvedValueOnce(okResponse({}, 204)); + + await addSpePermissions("obj-3", getToken); + + const graph = graphEntry(patchedRequiredResourceAccess(1))!; + expect(graph.resourceAccess).toHaveLength(DESIRED_IDS.length); + expect(new Set(graph.resourceAccess.map((a) => a.id)).size).toBe(DESIRED_IDS.length); + }); + + it("handles an app with no requiredResourceAccess field at all", async () => { + fetchMock + .mockResolvedValueOnce(okResponse({})) // no requiredResourceAccess key + .mockResolvedValueOnce(okResponse({}, 204)); + + await addSpePermissions("obj-4", getToken); + + const graph = graphEntry(patchedRequiredResourceAccess(1))!; + expect(graph.resourceAccess.map((a) => a.id).sort()).toEqual([...DESIRED_IDS].sort()); + }); +}); + +describe("addSpePermissions — G2 scope parity (intent-based least privilege)", () => { + // The scope set is now a function of the captured owner intent (PR #3 review): + // "manage-all" requests the broad .Manage.All set; "selected" requests only the + // least-privilege .Selected pair (+ the delegated-only ContainerType.Manage.All, + // which has no .Selected/app-only form and is required to create/enumerate CTs). + + it("manage-all keeps the broad .Manage.All scopes plus FileStorageContainerTypeReg.Selected", async () => { + fetchMock + .mockResolvedValueOnce(okResponse({ requiredResourceAccess: [] })) + .mockResolvedValueOnce(okResponse({}, 204)); + + await addSpePermissions("obj-5", getToken, { ownerScope: "manage-all" }); + + const graphIds = graphEntry(patchedRequiredResourceAccess(1))!.resourceAccess.map( + (a) => a.id, + ); + expect(graphIds).toContain(IDS.fsctrSelected); // parity scope + expect(graphIds).toContain(IDS.fscManage); // .Manage.All retained + expect(graphIds).toContain(IDS.fsctManage); + expect(graphIds).toContain(IDS.fsctrManage); + expect(graphIds.map((id) => id).sort()).toEqual([...DESIRED_IDS].sort()); + }); + + it("defaults to the broad manage-all set when no ownerScope is passed (back-compat)", async () => { + fetchMock + .mockResolvedValueOnce(okResponse({ requiredResourceAccess: [] })) + .mockResolvedValueOnce(okResponse({}, 204)); + + await addSpePermissions("obj-5d", getToken); + + const graphIds = graphEntry(patchedRequiredResourceAccess(1))!.resourceAccess.map( + (a) => a.id, + ); + expect(graphIds.sort()).toEqual([...DESIRED_IDS].sort()); + }); + + it("selected requests only the least-privilege scopes and OMITS the broad Container/Reg .Manage.All scopes", async () => { + fetchMock + .mockResolvedValueOnce(okResponse({ requiredResourceAccess: [] })) + .mockResolvedValueOnce(okResponse({}, 204)); + + await addSpePermissions("obj-5s", getToken, { ownerScope: "selected" }); + + const graphIds = graphEntry(patchedRequiredResourceAccess(1))!.resourceAccess.map( + (a) => a.id, + ); + // Least-privilege trio present… + expect(graphIds).toContain(IDS.fscSelected); + expect(graphIds).toContain(IDS.fsctrSelected); + // …including the unavoidable delegated-only ContainerType.Manage.All (no + // .Selected/app-only counterpart; required for CT create/enumerate). + expect(graphIds).toContain(IDS.fsctManage); + // Broad Container/Reg .Manage.All scopes are NOT requested. + expect(graphIds).not.toContain(IDS.fscManage); + expect(graphIds).not.toContain(IDS.fsctrManage); + expect(graphIds.sort()).toEqual( + [IDS.fscSelected, IDS.fsctManage, IDS.fsctrSelected].sort(), + ); + }); + + it("merge stays non-destructive for a reused broad app even when ownerScope is selected (never downgrades)", async () => { + // A pre-existing app already holding the broad .Manage.All scopes must NOT be + // stripped down when a later selected-intent run merges — merge only ADDS. + const existing: RequiredResourceAccess[] = [ + { + resourceAppId: GRAPH_RESOURCE_APP_ID, + resourceAccess: DESIRED_IDS.map((id) => ({ id, type: "Scope" })), + }, + ]; + fetchMock + .mockResolvedValueOnce(okResponse({ requiredResourceAccess: existing })) + .mockResolvedValueOnce(okResponse({}, 204)); + + await addSpePermissions("obj-5m", getToken, { ownerScope: "selected" }); + + const graphIds = graphEntry(patchedRequiredResourceAccess(1))!.resourceAccess.map( + (a) => a.id, + ); + // Still holds every broad scope — nothing was removed. + expect(graphIds.sort()).toEqual([...DESIRED_IDS].sort()); + }); +}); + +describe("desiredGraphResourceAccess — intent → scope mapping (PR #3 review)", () => { + it("selected → least-privilege trio, keeps ContainerType.Manage.All, omits broad Container/Reg .Manage.All", () => { + const ids = desiredGraphResourceAccess("selected").map((a) => a.id); + expect(ids.sort()).toEqual([IDS.fscSelected, IDS.fsctManage, IDS.fsctrSelected].sort()); + expect(ids).toContain(IDS.fsctManage); + expect(ids).not.toContain(IDS.fscManage); + expect(ids).not.toContain(IDS.fsctrManage); + }); + + it("manage-all → the full broad scope set", () => { + const ids = desiredGraphResourceAccess("manage-all").map((a) => a.id); + expect(ids.sort()).toEqual([...DESIRED_IDS].sort()); + }); + + it("every entry is a delegated Scope (never an app-only Role)", () => { + for (const scope of ["selected", "manage-all"] as const) { + expect(desiredGraphResourceAccess(scope).every((a) => a.type === "Scope")).toBe(true); + } + }); +}); + +describe("registerContainerType — app-only default ['none'] + re-grant preservation (PR #3 review)", () => { + // The full-setup path uses ONLY delegated tokens (no app-only token path), so + // the owning app needs no app-only grant. app-only permissions default to + // ["none"] and are opt-in. The registration PUT REPLACES the whole + // applicationPermissionGrants collection, so when the caller omits app-only + // perms we read-merge any grant the app already holds rather than revoke it. + + /** Parse the PUT registration body from the Nth fetch call. */ + function putGrant(callIndex: number) { + const init = fetchMock.mock.calls[callIndex][1] as RequestInit; + const parsed = JSON.parse(init.body as string) as { + applicationPermissionGrants: { + appId: string; + delegatedPermissions: string[]; + applicationPermissions: string[]; + }[]; + }; + return parsed.applicationPermissionGrants[0]; + } + + it("defaults application permissions to ['none'] (delegated stays ['full'])", async () => { + fetchMock + // GET existing grants (read-merge lookup) → none yet. + .mockResolvedValueOnce(okResponse({ value: [] })) + // PUT registration. + .mockResolvedValueOnce(okResponse({}, 204)); + + await registerContainerType("ct-1", "app-1"); + + // GET (grants) then PUT (registration). + expect(fetchMock).toHaveBeenCalledTimes(2); + const [putUrl, putInit] = fetchMock.mock.calls[1]; + expect((putInit as RequestInit).method).toBe("PUT"); + expect(putUrl).toContain("/storage/fileStorage/containerTypeRegistrations/ct-1"); + const grant = putGrant(1); + expect(grant.appId).toBe("app-1"); + expect(grant.delegatedPermissions).toEqual(["full"]); + expect(grant.applicationPermissions).toEqual(["none"]); + }); + + it("opt-in ['full'] writes an app-only grant and skips the read-merge lookup", async () => { + // Explicit app-only perms → single PUT, no preceding GET. + fetchMock.mockResolvedValueOnce(okResponse({}, 204)); + + await registerContainerType("ct-1", "app-1", ["full"], ["full"]); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const grant = putGrant(0); + expect(grant.applicationPermissions).toEqual(["full"]); + expect(grant.delegatedPermissions).toEqual(["full"]); + }); + + it("re-register preserves an existing app-only grant when the caller omits applicationPermissions", async () => { + // The app already holds an app-only ['full'] grant from a prior daemon setup. + fetchMock + .mockResolvedValueOnce( + okResponse({ + value: [ + { appId: "APP-1", delegatedPermissions: ["full"], applicationPermissions: ["full"] }, + ], + }), + ) + .mockResolvedValueOnce(okResponse({}, 204)); + + // Caller omits app-only perms → must NOT silently revoke the prior grant. + await registerContainerType("ct-1", "app-1"); + + const grant = putGrant(1); + expect(grant.applicationPermissions).toEqual(["full"]); // preserved, case-insensitive appId match + }); + + it("keeps ['none'] when the read-merge lookup fails (e.g., first registration / 404)", async () => { + fetchMock + .mockResolvedValueOnce(errResponse(404, "no registration yet")) // GET grants → 404 + .mockResolvedValueOnce(okResponse({}, 204)); // PUT + + await registerContainerType("ct-1", "app-1"); + + expect(putGrant(1).applicationPermissions).toEqual(["none"]); + }); + + it("fails closed (throws, no PUT) when the read-merge lookup fails with a non-404 (avoids silent revoke)", async () => { + // An AMBIGUOUS lookup failure (403, not a clean 404) must NOT proceed to a PUT + // that would replace the whole grant collection and could silently revoke an + // app-only grant we merely failed to read. Fail closed instead (PR #3 review). + fetchMock.mockResolvedValueOnce(errResponse(403, "insufficient privileges")); // GET grants → 403 + + await expect(registerContainerType("ct-1", "app-1")).rejects.toThrow(/Access denied/); + + // Only the GET happened — no PUT (which would have replaced the grant) was attempted. + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); + +describe("listContainerTypes — runtime staleness-flag self-heal (PR #3 review)", () => { + it("records owningAppManagesAllContainerTypes=true on a successful enumerate", async () => { + fetchMock.mockResolvedValueOnce( + okResponse({ value: [{ id: "ct-1", name: "CT One", owningAppId: "app-1" }] }), + ); + + const result = await listContainerTypes(); + + expect(result).toHaveLength(1); + expect(writeStateMock).toHaveBeenCalledWith({ owningAppManagesAllContainerTypes: true }); + expect(stateStore.owningAppManagesAllContainerTypes).toBe(true); + }); + + it("records owningAppManagesAllContainerTypes=false on a 403 and rethrows the original error", async () => { + fetchMock.mockResolvedValueOnce(errResponse(403, "insufficient privileges")); + + await expect(listContainerTypes()).rejects.toThrow(/Access denied/); + + expect(writeStateMock).toHaveBeenCalledWith({ owningAppManagesAllContainerTypes: false }); + expect(stateStore.owningAppManagesAllContainerTypes).toBe(false); + }); + + it("does not re-write the flag when it already matches (no churn)", async () => { + stateStore.owningAppManagesAllContainerTypes = true; + fetchMock.mockResolvedValueOnce(okResponse({ value: [] })); + + await listContainerTypes(); + + expect(writeStateMock).not.toHaveBeenCalled(); + }); +}); + +describe("addSpePermissions — G3 best-effort attach path", () => { + // bestEffort use case: when reusing an ALREADY-provisioned owning app, the + // signed-in user may not have rights to edit that app's API permissions. + // Syncing permissions is a nice-to-have on that path, not a gate — so + // bestEffort=true makes addSpePermissions swallow + log a Graph failure instead + // of aborting provisioning. On the create path bestEffort is omitted, so the + // same failures surface (see the "propagates … when bestEffort is not set" tests). + it("swallows a PATCH failure and logs when bestEffort=true (attach/reuse path)", async () => { + fetchMock + .mockResolvedValueOnce(okResponse({ requiredResourceAccess: [] })) // GET ok + .mockResolvedValueOnce(errResponse(403, "Insufficient privileges")); // PATCH fails + + await expect( + addSpePermissions("obj-6", getToken, { bestEffort: true }), + ).resolves.toBeUndefined(); + + const logged = (console.error as unknown as ReturnType).mock.calls + .map((c) => c.join(" ")) + .join("\n"); + expect(logged).toContain("best-effort"); + }); + + it("swallows a GET failure too when bestEffort=true", async () => { + fetchMock.mockResolvedValueOnce(errResponse(403, "Insufficient privileges")); + + await expect( + addSpePermissions("obj-7", getToken, { bestEffort: true }), + ).resolves.toBeUndefined(); + }); + + it("propagates a PATCH failure on the strict (create-new) path", async () => { + fetchMock + .mockResolvedValueOnce(okResponse({ requiredResourceAccess: [] })) + .mockResolvedValueOnce(errResponse(403, "Insufficient privileges")); + + await expect(addSpePermissions("obj-8", getToken)).rejects.toThrow(/Access denied/); + }); +}); + +describe("findApplicationByAppId — G3 appId resolution", () => { + it("resolves an app by appId and maps it to the OwningApp shape", async () => { + fetchMock.mockResolvedValueOnce( + okResponse({ + value: [{ id: "obj-9", appId: "app-9", displayName: "Existing App" }], + }), + ); + + const app = await findApplicationByAppId("app-9", getToken); + + expect(app).toEqual({ objectId: "obj-9", appId: "app-9", displayName: "Existing App" }); + const [url, init] = fetchMock.mock.calls[0]; + expect(init.method).toBe("GET"); + // Filters on appId, not displayName. + expect(decodeURIComponent(url as string)).toContain("appId eq 'app-9'"); + }); + + it("returns null when no app matches the appId", async () => { + fetchMock.mockResolvedValueOnce(okResponse({ value: [] })); + expect(await findApplicationByAppId("missing", getToken)).toBeNull(); + }); + + it("findApplicationByName still filters on displayName (regression)", async () => { + fetchMock.mockResolvedValueOnce(okResponse({ value: [] })); + await findApplicationByName("My App", getToken); + const url = fetchMock.mock.calls[0][0] as string; + expect(decodeURIComponent(url)).toContain("displayName eq 'My App'"); + }); +}); + +/** Parse the JSON request body of the Nth fetch call. */ +function requestBody(callIndex: number): T { + const init = fetchMock.mock.calls[callIndex][1] as RequestInit; + return JSON.parse(init.body as string) as T; +} + +describe("createApplication — SPA platform", () => { + interface CreateBody { + displayName: string; + signInAudience: string; + isFallbackPublicClient?: boolean; + publicClient?: { redirectUris?: string[] }; + spa?: { redirectUris?: string[] }; + } + + it("registers a `spa` platform with the local Vite origin (fixes AADSTS9002326)", async () => { + fetchMock.mockResolvedValueOnce( + okResponse({ id: "obj-new", appId: "app-new", displayName: "SPE Builder App" }), + ); + + const app = await createApplication("SPE Builder App", getToken); + expect(app).toEqual({ objectId: "obj-new", appId: "app-new", displayName: "SPE Builder App" }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(init.method).toBe("POST"); + expect(url).toContain("/applications"); + + const body = requestBody(0); + // The browser SPA's MSAL.js auth-code + PKCE redirect origin. + expect(body.spa?.redirectUris).toContain("http://localhost:5173"); + expect(body.spa?.redirectUris).toContain(LOCAL_SPA_REDIRECT_URI); + }); + + it("is ADDITIVE — preserves publicClient loopback and isFallbackPublicClient for the CLI flow", async () => { + fetchMock.mockResolvedValueOnce( + okResponse({ id: "obj-new2", appId: "app-new2", displayName: "SPE Builder App" }), + ); + + await createApplication("SPE Builder App", getToken); + + const body = requestBody(0); + // publicClient loopback (MCP CLI desktop public-client flow) must remain. + expect(body.publicClient?.redirectUris).toEqual(["http://localhost"]); + expect(body.isFallbackPublicClient).toBe(true); + expect(body.signInAudience).toBe("AzureADMyOrg"); + // Both platforms coexist — neither replaces the other. + expect(body.publicClient?.redirectUris).not.toContain("http://localhost:5173"); + expect(body.spa?.redirectUris).not.toContain("http://localhost"); + }); +}); + +describe("addSpaRedirectUris — deployed-origin patch", () => { + const DEPLOYED = "https://delightful-coast-0ac296a1e.7.azurestaticapps.net"; + + it("appends a deployed origin without dropping the existing local SPA redirect URI", async () => { + fetchMock + .mockResolvedValueOnce(okResponse({ spa: { redirectUris: ["http://localhost:5173"] } })) // GET + .mockResolvedValueOnce(okResponse({}, 204)); // PATCH + + const result = await addSpaRedirectUris("obj-spa", [DEPLOYED], getToken); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0][1].method).toBe("GET"); + expect(fetchMock.mock.calls[0][0]).toContain("$select=spa"); + expect(fetchMock.mock.calls[1][1].method).toBe("PATCH"); + + const body = requestBody<{ spa: { redirectUris: string[] } }>(1); + // Existing local dev origin preserved; deployed origin appended. + expect(body.spa.redirectUris).toEqual(["http://localhost:5173", DEPLOYED]); + expect(result).toEqual({ + added: [DEPLOYED], + redirectUris: ["http://localhost:5173", DEPLOYED], + }); + }); + + it("is idempotent — re-adding an already-registered origin issues no PATCH and drops nothing", async () => { + fetchMock.mockResolvedValueOnce( + okResponse({ spa: { redirectUris: ["http://localhost:5173", DEPLOYED] } }), // GET only + ); + + const result = await addSpaRedirectUris("obj-spa", [DEPLOYED], getToken); + + // GET only — no PATCH when nothing changes. + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][1].method).toBe("GET"); + expect(result).toEqual({ + added: [], + redirectUris: ["http://localhost:5173", DEPLOYED], + }); + }); + + it("dedupes case- and trailing-slash-insensitively (no duplicate redirect URIs)", async () => { + fetchMock.mockResolvedValueOnce( + okResponse({ spa: { redirectUris: [`${DEPLOYED}/`] } }), // stored with trailing slash + ); + + const result = await addSpaRedirectUris("obj-spa", [DEPLOYED], getToken); + + // Same origin modulo trailing slash → treated as present, no PATCH. + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result?.added).toEqual([]); + }); + + it("handles an app with no spa platform yet (adds the origin)", async () => { + fetchMock + .mockResolvedValueOnce(okResponse({})) // no spa key + .mockResolvedValueOnce(okResponse({}, 204)); + + const result = await addSpaRedirectUris("obj-spa", [DEPLOYED], getToken); + + const body = requestBody<{ spa: { redirectUris: string[] } }>(1); + expect(body.spa.redirectUris).toEqual([DEPLOYED]); + expect(result?.added).toEqual([DEPLOYED]); + }); + + it("swallows a PATCH failure when bestEffort=true (non-blocking deploy path)", async () => { + // bestEffort use case here: registering a deployed SPA redirect URI is a + // convenience during provisioning. If the caller can't PATCH the app (e.g. + // insufficient privileges on a reused app), that must not fail the deploy — so + // bestEffort=true swallows the Graph error and resolves undefined. + fetchMock + .mockResolvedValueOnce(okResponse({ spa: { redirectUris: [] } })) + .mockResolvedValueOnce(errResponse(403, "Insufficient privileges")); + + await expect( + addSpaRedirectUris("obj-spa", [DEPLOYED], getToken, { bestEffort: true }), + ).resolves.toBeUndefined(); + }); + + it("propagates a PATCH failure when bestEffort is not set", async () => { + fetchMock + .mockResolvedValueOnce(okResponse({ spa: { redirectUris: [] } })) + .mockResolvedValueOnce(errResponse(403, "Insufficient privileges")); + + await expect(addSpaRedirectUris("obj-spa", [DEPLOYED], getToken)).rejects.toThrow(/Access denied/); + }); +}); + +describe("getSignedInUser — /me select includes userType (guest handling, PR #3 review)", () => { + it("requests userType and surfaces it so callers can detect a guest (B2B) user", async () => { + fetchMock.mockResolvedValueOnce( + okResponse({ id: "user-1", displayName: "Alice", userPrincipalName: "alice@contoso.com", userType: "Guest" }), + ); + + const me = await getSignedInUser(getToken); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][1].method).toBe("GET"); + // The $select must carry userType (added for the guest-owner check) alongside + // the pre-existing fields. + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain("/me"); + expect(url).toContain("$select=id,displayName,userPrincipalName,userType"); + expect(me.userType).toBe("Guest"); + expect(me.id).toBe("user-1"); + }); +}); + diff --git a/src/graph-client.ts b/src/graph-client.ts new file mode 100644 index 0000000..c82fd02 --- /dev/null +++ b/src/graph-client.ts @@ -0,0 +1,1358 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Microsoft Graph client for SPE operations. + * + * The client: + * - Auth interceptor injects Bearer token on every request + * - Centralized error handling with actionable messages + * - Retry logic for transient failures (429, 5xx) + */ + +import { getAccessToken } from "./auth.js"; +import { LOCAL_SPA_REDIRECT_URI } from "./constants.js"; +import { AppError } from "./errors.js"; +import { parseRetryAfterMs } from "./http-client.js"; +import { readState, writeState } from "./state.js"; +import { USER_AGENT } from "./user-agent.js"; +import type { + ApplicationPermissionGrant, + Container, + ContainerPermission, + ContainerType, + ContainerTypePermission, + ContainerTypeRegistrationRecord, + CustomProperties, + Drive, + DriveItem, + GraphCollection, + Guid, + OwnerScope, + PreviewResult, + SearchResponse, + SharingLink, + UploadSession, +} from "./types.js"; + +import type { + Application, + RequiredResourceAccess as GraphRequiredResourceAccess, + ResourceAccess as GraphResourceAccess, +} from "@microsoft/microsoft-graph-types"; + +// Container types are a Microsoft Graph **beta** control-plane resource; the +// official type comes from the types-only, dev-only `-beta` package. +import type { FileStorageContainerType } from "@microsoft/microsoft-graph-types-beta"; + +const GRAPH_BASE = "https://graph.microsoft.com/v1.0"; +// SPE container-type `permissions` (the `owner` role that lets a public client / +// PCA create containers) exist only under the beta endpoint. v1.0 stays the +// default base; specific container-type / permission / createContainer calls opt +// into beta via graphRequestBeta. +const GRAPH_BETA_BASE = "https://graph.microsoft.com/beta"; + +// Retry config for throttled/transient errors +const MAX_RETRIES = 3; +const BASE_RETRY_DELAY_MS = 2000; + +function log(message: string, data?: unknown): void { + const timestamp = new Date().toISOString(); + if (data !== undefined) { + console.error( + `[${timestamp}] [Graph] ${message}`, + typeof data === "string" ? data : JSON.stringify(data), + ); + } else { + console.error(`[${timestamp}] [Graph] ${message}`); + } +} + +async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function graphErrorForStatus(status: number, errorBody: string, retryAfter?: string | null): AppError { + if (status === 401) { + return new AppError( + "UNAUTHORIZED", + "Authentication failed. Token may have expired — try re-authenticating.", + { + status, + safeMessage: "Authentication failed while calling Microsoft Graph.", + suggestion: "Re-authenticate and retry.", + }, + ); + } + if (status === 403) { + return new AppError("FORBIDDEN", `Access denied: ${errorBody}`, { + status, + safeMessage: "Access denied by Microsoft Graph.", + suggestion: "Confirm the signed-in account, tenant, consent, and SharePoint Embedded permissions.", + }); + } + if (status === 404) { + return new AppError("NOT_FOUND", `Resource not found: ${errorBody}`, { + status, + safeMessage: "Microsoft Graph resource was not found.", + suggestion: "Verify identifiers such as containerTypeId, containerId, driveId, or itemId.", + }); + } + if (status === 409) { + return new AppError("CONFLICT", `Graph API conflict (409): ${errorBody}`, { + status, + safeMessage: "Microsoft Graph reported a conflict.", + suggestion: "Refresh the resource state and retry.", + }); + } + if (status === 429) { + return new AppError("RATE_LIMITED", `Graph API throttled (429): ${errorBody}`, { + status, + retryAfter, + safeMessage: "Microsoft Graph throttled the request.", + suggestion: retryAfter ? `Retry after ${retryAfter} second(s).` : "Wait and retry the request.", + }); + } + if (status >= 500) { + return new AppError("UPSTREAM", `Graph API upstream error (${status}): ${errorBody}`, { + status, + safeMessage: "Microsoft Graph returned an upstream service error.", + suggestion: "Retry later. If the issue persists, check Microsoft Graph service health.", + }); + } + return new AppError("UPSTREAM", `Graph API error (${status}): ${errorBody}`, { + status, + safeMessage: "Microsoft Graph rejected the request.", + suggestion: "Check the request arguments and current resource state.", + }); +} + +/** + * Make an authenticated request to Microsoft Graph with retry logic. + * + * By default the token comes from the MSAL provider (SPE owning-app token). + * Pass `getToken` to use a different token source — e.g. the Azure CLI + * bootstrap token for directory operations like creating the owning app. + */ +async function graphRequest( + method: string, + path: string, + body?: unknown, + customHeaders?: Record, + getToken: () => Promise = getAccessToken, + baseUrl: string = GRAPH_BASE, +): Promise { + const url = `${baseUrl}${path}`; + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + const token = await getToken(); + + const headers: Record = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "User-Agent": USER_AGENT, + ...customHeaders, + }; + + const options: RequestInit = { method, headers }; + if (body && (method === "POST" || method === "PUT" || method === "PATCH")) { + options.body = typeof body === "string" ? body : JSON.stringify(body); + } + + log(`${method} ${path} (attempt ${attempt + 1})`); + + let response: Response; + try { + response = await fetch(url, options); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (attempt < MAX_RETRIES) { + const delay = BASE_RETRY_DELAY_MS * Math.pow(2, attempt); + log(`Network error, retrying in ${delay}ms: ${msg}`); + await sleep(delay); + continue; + } + throw new AppError("UPSTREAM", `Network error calling Graph API: ${msg}`, { + safeMessage: "Network error calling Microsoft Graph.", + suggestion: "Check network connectivity and retry.", + }); + } + + if (response.ok) { + // 204 No Content + if (response.status === 204) { + return undefined as T; + } + return (await response.json()) as T; + } + + // Retry on throttle or server error + if ((response.status === 429 || response.status >= 500) && attempt < MAX_RETRIES) { + const retryAfter = response.headers.get("Retry-After"); + const delay = parseRetryAfterMs(retryAfter) ?? BASE_RETRY_DELAY_MS * Math.pow(2, attempt); + log(`${response.status} — retrying in ${delay}ms`); + await sleep(delay); + continue; + } + + // Parse error response + let errorBody: string; + try { + const errJson = await response.json(); + errorBody = errJson?.error?.message ?? JSON.stringify(errJson); + } catch { + errorBody = await response.text().catch(() => "Unknown error"); + } + + throw graphErrorForStatus(response.status, errorBody, response.headers.get("Retry-After")); + } + + throw new AppError("UPSTREAM", "Max retries exceeded", { + safeMessage: "Microsoft Graph request retry limit was exceeded.", + suggestion: "Retry later.", + }); +} + +/** + * Convenience wrapper over {@link graphRequest} that targets the Microsoft Graph + * **beta** endpoint. It does not duplicate any request logic — it simply calls + * `graphRequest` with `baseUrl = GRAPH_BETA_BASE`, so retry/auth/error handling + * stays in one place. It exists only so the many beta call sites (SPE + * container-type management and the container-type `permissions`/owner + * collection, which are beta-only) don't each have to repeat the base-URL + * argument. + */ +function graphRequestBeta( + method: string, + path: string, + body?: unknown, + customHeaders?: Record, + getToken: () => Promise = getAccessToken, +): Promise { + return graphRequest(method, path, body, customHeaders, getToken, GRAPH_BETA_BASE); +} + +// ─── Owning App (created via Azure CLI bootstrap token) ───────────────────── + +// Stable delegated permission GUIDs on Microsoft Graph. Include both +// FileStorageContainer.Manage.All and Selected so the owning app can read +// containers it creates and perform container-type operations. +const GRAPH_RESOURCE_APP_ID = "00000003-0000-0000-c000-000000000000"; +const SPE_DELEGATED_PERMISSION_IDS = { + FileStorageContainer_ManageAll: "527b6d64-cdf5-4b8b-b336-4aa0b8ca2ce5", + FileStorageContainer_Selected: "085ca537-6565-41c2-aca7-db852babc212", + FileStorageContainerType_ManageAll: "8e6ec84c-5fcd-4cc7-ac8a-2296efc0ed9b", + FileStorageContainerTypeReg_ManageAll: "c319a7df-930e-44c0-a43b-7e5e9c7f4f24", + // FileStorageContainerTypeReg.Selected — the .Selected counterpart used by + // SPAC's selected-container model (PR 2159372). Added for scope-set parity. + FileStorageContainerTypeReg_Selected: "d1e4f63a-1569-475c-b9b2-bdc140405e38", +} as const; + +/** + * The kind of a Microsoft Graph `requiredResourceAccess` entry: `"Scope"` for a + * delegated permission (acting on behalf of a signed-in user) or `"Role"` for an + * application permission (app-only). SPE's owning app requests delegated scopes, + * so every entry we author uses `"Scope"`; `"Role"` is included for + * completeness / round-tripping any pre-existing app-only grants we merge over. + */ +type GraphResourceAccessType = "Scope" | "Role"; + +/** + * A single Microsoft Graph permission entry inside a + * {@link RequiredResourceAccess} block — the `{ id, type }` shape Entra expects + * under `application.requiredResourceAccess[].resourceAccess`. `id` is the + * stable GUID of a delegated scope or app role on the target resource (for us, + * the Microsoft Graph service principal); `type` distinguishes the two. + */ +type ResourceAccess = Required> & { + type: GraphResourceAccessType; +}; + +/** + * A Microsoft Graph `requiredResourceAccess` block: the set of {@link + * ResourceAccess} permissions requested against one resource API, keyed by that + * API's app id (`resourceAppId` — the Microsoft Graph service principal + * `00000003-0000-0000-c000-000000000000` for the scopes we add). + */ +type RequiredResourceAccess = Required> & { + resourceAccess: ResourceAccess[]; +}; + +// Desired delegated (type "Scope") Microsoft Graph permissions for the owning +// app, as a function of the captured owner intent (PR #3 review). The broad +// `.Manage.All` Container/Reg scopes are requested ONLY for an admin/console app +// that manages every container type ("manage-all"); a standard ISV/LOB app +// ("selected") gets the least-privilege `.Selected` pair instead. Note: +// FileStorageContainerType.Manage.All is KEPT in BOTH sets — it is delegated-only +// (there is no `.Selected` or app-only counterpart) and is REQUIRED to create / +// enumerate container types, so narrowing it is not possible. +export function desiredGraphResourceAccess(ownerScope: OwnerScope): ResourceAccess[] { + if (ownerScope === "selected") { + return [ + { id: SPE_DELEGATED_PERMISSION_IDS.FileStorageContainer_Selected, type: "Scope" }, + { id: SPE_DELEGATED_PERMISSION_IDS.FileStorageContainerType_ManageAll, type: "Scope" }, + { id: SPE_DELEGATED_PERMISSION_IDS.FileStorageContainerTypeReg_Selected, type: "Scope" }, + ]; + } + return [ + { id: SPE_DELEGATED_PERMISSION_IDS.FileStorageContainer_ManageAll, type: "Scope" }, + { id: SPE_DELEGATED_PERMISSION_IDS.FileStorageContainer_Selected, type: "Scope" }, + { id: SPE_DELEGATED_PERMISSION_IDS.FileStorageContainerType_ManageAll, type: "Scope" }, + { id: SPE_DELEGATED_PERMISSION_IDS.FileStorageContainerTypeReg_ManageAll, type: "Scope" }, + { id: SPE_DELEGATED_PERMISSION_IDS.FileStorageContainerTypeReg_Selected, type: "Scope" }, + ]; +} + +/** + * Non-destructively merge `desiredAccess` for `resourceAppId` into an app's + * existing requiredResourceAccess. Preserves every pre-existing entry (including + * unrelated resourceAppIds) and adds only the {id,type} pairs that are missing. + * Dedupe is by resourceAppId + access id + type, so merging is idempotent. + */ +function mergeRequiredResourceAccess( + existing: RequiredResourceAccess[], + resourceAppId: string, + desiredAccess: ResourceAccess[], +): RequiredResourceAccess[] { + // Deep-clone so we never mutate the caller's/Graph's objects. + const merged: RequiredResourceAccess[] = (existing ?? []).map((entry) => ({ + resourceAppId: entry.resourceAppId, + resourceAccess: [...(entry.resourceAccess ?? [])], + })); + + let target = merged.find( + (e) => e.resourceAppId?.toLowerCase() === resourceAppId.toLowerCase(), + ); + if (!target) { + target = { resourceAppId, resourceAccess: [] }; + merged.push(target); + } + + // Seed a Set of the target's existing `id|type` keys (id lower-cased so dedupe + // is case-insensitive) so each membership check is O(1) instead of re-scanning + // the array with `.some()`. Newly-added keys are recorded too, guarding against + // duplicates within `desiredAccess` itself. Order is preserved (we still push + // onto the array); the Set is only the presence index. + const seen = new Set( + target.resourceAccess.map((a) => `${a.id?.toLowerCase()}|${a.type}`), + ); + for (const desired of desiredAccess) { + const key = `${desired.id.toLowerCase()}|${desired.type}`; + if (!seen.has(key)) { + seen.add(key); + target.resourceAccess.push({ id: desired.id, type: desired.type }); + } + } + + return merged; +} + +/** Strip a trailing slash and l-case so dedupe treats equivalent origins as one. */ +function normalizeRedirectUri(uri: string): string { + return uri.replace(/\/+$/, "").toLowerCase(); +} + +/** + * Append `toAdd` redirect URIs to `existing`, skipping any already present + * (case-insensitive, trailing-slash-insensitive). Existing URIs keep their + * original order and casing; newly added URIs are appended in order. Idempotent. + */ +function mergeRedirectUris(existing: string[], toAdd: string[]): string[] { + const seen = new Set(existing.map(normalizeRedirectUri)); + const merged = [...existing]; + for (const uri of toAdd) { + const key = normalizeRedirectUri(uri); + if (!seen.has(key)) { + seen.add(key); + merged.push(uri); + } + } + return merged; +} + +export interface OwningApp { + /** Application (client) id. */ + appId: NonNullable; + /** Directory object id. */ + objectId: NonNullable; + displayName: NonNullable; +} + +/** + * The raw Microsoft Graph `application` payload as returned by the + * `/applications` endpoints — a subset of the fields we actually consume. Note + * Graph's naming: `id` is the directory **object id** and `appId` is the + * **client id**; {@link toOwningApp} maps this into our {@link OwningApp} shape + * (`objectId`/`appId`) so callers aren't exposed to the ambiguous `id` field. + */ +interface RawApplication { + /** Directory object id (Graph `id`), surfaced as {@link OwningApp.objectId}. */ + id: NonNullable; + /** Application (client) id, surfaced as {@link OwningApp.appId}. */ + appId: NonNullable; + displayName: NonNullable; +} + +function toOwningApp(raw: RawApplication): OwningApp { + return { appId: raw.appId, objectId: raw.id, displayName: raw.displayName }; +} + +/** Find an existing Entra app by display name (idempotent provisioning). */ +export async function findApplicationByName( + displayName: string, + getToken: () => Promise, +): Promise { + const filter = `displayName eq '${displayName.replace(/'/g, "''")}'`; + const result = await graphRequest>( + "GET", + `/applications?$filter=${encodeURIComponent(filter)}`, + undefined, + undefined, + getToken, + ); + const app = result.value?.[0]; + return app ? toOwningApp(app) : null; +} + +/** + * Find an existing Entra app by its appId (client ID). Preferred over + * display-name lookup when an appId is known — appId is the stable identity, so + * attach/reuse resolves the correct object even if the display name changed or + * collides. Mirrors SPAC's appId-based application resolution (PR 2159372). + */ +export async function findApplicationByAppId( + appId: string, + getToken: () => Promise, +): Promise { + const filter = `appId eq '${appId.replace(/'/g, "''")}'`; + const result = await graphRequest>( + "GET", + `/applications?$filter=${encodeURIComponent(filter)}`, + undefined, + undefined, + getToken, + ); + const app = result.value?.[0]; + return app ? toOwningApp(app) : null; +} + +/** + * Local origin of the scaffolded React SPA's Vite dev server. The generated app + * (react-spa-template.ts emits `server: { port: LOCAL_DEV_PORT }`) authenticates + * with MSAL.js using `redirectUri: window.location.origin`, i.e. the local Vite + * origin during dev. MSAL.js uses the auth-code + PKCE flow, which Entra only + * honours for a redirect URI registered under the app's `spa` platform — hence + * this constant is added to `spa.redirectUris` at app create/reuse time (see + * createApplication and the project_app_create reuse path). + * + * Defined in ./constants.ts + * and re-exported here so existing importers keep their `./graph-client.js` path. + */ +export { LOCAL_SPA_REDIRECT_URI } from "./constants.js"; + +/** + * Create a public-client Entra app to own a container type. Public client + * (`isFallbackPublicClient: true`) so no secret is needed — auth is delegated + * device-code/interactive as this app. Mirrors the full-setup skill `02-app.ps1`. + * + * The SAME app registration is used by two clients: + * 1. the SPE MCP CLI desktop interactive/device-code flow — a *public client* + * redeeming on the loopback `http://localhost`; and + * 2. the generated browser React SPA — a *single-page application* that uses + * MSAL.js (auth-code + PKCE) and redeems from its web origin. + * Entra rejects cross-origin SPA code redemption unless the origin is registered + * under the `spa` platform (AADSTS9002326). So we register BOTH platforms: + * `publicClient` (loopback, for the CLI) AND `spa` (local Vite origin, for the + * browser app). The two are additive — neither flow works if either is dropped. + * The `spa` key is the Microsoft Graph v1.0 `application` resource's + * single-page-application platform (siblings: `web`, `spa`, `publicClient`). + */ +export async function createApplication( + displayName: string, + getToken: () => Promise, +): Promise { + const body = { + displayName, + signInAudience: "AzureADMyOrg", + isFallbackPublicClient: true, + // Loopback redirect for the MCP CLI desktop public-client flow. + publicClient: { redirectUris: ["http://localhost"] }, + // SPA platform for the generated browser app's MSAL.js auth-code + PKCE flow. + // Without this, browser code redemption from the Vite origin fails with + // AADSTS9002326. Deployed origins are appended post-deploy (addSpaRedirectUris). + spa: { redirectUris: [LOCAL_SPA_REDIRECT_URI] }, + }; + const raw = await graphRequest( + "POST", + "/applications", + body, + undefined, + getToken, + ); + return toOwningApp(raw); +} + +/** + * Non-destructively add one or more redirect URIs to an app's `spa` platform. + * + * Read-modify-write: GETs the app's current + * `spa.redirectUris`, appends only the origins that are not already present + * (dedupe is case-insensitive and trailing-slash-insensitive), then PATCHes the + * merged list back. This preserves existing SPA URIs — notably the local + * http://localhost:5173 dev origin set at create time — and is idempotent: + * re-running with an already-registered origin adds nothing and issues no PATCH. + * + * Used after a successful deploy to add the live Static Web App origin (e.g. + * https://.azurestaticapps.net) so browser sign-in works without a manual + * portal edit. + * + * @param options.bestEffort When true, a failure to read or patch is logged and + * swallowed (returns undefined) so the caller — e.g. project_deploy — is not + * blocked by a missing Application.ReadWrite grant. + * @returns `{ added, redirectUris }` describing what changed (`added` is empty + * when every origin was already registered), or undefined when a best-effort + * attempt failed. + */ +export async function addSpaRedirectUris( + appObjectId: string, + origins: string[], + getToken: () => Promise, + options: { bestEffort?: boolean } = {}, +): Promise<{ added: string[]; redirectUris: string[] } | undefined> { + try { + // 1. Read the app's current SPA redirect URIs so we extend, not replace. + const existing = await graphRequest<{ spa?: { redirectUris?: string[] } }>( + "GET", + `/applications/${appObjectId}?$select=spa`, + undefined, + undefined, + getToken, + ); + const current = existing.spa?.redirectUris ?? []; + + // 2. Append only the origins not already registered (no duplicates). + const merged = mergeRedirectUris(current, origins); + const added = merged.slice(current.length); + + // 3. Nothing to do — skip the PATCH entirely so the call is a true no-op. + if (added.length === 0) { + return { added: [], redirectUris: current }; + } + + // 4. PATCH the merged list back (read-modify-write; existing URIs preserved). + await graphRequest( + "PATCH", + `/applications/${appObjectId}`, + { spa: { redirectUris: merged } }, + undefined, + getToken, + ); + return { added, redirectUris: merged }; + } catch (error) { + if (options.bestEffort) { + const msg = error instanceof Error ? error.message : String(error); + log( + `addSpaRedirectUris (best-effort): could not add SPA redirect URIs to app ` + + `${appObjectId}; continuing. Add them manually if needed. Reason: ${msg}`, + ); + return undefined; + } + throw error; + } +} + +/** + * Ensure the SPE delegated permissions are present on an app's + * requiredResourceAccess. + * + * Non-destructive: GETs the app's current + * requiredResourceAccess, MERGES in only the missing Microsoft Graph scope ids, + * then PATCHes the merged array. This preserves any other API permissions + * already on the app (which a wholesale REPLACE would silently wipe, since + * spe_create_app reuses an existing app by identity) and is idempotent — + * re-running adds nothing and creates no duplicates. + * + * @param options.bestEffort When true (the attach/reuse path), a failure to read + * or patch permissions is logged as a warning and swallowed so provisioning is + * non-blocking, mirroring SPAC. The create-new path leaves this false so errors + * propagate. + * @param options.ownerScope The captured owner intent (PR #3 review) that selects + * the least-privilege scope set: "selected" (default here is "manage-all" for + * backward-compatible callers) requests only the `.Selected` scopes, while + * "manage-all" requests the broad `.Manage.All` set. The merge stays + * non-destructive either way, so an existing broad app is never downgraded — + * only a brand-new app gets the narrower set. + */ +export async function addSpePermissions( + appObjectId: string, + getToken: () => Promise, + options: { bestEffort?: boolean; ownerScope?: OwnerScope } = {}, +): Promise { + try { + // 1. Read the app's existing requiredResourceAccess so we can merge, not replace. + const existing = await graphRequest<{ requiredResourceAccess?: RequiredResourceAccess[] }>( + "GET", + `/applications/${appObjectId}?$select=requiredResourceAccess`, + undefined, + undefined, + getToken, + ); + + // 2. Merge in only the missing Graph scopes (dedupe by resourceAppId + id + type). + // An unspecified ownerScope defaults to "manage-all" so pre-existing + // callers keep the historical broad set; the intent-aware tools pass + // "selected" explicitly to request least privilege for new apps. + const merged = mergeRequiredResourceAccess( + existing.requiredResourceAccess ?? [], + GRAPH_RESOURCE_APP_ID, + desiredGraphResourceAccess(options.ownerScope ?? "manage-all"), + ); + + // 3. PATCH the merged array back. + await graphRequest( + "PATCH", + `/applications/${appObjectId}`, + { requiredResourceAccess: merged }, + undefined, + getToken, + ); + } catch (error) { + if (options.bestEffort) { + const msg = error instanceof Error ? error.message : String(error); + log( + `addSpePermissions (best-effort): could not add SPE delegated permissions to ` + + `app ${appObjectId}; continuing. Grant them manually if needed. Reason: ${msg}`, + ); + return; + } + throw error; + } +} + +/** + * Resolve the signed-in user's directory object id (and UPN). Used to default + * the container-type `owner` grant to the current user. Pass the Azure CLI + * bootstrap token provider — the az client has User.Read so `/me` succeeds. + * + * `userType` ("Member" | "Guest") is included so callers can surface a clear, + * NON-BLOCKING message that a guest (B2B) user cannot be a container-type owner + * (the Graph API rejects it) instead of a raw API error. (PR #3 review.) + */ +export async function getSignedInUser( + azCliTokenProvider: () => Promise, +): Promise<{ id: Guid; displayName?: string; userPrincipalName?: string; userType?: string }> { + return graphRequest<{ id: Guid; displayName?: string; userPrincipalName?: string; userType?: string }>( + "GET", + "/me?$select=id,displayName,userPrincipalName,userType", + undefined, + undefined, + azCliTokenProvider, + ); +} + +// ─── Container Types ──────────────────────────────────────────────────────── + +// Graph returns container types with `id` and `name`; our model uses +// `containerTypeId` and `displayName`. Normalize at the boundary so callers +// (and the 1:1 owning-app guard / auto-registration) read a populated id. +// +// Derived from the official beta `FileStorageContainerType` (types-only, dev-only +// dependency): the scalar wire fields (`id`, `name`, `owningAppId`, +// `createdDateTime`, `expirationDateTime`, `etag`) are `Pick`ed from the official +// type; the normalize-friendly extras our model adds (`containerTypeId`, +// `displayName`, `azureSubscriptionId`) and the locally-narrowed +// `billingClassification` union are intersected on. Every field stays optional so +// `normalizeContainerType({})` (used for an empty PATCH/204 body) still +// type-checks (per PR #3 review). +type RawContainerType = Pick< + FileStorageContainerType, + "id" | "name" | "owningAppId" | "createdDateTime" | "expirationDateTime" | "etag" +> & { + containerTypeId?: string; + displayName?: string; + billingClassification?: ContainerType["billingClassification"]; + azureSubscriptionId?: string; +}; + +function normalizeContainerType(raw: RawContainerType): ContainerType { + return { + ...raw, + containerTypeId: raw.containerTypeId ?? raw.id ?? "", + displayName: raw.displayName ?? raw.name ?? "", + owningAppId: raw.owningAppId ?? "", + } as ContainerType; +} + +/** + * Record whether the owning app can enumerate ALL container types (i.e., holds + * FileStorageContainerType.Manage.All) into persisted state (PR #3 review). This + * lights up the context-gate staleness warning when the flag is `false`. It is + * deliberately NON-THROWING and only writes on an actual change: a failed state + * write must never mask the caller's list result or error semantics, and it must + * not churn the state file on every read. + */ +function recordManagesAllContainerTypes(value: boolean): void { + try { + if (readState().owningAppManagesAllContainerTypes !== value) { + writeState({ owningAppManagesAllContainerTypes: value }); + } + } catch { + /* best-effort flag write — swallow so the caller's semantics are unchanged */ + } +} + +export async function listContainerTypes(): Promise { + try { + const result = await graphRequestBeta>( + "GET", + "/storage/fileStorage/containerTypes", + ); + // Success ⇒ this app can enumerate ALL container types (it holds + // FileStorageContainerType.Manage.All). Self-heal the staleness flag to true; + // this runtime signal wins over recorded intent for reused apps (PR #3 review). + recordManagesAllContainerTypes(true); + return (result.value ?? []).map(normalizeContainerType); + } catch (error) { + // A 403 (FORBIDDEN) means the app lacks FileStorageContainerType.Manage.All + // and cannot enumerate all container types — record the flag false so the + // context-gate staleness warning fires, then rethrow the original error + // UNCHANGED so callers see the same failure semantics as before. + if (error instanceof AppError && (error.status === 403 || error.code === "FORBIDDEN")) { + recordManagesAllContainerTypes(false); + } + throw error; + } +} + +export async function createContainerType(params: { + displayName: string; + owningAppId: Guid; + billingClassification?: "trial" | "standard" | "directToCustomer"; + // OPTIONAL by design: the Graph container-type *create* call does not take a + // subscription (see the NOTE below). azureSubscriptionId/resourceGroup/region + // are only required for the separate ARM billing-link step (standard billing); + // they ride on this signature for call-site convenience but are consumed there, + // not here. Trial container types need none of them. + azureSubscriptionId?: Guid; + resourceGroup?: string; + region?: string; +}): Promise { + // IMPORTANT: the Graph create body field is `name`, NOT `displayName` + // (verified by the live-tested full-setup skill — gotchas.md #2). + const body: Record = { + name: params.displayName, + owningAppId: params.owningAppId, + }; + + if (params.billingClassification) { + body.billingClassification = params.billingClassification; + } + + // NOTE: azureSubscriptionId/resourceGroup/region are intentionally NOT sent in + // the Graph create body — the v1.0 fileStorageContainerType resource does not + // accept them (the same field family the Update PATCH rejects with HTTP 400). + // The Azure billing link is attached separately by creating a + // Microsoft.Syntex/accounts (RaaS) ARM resource (see billing_setup → + // createSyntexAccount). The sub/rg/region params remain on the signature for + // call-site compatibility but are consumed by the ARM-account step, not here. + + // Response field is `id`, NOT `containerTypeId` — normalize so the id is usable. + const raw = await graphRequestBeta("POST", "/storage/fileStorage/containerTypes", body); + return normalizeContainerType(raw); +} + +// ─── Container Type Registration ──────────────────────────────────────────── + +export async function registerContainerType( + containerTypeId: string, + appId: string, + delegatedPermissions: string[] = ["full"], + applicationPermissions?: string[], +): Promise { + // App-only (application) permissions default to ["none"] (PR #3 review). The + // full-setup path uses ONLY delegated tokens (an Azure CLI bootstrap token and + // an MSAL device-code token acquired AS the owning app); there is no app-only + // token path, so the owning app needs no app-only grant. App-only permissions + // are opt-in — for a separate daemon/app-only consumer — and passed explicitly. + // + // Re-grant safety: the registration PUT REPLACES the entire + // applicationPermissionGrants collection, so a naive re-run that dropped a + // pre-existing app-only grant would silently REVOKE it. When the caller does + // not specify app-only permissions, read-merge any grant this app already holds + // instead of clobbering it; an explicit value always wins. + let effectiveAppPermissions: string[]; + if (applicationPermissions !== undefined) { + effectiveAppPermissions = applicationPermissions; + } else { + effectiveAppPermissions = ["none"]; + try { + const existingGrants = await listContainerTypeAppPermissions(containerTypeId); + const priorGrant = existingGrants.find( + (g) => g.appId?.toLowerCase() === appId.toLowerCase(), + ); + if (priorGrant?.applicationPermissions && priorGrant.applicationPermissions.length > 0) { + effectiveAppPermissions = priorGrant.applicationPermissions; + } + } catch (lookupError) { + // Only a genuine "no existing registration/grant" (404 NOT_FOUND) is safe to + // treat as an absent grant and keep the least-privilege ["none"] default. Any + // OTHER failure (e.g. 403, or a transient error that exhausted retries) is + // AMBIGUOUS: proceeding with the PUT would replace the whole grant collection + // and could silently REVOKE an app-only grant we merely failed to read. Fail + // closed by rethrowing rather than risk a silent downgrade (PR #3 review). + if ( + !(lookupError instanceof AppError && + (lookupError.code === "NOT_FOUND" || lookupError.status === 404)) + ) { + throw lookupError; + } + } + } + + const body = { + applicationPermissionGrants: [ + { + appId, + delegatedPermissions, + applicationPermissions: effectiveAppPermissions, + } satisfies ApplicationPermissionGrant, + ], + }; + + // Tenant-level registration endpoint (verified by the live-tested skill + // 04-container-type.ps1). MUST include a grant entry with sufficient + // delegatedPermissions, or container creation later fails with + // UnauthorizedAccessException — it is the DELEGATED grant that matters here. + await graphRequest( + "PUT", + `/storage/fileStorage/containerTypeRegistrations/${containerTypeId}`, + body, + ); +} + +// ─── Container Type Registration — application permission grants (v1.0) ────── +// +// The `applicationPermissionGrants` collection on a containerTypeRegistration +// authorizes individual consuming apps to act on the container type. The +// registration above (PUT on the registration) replaces the WHOLE collection; +// these helpers add / list / remove a SINGLE app's grant without disturbing the +// others — the supported way to authorize additional apps on an existing +// registration. The appId is part of the URL, never the body. The list endpoint +// returns the standard OData `{ value: [...] }` envelope (shared `GraphCollection`). + +/** + * Grant (create or replace) a single application's permission grant on a + * container type registration (v1.0). Idempotent upsert via PUT — re-granting an + * existing appId overwrites its permissions. The registration id is the + * container type id in the tenant-local model used here. + */ +export async function grantContainerTypeAppPermission( + containerTypeId: string, + appId: string, + delegatedPermissions: string[] = ["full"], + applicationPermissions: string[] = ["full"], +): Promise { + return graphRequest( + "PUT", + `/storage/fileStorage/containerTypeRegistrations/${containerTypeId}/applicationPermissionGrants/${appId}`, + { delegatedPermissions, applicationPermissions }, + ); +} + +/** List the application permission grants on a container type registration (v1.0). */ +export async function listContainerTypeAppPermissions( + containerTypeId: string, +): Promise { + const result = await graphRequest>( + "GET", + `/storage/fileStorage/containerTypeRegistrations/${containerTypeId}/applicationPermissionGrants`, + ); + return result.value ?? []; +} + +// ─── Container Type Registrations — CRUDL on the registration RECORD ───────── +// +// A registration is the tenant↔containerType binding (distinct from a single +// app's permission grant). It must exist before containers can be created, and +// it MUST be deleted before the container type can be deleted. Per Graph, a +// registration can only be deleted once it has NO containers AND NO deleted +// (recycle-bin) containers. + +// Microsoft Graph returns collections as an OData envelope — `{ value: [...] }` +// (plus optional paging fields), never a bare JSON array — modeled by the shared +// `GraphCollection`. The public helpers below unwrap `.value` and return a +// plain `ContainerTypeRegistrationRecord[]` so callers never see the envelope. + +/** Read a single container type registration record (v1.0). */ +export async function getContainerTypeRegistration( + containerTypeId: string, +): Promise { + return graphRequest( + "GET", + `/storage/fileStorage/containerTypeRegistrations/${containerTypeId}`, + ); +} + +/** + * List the container type registrations on the tenant (v1.0). Unwraps Graph's + * OData `{ value: [...] }` envelope and returns the bare array. + */ +export async function listContainerTypeRegistrations(): Promise { + const result = await graphRequest>( + "GET", + "/storage/fileStorage/containerTypeRegistrations", + ); + return result.value ?? []; +} + +/** + * Delete a container type registration record (v1.0). Graph: + * DELETE /storage/fileStorage/containerTypeRegistrations/{id} → 204. Fails with + * 409 if the registration still has containers or deleted (recycle-bin) + * containers. This is the step that unblocks container type deletion. + */ +export async function deleteContainerTypeRegistration(containerTypeId: string): Promise { + await graphRequest( + "DELETE", + `/storage/fileStorage/containerTypeRegistrations/${containerTypeId}`, + ); +} + +/** Remove a single application's permission grant from a container type registration (v1.0). */ +export async function revokeContainerTypeAppPermission( + containerTypeId: string, + appId: string, +): Promise { + await graphRequest( + "DELETE", + `/storage/fileStorage/containerTypeRegistrations/${containerTypeId}/applicationPermissionGrants/${appId}`, + ); +} +// ─── Container Type Permissions (owner role — beta only) ───────────────── + +/** + * Grant the `owner` role on a container type to a USER (beta). Owners can create + * containers using a public client (PCA) / delegated token — v1.0 rejects + * container creation by public clients. Only the `owner` role and a USER + * identity are supported; max 3 permissions per container type (duplicates are + * idempotent). The caller must already be an owner / SPE admin / Global admin. + */ +export async function grantContainerTypeOwner( + containerTypeId: string, + userId: string, +): Promise { + return graphRequestBeta( + "POST", + `/storage/fileStorage/containerTypes/${containerTypeId}/permissions`, + { roles: ["owner"], grantedToV2: { user: { id: userId } } }, + ); +} + +/** List the permission (owner) entries on a container type (beta). */ +export async function listContainerTypePermissions( + containerTypeId: string, +): Promise { + const result = await graphRequestBeta>( + "GET", + `/storage/fileStorage/containerTypes/${containerTypeId}/permissions`, + ); + return result.value ?? []; +} + +/** Get a single container-type permission by id (beta). */ +export async function getContainerTypePermission( + containerTypeId: string, + permissionId: string, +): Promise { + return graphRequestBeta( + "GET", + `/storage/fileStorage/containerTypes/${containerTypeId}/permissions/${permissionId}`, + ); +} + +/** Remove an owner permission from a container type (beta). */ +export async function revokeContainerTypePermission( + containerTypeId: string, + permissionId: string, +): Promise { + await graphRequestBeta( + "DELETE", + `/storage/fileStorage/containerTypes/${containerTypeId}/permissions/${permissionId}`, + ); +} +// ─── Containers ───────────────────────────────────────────────────────────── + +export async function listContainers(containerTypeId: string): Promise { + const result = await graphRequest>( + "GET", + `/storage/fileStorage/containers?$filter=containerTypeId eq ${containerTypeId}`, + ); + return result.value ?? []; +} + +export async function createContainer( + containerTypeId: string, + displayName: string, +): Promise { + return graphRequestBeta("POST", "/storage/fileStorage/containers", { + displayName, + containerTypeId, + }); +} + +/** + * Update (rename / edit) a container's editable properties (displayName, + * description). Graph: PATCH /storage/fileStorage/containers/{id}. Only the + * provided fields are sent. Returns the updated container. + */ +export async function updateContainer( + containerId: string, + patch: { displayName?: string; description?: string }, +): Promise { + const body: Record = {}; + if (patch.displayName !== undefined) body.displayName = patch.displayName; + if (patch.description !== undefined) body.description = patch.description; + return graphRequest( + "PATCH", + `/storage/fileStorage/containers/${containerId}`, + body, + ); +} + +/** + * List soft-deleted containers in the tenant recycle bin (optionally filtered by + * container type). Graph: GET /storage/fileStorage/deletedContainers. These are + * containers that have been soft-deleted but not yet permanently purged; a + * container type registration cannot be deleted while any (live OR deleted) + * container exists, so this is required to find recycle-bin blockers. + */ +export async function listDeletedContainers(containerTypeId?: string): Promise { + const path = containerTypeId + ? `/storage/fileStorage/deletedContainers?$filter=containerTypeId eq ${containerTypeId}` + : "/storage/fileStorage/deletedContainers"; + const result = await graphRequest>("GET", path); + return result.value ?? []; +} + +export async function activateContainer(containerId: string): Promise { + await graphRequest( + "POST", + `/storage/fileStorage/containers/${containerId}/activate`, + ); +} + +// ─── Container Permissions ────────────────────────────────────────────────── + +export async function addContainerPermission( + containerId: string, + userPrincipalName: string, + role: string, +): Promise { + return graphRequest( + "POST", + `/storage/fileStorage/containers/${containerId}/permissions`, + { + roles: [role], + grantedToV2: { + user: { userPrincipalName }, + }, + }, + ); +} + +// ─── Container Details ────────────────────────────────────────────────────── + +export async function getContainer(containerId: string): Promise { + return graphRequest( + "GET", + `/storage/fileStorage/containers/${containerId}`, + ); +} + +export async function deleteContainer(containerId: string): Promise { + await graphRequest( + "DELETE", + `/storage/fileStorage/containers/${containerId}`, + ); +} + +export async function permanentDeleteContainer(containerId: string): Promise { + await graphRequest( + "POST", + `/storage/fileStorage/containers/${containerId}/permanentDelete`, + ); +} + +export async function restoreDeletedContainer(containerId: string): Promise { + await graphRequest( + "POST", + `/storage/fileStorage/deletedContainers/${containerId}/restore`, + ); +} + +export async function lockContainer(containerId: string): Promise { + await graphRequest( + "POST", + `/storage/fileStorage/containers/${containerId}/lock`, + ); +} + +export async function unlockContainer(containerId: string): Promise { + await graphRequest( + "POST", + `/storage/fileStorage/containers/${containerId}/unlock`, + ); +} + +export async function listContainerPermissions( + containerId: string, +): Promise { + const result = await graphRequest>( + "GET", + `/storage/fileStorage/containers/${containerId}/permissions`, + ); + return result.value ?? []; +} + +export async function updateContainerPermission( + containerId: string, + permissionId: string, + role: string, +): Promise { + await graphRequest( + "PATCH", + `/storage/fileStorage/containers/${containerId}/permissions/${permissionId}`, + { roles: [role] }, + ); +} + +export async function removeContainerPermission( + containerId: string, + permissionId: string, +): Promise { + await graphRequest( + "DELETE", + `/storage/fileStorage/containers/${containerId}/permissions/${permissionId}`, + ); +} + +export async function getCustomProperties( + containerId: string, +): Promise { + return graphRequest( + "GET", + `/storage/fileStorage/containers/${containerId}/customProperties`, + ); +} + +// ─── Drive / Content Operations ───────────────────────────────────────────── + +export async function getContainerDrive(containerId: string): Promise { + return graphRequest( + "GET", + `/storage/fileStorage/containers/${containerId}/drive`, + ); +} + +export async function getDriveItem( + driveId: string, + itemPath: string, +): Promise { + return graphRequest( + "GET", + `/drives/${driveId}/root:${itemPath}`, + ); +} + +export async function listDriveChildren( + driveId: string, + folderId?: string, +): Promise { + const path = folderId + ? `/drives/${driveId}/items/${folderId}/children` + : `/drives/${driveId}/root/children`; + const result = await graphRequest>("GET", path); + return result.value ?? []; +} + +export async function uploadSmallFile( + driveId: string, + targetPath: string, + content: string, +): Promise { + return graphRequest( + "PUT", + `/drives/${driveId}/root:${targetPath}:/content`, + content, + { "Content-Type": "text/plain" }, + ); +} + +export async function createUploadSession( + driveId: string, + targetPath: string, + fileName: string, +): Promise { + return graphRequest( + "POST", + `/drives/${driveId}/root:${targetPath}:/createUploadSession`, + { + item: { + "@microsoft.graph.conflictBehavior": "rename", + name: fileName, + }, + }, + ); +} + +export async function createFolder( + driveId: string, + parentId: string, + folderName: string, +): Promise { + const path = parentId === "root" + ? `/drives/${driveId}/root/children` + : `/drives/${driveId}/items/${parentId}/children`; + return graphRequest("POST", path, { + name: folderName, + folder: {}, + "@microsoft.graph.conflictBehavior": "fail", + }); +} + +export async function previewDriveItem( + driveId: string, + itemId: string, +): Promise { + return graphRequest( + "POST", + `/drives/${driveId}/items/${itemId}/preview`, + {}, + ); +} + +export async function createSharingLink( + driveId: string, + itemId: string, + type: string, + scope: string, +): Promise { + return graphRequest( + "POST", + `/drives/${driveId}/items/${itemId}/createLink`, + { type, scope }, + ); +} + +export async function listDriveItemPermissions( + driveId: string, + itemId: string, +): Promise { + const result = await graphRequest>( + "GET", + `/drives/${driveId}/items/${itemId}/permissions`, + ); + return result.value ?? []; +} + +export async function revokeSharingLink( + driveId: string, + itemId: string, + permissionId: string, +): Promise { + await graphRequest( + "DELETE", + `/drives/${driveId}/items/${itemId}/permissions/${permissionId}`, + ); +} + +export async function searchContent( + query: string, + maxResults: number = 25, + from: number = 0, +): Promise { + return graphRequest( + "POST", + "/search/query", + { + requests: [ + { + entityTypes: ["driveItem"], + query: { queryString: query, includeHiddenContent: true }, + from, + size: maxResults, + }, + ], + }, + ); +} + +// ─── Container Type Config (get / update / delete) ────────────────────────── +// NOTE on billing: there are no billing *operations* in this module. SPE billing +// is an Azure Resource Manager concern — a Microsoft.Syntex/accounts (RaaS) +// resource linked to the container type — and lives in `azure-cli.ts` +// (ensureSyntexProviderRegistered / getSyntexAccounts / createSyntexAccount), +// orchestrated by `tools/provision.ts`. The functions below only read/mutate the +// container type's Graph configuration (which *carries* a billingClassification +// field); `billing_check` reads that field via getContainerType. + +export async function getContainerType( + containerTypeId: string, +): Promise { + // Graph beta returns `id`/`name`; normalize to containerTypeId/displayName so + // callers (container_type_get, billing_check) read a populated id and name. + const raw = await graphRequestBeta( + "GET", + `/storage/fileStorage/containerTypes/${containerTypeId}`, + ); + return normalizeContainerType(raw); +} + +export async function updateContainerType( + containerTypeId: string, + update: Record, +): Promise { + // The beta Update fileStorageContainerType API accepts only name/settings/etag + // (the display name field is `name`, NOT `displayName`), and **etag is REQUIRED** + // for optimistic concurrency: it must equal the CURRENT server value from a + // fresh Get/Create — it is an included concurrency token, NOT a client-set + // field. Omitting it returns HTTP 400 "One of the provided arguments is not + // acceptable" (see the docs' "Update without ETag" example). Defensive + // hardening: always source the etag from a fresh Get and DROP any + // caller-supplied `etag` (a stale value would cause a 412 / lost update). + // Normalize the response (a PATCH may also return 204 No Content) so callers + // read a populated id/name. + const { etag: _ignoredCallerEtag, ...safeUpdate } = update; + void _ignoredCallerEtag; // intentionally discarded: never trust a caller etag + const body: Record = { ...safeUpdate }; + const current = await getContainerType(containerTypeId); + if (current.etag) body.etag = current.etag; + const raw = await graphRequestBeta( + "PATCH", + `/storage/fileStorage/containerTypes/${containerTypeId}`, + body, + ); + return normalizeContainerType(raw ?? {}); +} + +/** Delete a container type (owning-app token). Used by cleanup. */ +export async function deleteContainerType(containerTypeId: string): Promise { + await graphRequestBeta( + "DELETE", + `/storage/fileStorage/containerTypes/${containerTypeId}`, + ); +} + +/** Delete an Entra app registration (bootstrap token). Used by cleanup. */ +export async function deleteApplication( + appObjectId: string, + getToken: () => Promise, +): Promise { + await graphRequest( + "DELETE", + `/applications/${appObjectId}`, + undefined, + undefined, + getToken, + ); +} diff --git a/src/guest-advisory.test.ts b/src/guest-advisory.test.ts new file mode 100644 index 0000000..768703e --- /dev/null +++ b/src/guest-advisory.test.ts @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for the NON-BLOCKING guest (B2B) sign-in advisory helpers. + * + * These prove the `#EXT#` guest heuristic and that the advisory is purely + * informational — it returns a note for a guest, nothing for a member, and + * never blocks/rejects. (PR #3 review — WI-11 guest handling.) + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; +import { isLikelyGuestUpn, guestSignInAdvisory } from "./guest-advisory.js"; + +describe("isLikelyGuestUpn", () => { + it("is true for a B2B guest UPN carrying the #EXT# marker", () => { + expect(isLikelyGuestUpn("alice_corp.com#EXT#@resourcetenant.onmicrosoft.com")).toBe(true); + }); + + it("matches #EXT# case-insensitively", () => { + expect(isLikelyGuestUpn("bob_corp.com#ext#@resourcetenant.onmicrosoft.com")).toBe(true); + expect(isLikelyGuestUpn("bob_corp.com#Ext#@resourcetenant.onmicrosoft.com")).toBe(true); + }); + + it("is false for a normal member UPN", () => { + expect(isLikelyGuestUpn("alice@contoso.com")).toBe(false); + expect(isLikelyGuestUpn("dev@x.com")).toBe(false); + }); + + it("is false for undefined / empty input", () => { + expect(isLikelyGuestUpn(undefined)).toBe(false); + expect(isLikelyGuestUpn("")).toBe(false); + }); +}); + +describe("guestSignInAdvisory", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns an informational note mentioning guest + member for a guest identity", () => { + const note = guestSignInAdvisory("alice_corp.com#EXT#@resourcetenant.onmicrosoft.com"); + expect(note).not.toBe(""); + expect(note).toContain("guest (B2B)"); + expect(note).toContain("member"); + // Informational, not an error marker. + expect(note).toContain("Heads-up"); + }); + + it("returns an empty string for a member identity (no note, non-blocking)", () => { + expect(guestSignInAdvisory("alice@contoso.com")).toBe(""); + expect(guestSignInAdvisory(undefined)).toBe(""); + }); + + it("logs a single non-blocking warning for a guest and nothing for a member", () => { + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + guestSignInAdvisory("alice_corp.com#EXT#@resourcetenant.onmicrosoft.com"); + expect(errSpy).toHaveBeenCalledTimes(1); + expect(String(errSpy.mock.calls[0][0])).toContain("[warn]"); + + errSpy.mockClear(); + guestSignInAdvisory("alice@contoso.com"); + expect(errSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/guest-advisory.ts b/src/guest-advisory.ts new file mode 100644 index 0000000..54b3083 --- /dev/null +++ b/src/guest-advisory.ts @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * NON-BLOCKING guest (B2B) sign-in advisories for control-plane operations. + * + * Guest/B2B sign-in is a fully supported, legitimate scenario (a corporate + * identity invited into an SPE test/resource tenant). The authoritative + * wrong-tenant protection lives in auth.ts and keys on the ISSUED TOKEN's tenant + * (`isWrongTenantToken`) — NOT on guest status. Nothing here gates, rejects, or + * throws: these helpers only surface an informational heads-up, because guest + * accounts frequently lack permission to create Entra apps or own container + * types, so a guest whose control-plane call later fails with an authorization + * error gets an actionable hint up front. (PR #3 review.) + */ + +import { createLogger } from "./logger.js"; + +// Reuse the auth-domain stderr format ([] [Auth] [] ) — +// guest sign-in is an identity concern — WITHOUT importing auth.ts, so the +// sensitive token/account-selection logic there stays untouched. +const advisoryLogger = createLogger("Auth", { severity: true }); + +/** + * Heuristic: does this UPN look like a B2B **guest** identity? + * + * When an account is invited as a guest into a resource tenant, its UPN *within + * that tenant* carries the `#EXT#` marker, e.g. + * `alice_corp.com#EXT#@resourcetenant.onmicrosoft.com`. We match `#EXT#` + * case-insensitively. + * + * This is a NON-AUTHORITATIVE hint used only to surface a non-blocking heads-up; + * it never gates, rejects, or changes control flow. Guest/B2B sign-in remains + * fully supported (see auth.ts `getCachedAccount` / `isWrongTenantToken`, which + * verify the issued token's tenant, not guest status). + * + * Exported for unit testing. + */ +export function isLikelyGuestUpn(upn?: string): boolean { + return !!upn && /#EXT#/i.test(upn); +} + +/** + * The human-readable guest advisory text. Kept as a single constant so the + * stderr log line and the user-visible note stay in sync. + */ +const GUEST_SIGN_IN_ADVISORY = + "You appear to be signed in with a **guest (B2B)** account. Guest accounts often lack " + + "permission to create Entra apps or own SharePoint Embedded container types. If a control-plane " + + "step fails with an authorization error, sign in with a **member** account of the target tenant " + + "and retry."; + +/** + * NON-BLOCKING guest sign-in advisory for control-plane operations. + * + * When the signed-in control-plane identity (the Azure CLI UPN) looks like a + * B2B guest, emit a one-time warning to stderr and return an informational note + * (a Markdown blockquote) for the calling tool to append to its user-visible + * output. For a member identity it returns `""` and logs nothing. + * + * This is purely advisory: it does NOT block, reject, throw, or alter control + * flow — guest/B2B sign-in remains fully supported. + * + * @param username The Azure CLI UPN from `getSignedInIdentity()`. + * @returns A Markdown note to append to tool output, or `""` for a member. + */ +export function guestSignInAdvisory(username?: string): string { + if (!isLikelyGuestUpn(username)) return ""; + advisoryLogger.warn( + `Signed-in control-plane identity ${username} looks like a B2B guest — guest accounts often ` + + "lack permission to create Entra apps or own container types. If control-plane calls fail with " + + "authorization errors, sign in with a member account of the target tenant. (Non-blocking heads-up.)", + ); + return `\n\n> \u2139\uFE0F **Heads-up:** ${GUEST_SIGN_IN_ADVISORY}`; +} diff --git a/src/http-client.ts b/src/http-client.ts new file mode 100644 index 0000000..c0fd891 --- /dev/null +++ b/src/http-client.ts @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Outbound HTTP helper for the SPE MCP Server's calls to Microsoft Graph and + * Azure Resource Manager (ARM) REST APIs — parsing of the `Retry-After` header + * for throttling/backoff (429 / 5xx) handling. + * + * This is NOT an MCP transport. The MCP transport for this server is stdio + * (`StdioServerTransport`); MCP transport (how the client talks to this server) + * and outbound HTTPS (how this server talks to Graph/ARM) are orthogonal — a + * stdio MCP server still makes outbound HTTPS calls to Microsoft cloud APIs. + */ + +export function parseRetryAfterMs(value: string | null): number | undefined { + if (!value) return undefined; + const seconds = Number.parseInt(value, 10); + if (Number.isFinite(seconds)) return seconds * 1000; + const dateMs = Date.parse(value); + if (!Number.isFinite(dateMs)) return undefined; + return Math.max(0, dateMs - Date.now()); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..519f332 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,489 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * SPE MCP Server — main entry point. + * + * Architecture: + * 1. Connect transport first (so MCP `initialize` handshake succeeds immediately) + * 2. Initialize auth in background (non-blocking) + * 3. Tools array with { name, description, inputSchema, handler } + * 4. ListTools returns metadata only (no handler functions) + * 5. CallTool dispatches to handler by name + */ + +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + ListPromptsRequestSchema, + GetPromptRequestSchema, + ListResourcesRequestSchema, + ReadResourceRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import { initializeAuth, setAuthConfig } from "./auth.js"; +import { assertAzCli, getSignedInIdentity } from "./bootstrap.js"; +import { byoAppStartupNote, azLoginNotSignedInMessage } from "./onboarding-messages.js"; +import { readState } from "./state.js"; +import { USER_AGENT } from "./user-agent.js"; +import { PACKAGE_VERSION } from "./version.js"; +import type { McpTool, ServerConfig } from "./types.js"; +import { createLogger } from "./logger.js"; +import { redact } from "./logging.js"; +import { fail } from "./responses.js"; +import { toSafeError } from "./errors.js"; +import { + buildToolPolicy, + isToolListed, + checkToolCallAllowed, + type ResolvedToolPolicy, +} from "./policy.js"; +import { withConfirmation } from "./tools/confirmation.js"; +import { wireElicitation, type ElicitationCapableServer } from "./elicitation.js"; +// Status / diagnostics +import { statusTool } from "./tools/status.js"; +// Container Type tools +import { createContainerTypeTool } from "./tools/create-container-type.js"; +import { listContainerTypesTool } from "./tools/list-container-types.js"; +import { createAppTool } from "./tools/create-app.js"; +import { registerContainerTypeTool } from "./tools/register-container-type.js"; +import { getContainerTypeTool, updateContainerTypeTool, deleteContainerTypeTool } from "./tools/container-type-crud.js"; +import { grantContainerTypeOwnerTool, listContainerTypeOwnersTool, revokeContainerTypeOwnerTool } from "./tools/container-type-permissions.js"; +import { addContainerTypeAppGrantTool, listContainerTypeAppGrantsTool, removeContainerTypeAppGrantTool } from "./tools/container-type-app-grants.js"; +import { + getContainerTypeRegistrationTool, + listContainerTypeRegistrationsTool, + deleteContainerTypeRegistrationTool, +} from "./tools/container-type-registration.js"; +import { createContainerTool } from "./tools/create-container.js"; +// Container Management tools +import { listContainersTool } from "./tools/list-containers.js"; +import { getContainerTool } from "./tools/get-container.js"; +import { updateContainerTool } from "./tools/update-container.js"; +import { managePermissionsTool } from "./tools/manage-permissions.js"; +import { archiveRestoreTool } from "./tools/archive-restore.js"; +import { deleteContainerTool } from "./tools/delete-container.js"; +import { listDeletedContainersTool } from "./tools/list-deleted-containers.js"; +// Content Operations tools +import { uploadFileTool } from "./tools/upload-file.js"; +import { createFolderTool } from "./tools/create-folder.js"; +import { searchContentTool } from "./tools/search-content.js"; +import { previewFileTool } from "./tools/preview-file.js"; +import { manageSharingTool } from "./tools/manage-sharing.js"; +// Billing tools +import { checkBillingTool } from "./tools/check-billing.js"; +import { setupBillingTool } from "./tools/setup-billing.js"; +import { listSubscriptionsTool, listResourceGroupsTool } from "./tools/list-azure.js"; +// Documentation tools (proxy to Microsoft Learn MCP) +import { searchDocsTool, fetchDocTool } from "./tools/search-docs.js"; +// Orchestration + config + Azure list + scaffold/run/deploy + content + cleanup +import { provisionTool } from "./tools/provision.js"; +import { hydrateConfigTool } from "./tools/hydrate-config.js"; +import { scaffoldTool } from "./tools/scaffold.js"; +import { seedSampleDataTool } from "./tools/seed-sample-data.js"; +import { runLocalTool } from "./tools/run-local.js"; +import { deployAzureTool } from "./tools/deploy-azure.js"; +import { grantContentAccessTool, revokeContentAccessTool, withContentAccess } from "./tools/content-access.js"; +import { cleanupTool } from "./tools/cleanup.js"; +import { SPE_PROMPTS, getPromptMessages } from "./prompts.js"; +import { SPE_RESOURCES, readResource } from "./resources.js"; +import { SPE_SERVER_INSTRUCTIONS } from "./server-instructions.js"; + +// Derived from package.json (single source of truth) — see src/version.ts. +const SERVER_VERSION = PACKAGE_VERSION; + +// All server diagnostics go to **stderr**, never stdout. For a stdio MCP +// server, stdout is the JSON-RPC protocol channel — writing logs there would +// corrupt the message stream and break the client. `console.error` (stderr) is +// therefore the correct, intentional sink for every log and status line below. +// +// Backed by the shared stderr logger (src/logger.ts). `stringifyData: true` +// preserves the exact `[] [MCP] ` format — with any `data` +// JSON-stringified as a second argument — that this entry point has always +// emitted. +const log = createLogger("MCP", { stringifyData: true }).log; + +// ─── Tool Registry ────────────────────────────────────────────────────────── + +const TOOLS: McpTool[] = [ + // Status / diagnostics + statusTool, + // Provisioning (owning app → container type → register → container) + createAppTool, + provisionTool, + // Container Types + listContainerTypesTool, + createContainerTypeTool, + registerContainerTypeTool, + getContainerTypeTool, + updateContainerTypeTool, + deleteContainerTypeTool, + // Container Type permissions (owner role — beta; enables PCA container creation) + grantContainerTypeOwnerTool, + listContainerTypeOwnersTool, + revokeContainerTypeOwnerTool, + // Container Type registration — application permission grants (v1.0; authorize consuming apps) + addContainerTypeAppGrantTool, + listContainerTypeAppGrantsTool, + removeContainerTypeAppGrantTool, + // Container Type registration RECORD — CRUDL on the registration itself (v1.0). + // CRUD map (the *registration* is the tenant↔containerType binding): + // Create → container_type_register (registerContainerTypeTool, above) + // Read → container_type_registration_get / _list (below) + // Update → container_type_app_grant_add (change the app permission grants; + // registration has no other mutable fields exposed here) + // Delete → container_type_registration_delete (below) + // There is intentionally no standalone "registration create/update" tool: PUT + // registration IS the create, and grant add/remove is the only update surface. + // Deleting the registration is REQUIRED before a container type can be deleted. + // The delete tool self-gates on `confirm` and shows a blocker-aware preview, so + // it is intentionally NOT wrapped with withConfirmation (which would suppress + // that richer preview). + getContainerTypeRegistrationTool, + listContainerTypeRegistrationsTool, + deleteContainerTypeRegistrationTool, + // Container Management + listContainersTool, + getContainerTool, + createContainerTool, + updateContainerTool, + managePermissionsTool, + archiveRestoreTool, + // container_delete self-guards permanent-delete; the middleware enforces the + // same gate uniformly at registration (SAFE-002). Both produce an identical + // CONFIRMATION_REQUIRED, so there is no double-prompt. + withConfirmation(deleteContainerTool, { actions: ["permanent-delete"] }), + // Recycle bin — list soft-deleted containers (blockers for registration delete) + listDeletedContainersTool, + // Content Operations (content-plane: gated behind content-access opt-in) + withContentAccess(uploadFileTool), + withContentAccess(createFolderTool), + withContentAccess(searchContentTool), + withContentAccess(previewFileTool), + withContentAccess(manageSharingTool), + // Billing + checkBillingTool, + setupBillingTool, + listSubscriptionsTool, + listResourceGroupsTool, + // Config + scaffold + run + deploy + hydrateConfigTool, + scaffoldTool, + withContentAccess(seedSampleDataTool), + runLocalTool, + deployAzureTool, + // Content plane (opt-in, step-up consent) + grantContentAccessTool, + revokeContentAccessTool, + // Hardening + cleanupTool, + // Documentation (grounded via Microsoft Learn MCP) + searchDocsTool, + fetchDocTool, +]; + +/** + * Map an internal tool's `annotations` onto the subset of MCP annotation *hints* + * the SDK serializes into the ListTools response (`readOnlyHint`, + * `destructiveHint`, `idempotentHint`). Only annotations that are explicitly set + * on the tool are forwarded; when the tool has none, `undefined` is returned so + * the `annotations` field is omitted from the wire response entirely. + */ +function toMcpAnnotations(tool: McpTool): Record | undefined { + const annotations: Record = {}; + if (tool.annotations?.readOnly !== undefined) annotations.readOnlyHint = tool.annotations.readOnly; + if (tool.annotations?.destructive !== undefined) annotations.destructiveHint = tool.annotations.destructive; + if (tool.annotations?.idempotent !== undefined) annotations.idempotentHint = tool.annotations.idempotent; + return Object.keys(annotations).length > 0 ? annotations : undefined; +} + +function withDuration(structuredContent: unknown, durationMs: number): unknown { + if (structuredContent === undefined) return undefined; + if (structuredContent && typeof structuredContent === "object" && !Array.isArray(structuredContent)) { + return { ...structuredContent as Record, durationMs }; + } + return { data: structuredContent, durationMs }; +} + +function validateArgs(args: Record, tool: McpTool): { ok: true; args: Record } | { ok: false; result: ReturnType } { + if (tool.validateArgs) { + return { ok: true, args: tool.validateArgs(args) }; + } + + const missing = (tool.inputSchema.required ?? []).filter((key) => args[key] === undefined || args[key] === null); + if (missing.length > 0) { + return { + ok: false, + result: fail( + "INVALID_ARGS", + `Missing required argument${missing.length === 1 ? "" : "s"}: ${missing.join(", ")}`, + "Provide all required fields from the tool input schema.", + ), + }; + } + + return { ok: true, args }; +} + +function toListToolEntry(tool: McpTool) { + return { + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + ...(toMcpAnnotations(tool) ? { annotations: toMcpAnnotations(tool) } : {}), + }; +} + +/** + * Active tool policy (SAFE-003 read-only mode / SAFE-004 tool allowlist). `null` + * means no restriction (every tool advertised and callable). Set once in + * startServer(). See docs/SECURITY-CONTROLS.md for the control-code legend. + */ +let activePolicy: ResolvedToolPolicy | null = null; + +/** Tools advertised to the client, filtered by the active policy. */ +function listVisibleTools() { + return TOOLS.filter((tool) => isToolListed(tool, activePolicy)).map(toListToolEntry); +} + +// ─── Server Setup ─────────────────────────────────────────────────────────── + +const server = new Server( + { name: "spe-mcp-server", version: SERVER_VERSION }, + { + capabilities: { tools: {}, prompts: {}, resources: {} }, + // Domain primer returned in the MCP `initialize` result so clients can prime + // the model with SPE's mental model + first-request routing before any tool + // is called. See src/server-instructions.ts. + instructions: SPE_SERVER_INSTRUCTIONS, + }, +); + +// Wire the server so tool handlers can issue native MCP elicitation +// (`elicitation/create`) prompts. Client capabilities are queried lazily at +// elicit time — always post-`initialize` — so this ordering is safe. Falls back +// to agent-guided text asks when the client does not support elicitation. +// +// The SDK's `elicitInput` param is a Zod-derived union whose `requestedSchema` +// is stricter than the minimal `ElicitationCapableServer` shape our tool +// handlers consume. The form-mode request we build at runtime (a restricted +// object schema with `oneOf`/string primitives) satisfies the SDK's validator, +// so we bridge the purely-structural gap with a documented cast here — the one +// boundary where the concrete `Server` meets our interface — rather than +// importing the SDK request type throughout the codebase. +wireElicitation(server as unknown as ElicitationCapableServer); + +server.setRequestHandler(ListToolsRequestSchema, async () => { + const tools = listVisibleTools(); + log(`ListTools request received`, { count: tools.length }); + return { tools }; +}); + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + const safeArgs = args && typeof args === "object" ? args as Record : {}; + log(`Tool call received: ${name}`, { tool: name, argKeys: Object.keys(safeArgs), args: redact(safeArgs) }); + const startTime = Date.now(); + + try { + const tool = TOOLS.find((t) => t.name === name); + if (!tool) { + // Reachable guard, not dead code: this single handler receives EVERY + // tools/call the MCP SDK dispatches, so a client can request a name that + // was never registered (or one hidden by policy). Fail closed with a + // structured UNKNOWN_TOOL error rather than throwing. Exercised by the + // "returns isError + UNKNOWN_TOOL for an unknown tool" protocol e2e test. + log(`Unknown tool: ${name}`); + const result = fail("UNKNOWN_TOOL", `Unknown tool: ${name}`); + return { + content: result.content.map((c) => ({ type: "text" as const, text: c.text })), + isError: result.isError, + structuredContent: withDuration(result.structuredContent, Date.now() - startTime), + } as const; + } + + log(`Executing ${name}`, { tool: name, argKeys: Object.keys(safeArgs), args: redact(safeArgs) }); + + // SAFE-003 (read-only mode) / SAFE-004 (tool allowlist): enforce these BEFORE + // validating arguments or invoking the handler, so a denied call never + // touches Graph/Azure. + const denied = checkToolCallAllowed(tool, activePolicy); + if (denied) { + const durationMs = Date.now() - startTime; + log(`${name} blocked by tool policy in ${durationMs}ms`); + return { + content: denied.content.map((c) => ({ type: "text" as const, text: c.text })), + isError: true, + structuredContent: withDuration(denied.structuredContent, durationMs), + } as const; + } + + const validated = validateArgs(safeArgs, tool); + if (!validated.ok) { + const durationMs = Date.now() - startTime; + log(`${name} rejected invalid arguments in ${durationMs}ms`); + return { + content: validated.result.content.map((c) => ({ type: "text" as const, text: c.text })), + isError: true, + structuredContent: withDuration(validated.result.structuredContent, durationMs), + } as const; + } + + const result = await tool.handler(validated.args); + const durationMs = Date.now() - startTime; + log(`${name} completed in ${durationMs}ms`); + return { + content: result.content.map((c) => ({ type: "text" as const, text: c.text })), + isError: result.isError, + structuredContent: withDuration(result.structuredContent, durationMs), + } as const; + } catch (error) { + const safeError = toSafeError(error); + log(`Tool error (${safeError.correlationId})`, { + tool: name, + argKeys: Object.keys(safeArgs), + args: redact(safeArgs), + error: error instanceof Error + ? { name: error.name, message: error.message, stack: error.stack } + : String(error), + }); + const result = fail(safeError.code, `${safeError.message} (correlationId: ${safeError.correlationId})`, safeError.suggestion); + return { + content: result.content.map((c) => ({ type: "text" as const, text: c.text })), + isError: result.isError, + structuredContent: withDuration(result.structuredContent, Date.now() - startTime), + } as const; + } +}); + +server.onerror = (error: Error) => { + log("Protocol/transport error", error.message); +}; + +// ─── Prompts ──────────────────────────────────────────────────────────────── + +server.setRequestHandler(ListPromptsRequestSchema, async () => { + log("ListPrompts request received"); + return { prompts: SPE_PROMPTS }; +}); + +server.setRequestHandler(GetPromptRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + log(`GetPrompt request: ${name}`); + return getPromptMessages(name, args ?? {}); +}); + +// ─── Resources (reference architectures) ──────────────────────────────────── + +server.setRequestHandler(ListResourcesRequestSchema, async () => { + log("ListResources request received"); + return { resources: SPE_RESOURCES }; +}); + +server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + const { uri } = request.params; + log(`ReadResource request: ${uri}`); + return readResource(uri); +}); + +// ─── Start ────────────────────────────────────────────────────────────────── + +export async function startServer(config: ServerConfig) { + log("Starting SharePoint Embedded MCP Server..."); + + // SAFE-003 (read-only mode) / SAFE-004 (tool allowlist): build the tool policy + // once from config (read-only mode and/or an allowlist profile or CSV). When + // neither is set, `activePolicy` stays null and every tool is advertised and + // callable. + activePolicy = buildToolPolicy(TOOLS, config.readOnly ?? false, config.tools); + if (activePolicy.readOnly || activePolicy.allow) { + const parts: string[] = []; + if (activePolicy.readOnly) parts.push("read-only mode (mutating tools rejected)"); + if (config.tools) parts.push(`tool allowlist '${config.tools}'`); + const visible = TOOLS.filter((t) => isToolListed(t, activePolicy)).length; + console.error( + `[SPE MCP Server] Tool policy active: ${parts.join(" + ") || "restricted"} — ${visible}/${TOOLS.length} tools exposed`, + ); + } + + // Stamp outbound `az` / `azd` traffic for aggregate attribution. The Azure + // CLI and Developer CLI append AZURE_HTTP_USER_AGENT to their User-Agent on + // every ARM request. Respect any value the user already set. + if (!process.env.AZURE_HTTP_USER_AGENT) { + process.env.AZURE_HTTP_USER_AGENT = USER_AGENT; + } + + // Connect transport first so MCP `initialize` handshake works immediately + const transport = new StdioServerTransport(); + try { + await server.connect(transport); + } catch (error) { + log("Failed to connect transport", error instanceof Error ? error.message : String(error)); + throw error; + } + log("Server connected and ready for requests"); + // User-facing startup status. Emitted on stderr (not stdout) for the same + // reason as log() above: stdout carries the MCP JSON-RPC protocol only. + console.error("[SPE MCP Server] Started and ready for connections"); + + if (config.clientId) { + // Bring-your-own-app mode: the caller has ALREADY pre-created an owning Entra + // application (its client id supplied via --client-id / SPE_CLIENT_ID) and + // wants the server to sign in AS that app. So we skip bootstrap app-creation + // entirely and go straight to MSAL. Resolve the tenant (discover from az when + // not supplied) and initialize auth. + let tenantId = config.tenantId; + if (!tenantId) { + const identity = await getSignedInIdentity().catch(() => null); + tenantId = identity?.tenantId ?? "organizations"; + } + setAuthConfig({ clientId: config.clientId, tenantId }); + // Make the bring-your-own-app scenario explicit at startup (PR #3 review): + // no owning app is provisioned — we authenticate as the pre-created app. + console.error(byoAppStartupNote(config.clientId, tenantId)); + log("Initializing authentication (bring-your-own pre-created owning app)..."); + try { + await initializeAuth(); + log("Authentication ready"); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + log(`Auth initialization failed — will retry on first tool call: ${msg}`); + console.error("[SPE MCP Server] Auth failed at startup. Will retry when a tool is called."); + } + } else { + // Bootstrap mode (default): no owning app yet. Control-plane operations use + // the Azure CLI bootstrap token; SPE provisioning creates the owning app on + // demand (Phase 1). Verify az is available and report the signed-in identity. + log("Bootstrap mode — no --client-id; using Azure CLI for the control plane"); + // Prime MSAL auth from persisted provisioning state so owning-app SPE/Graph + // calls work regardless of which tool runs first. Without this, read tools + // that don't call setAuthConfig themselves (container_list, container_get, + // billing_check, container_type_list, content_*) throw "Auth not configured" + // when one of them is the first Graph call of the session. + const persisted = readState(); + if (persisted.appId && persisted.tenantId) { + setAuthConfig({ clientId: persisted.appId, tenantId: persisted.tenantId }); + log(`Primed auth from persisted state (owning app ${persisted.appId}, tenant ${persisted.tenantId})`); + // Priming only wires MSAL — it does NOT confirm the active context (r-appgate). + // A fresh process is UNconfirmed until the user answers the always-ask prompt, + // so we intentionally do not stamp confirmedSessionId here. + } + try { + await assertAzCli(); + const identity = await getSignedInIdentity(); + if (identity) { + console.error( + `[SPE MCP Server] Bootstrap ready — signed in as ${identity.username} (tenant ${identity.tenantId})`, + ); + } else { + // Not signed in: tell the user to sign in AND to restart the server + // afterward, since auth/session state is stamped at startup (PR #3 review). + console.error(azLoginNotSignedInMessage()); + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`[SPE MCP Server] ${msg}`); + } + } +} diff --git a/src/logger.test.ts b/src/logger.test.ts new file mode 100644 index 0000000..e9e8bbe --- /dev/null +++ b/src/logger.test.ts @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for the shared stderr logger (src/logger.ts). + * + * These lock the EXACT output format so the WI-15 consolidation of the two + * previously-duplicated `log()` helpers (auth + index) stays behavior- + * preserving. Both variants must remain byte-for-byte identical to what those + * modules emitted before the refactor: + * - index style: `[] [MCP] ` (+ JSON.stringify(data)) + * - auth style: `[] [Auth] [] ` (+ raw data) + * Every line MUST go to stderr (console.error), never stdout. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createLogger } from "./logger.js"; + +// A fixed instant so the leading `[]` timestamp is deterministic. +const FIXED_ISO = "2020-01-02T03:04:05.678Z"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +function spyConsoleError() { + return vi.spyOn(console, "error").mockImplementation(() => {}); +} + +describe("createLogger — index (MCP) style", () => { + it("emits `[] [MCP] ` on stderr with no level tag", () => { + vi.useFakeTimers().setSystemTime(new Date(FIXED_ISO)); + const spy = spyConsoleError(); + + createLogger("MCP", { stringifyData: true }).log("hello world"); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(`[${FIXED_ISO}] [MCP] hello world`); + }); + + it("JSON.stringifies the data argument as a second console.error arg", () => { + vi.useFakeTimers().setSystemTime(new Date(FIXED_ISO)); + const spy = spyConsoleError(); + + createLogger("MCP", { stringifyData: true }).log("with data", { a: 1, b: "x" }); + + expect(spy).toHaveBeenCalledWith( + `[${FIXED_ISO}] [MCP] with data`, + JSON.stringify({ a: 1, b: "x" }), + ); + }); +}); + +describe("createLogger — auth (Auth) style", () => { + it("emits `[] [Auth] [] ` per severity", () => { + vi.useFakeTimers().setSystemTime(new Date(FIXED_ISO)); + const spy = spyConsoleError(); + + const logger = createLogger("Auth", { severity: true }); + logger.log("info line"); + logger.debug("debug line"); + logger.warn("warn line"); + logger.error("error line"); + + expect(spy.mock.calls).toEqual([ + [`[${FIXED_ISO}] [Auth] [info] info line`], + [`[${FIXED_ISO}] [Auth] [debug] debug line`], + [`[${FIXED_ISO}] [Auth] [warn] warn line`], + [`[${FIXED_ISO}] [Auth] [error] error line`], + ]); + }); + + it("passes the data argument through RAW (not stringified)", () => { + vi.useFakeTimers().setSystemTime(new Date(FIXED_ISO)); + const spy = spyConsoleError(); + + const err = new Error("boom"); + createLogger("Auth", { severity: true }).error("failed", err); + + expect(spy).toHaveBeenCalledWith(`[${FIXED_ISO}] [Auth] [error] failed`, err); + }); + + it("emit() honors the explicit level argument", () => { + vi.useFakeTimers().setSystemTime(new Date(FIXED_ISO)); + const spy = spyConsoleError(); + + createLogger("Auth", { severity: true }).emit("warn", "manual"); + + expect(spy).toHaveBeenCalledWith(`[${FIXED_ISO}] [Auth] [warn] manual`); + }); +}); diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..4ddd3dc --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Shared stderr logger for the SPE MCP Server. + * + * ALL diagnostics MUST go to **stderr** (never stdout): a stdio MCP server keeps + * stdout reserved for the JSON-RPC message stream, so any stray stdout write + * would corrupt the protocol channel. `console.error` (stderr) is therefore the + * intentional sink for every log line. + * + * This factory consolidates the two previously-duplicated `log()` helpers (auth + * and index) while preserving each caller's exact output format byte-for-byte: + * + * - index (`createLogger("MCP", { stringifyData: true })`): + * `[] [MCP] ` (+ `JSON.stringify(data)` as a second arg) + * - auth (`createLogger("Auth", { severity: true })`): + * `[] [Auth] [] ` (+ raw `data` as a second arg) + */ + +/** Log severity, ordered from most to least verbose. */ +export type LogSeverity = "debug" | "info" | "warn" | "error"; + +export interface LoggerOptions { + /** + * Emit an explicit `[]` tag after the prefix (auth-style). When false + * (the default) the level is omitted from the line entirely, matching the + * plain index-style format. + */ + severity?: boolean; + /** + * `JSON.stringify` the optional `data` argument before handing it to + * `console.error` (index-style). When false (the default) `data` is passed + * through unchanged (auth-style), letting the console format objects itself. + */ + stringifyData?: boolean; +} + +export interface Logger { + /** Emit a line at an explicit severity. */ + emit(level: LogSeverity, message: string, data?: unknown): void; + /** Default-severity (info) line. */ + log(message: string, data?: unknown): void; + /** Expected, handled flow — not actionable. */ + debug(message: string, data?: unknown): void; + /** Handled but noteworthy. */ + warn(message: string, data?: unknown): void; + /** Genuine, unexpected failure. */ + error(message: string, data?: unknown): void; +} + +/** + * Create a stderr logger with a fixed `prefix` (e.g. `"MCP"`, `"Auth"`). + * + * The emitted line is `[] `, where `` is + * `[]` by default or `[] []` when `severity` is enabled. + * When a `data` argument is provided it is passed as `console.error`'s second + * argument — either `JSON.stringify(data)` (`stringifyData`) or the raw value. + */ +export function createLogger(prefix: string, options: LoggerOptions = {}): Logger { + const { severity = false, stringifyData = false } = options; + + function emit(level: LogSeverity, message: string, data?: unknown): void { + const timestamp = new Date().toISOString(); + const tag = severity ? `[${prefix}] [${level}]` : `[${prefix}]`; + const line = `[${timestamp}] ${tag} ${message}`; + if (data !== undefined) { + console.error(line, stringifyData ? JSON.stringify(data) : data); + } else { + console.error(line); + } + } + + return { + emit, + log: (message, data) => emit("info", message, data), + debug: (message, data) => emit("debug", message, data), + warn: (message, data) => emit("warn", message, data), + error: (message, data) => emit("error", message, data), + }; +} diff --git a/src/logging.ts b/src/logging.ts new file mode 100644 index 0000000..d4b2c88 --- /dev/null +++ b/src/logging.ts @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +const SENSITIVE_KEY_RE = /token|secret|password|authorization|auth|code|content|bytes|upn|email|userprincipalname|displayname/i; +const MAX_STRING_LENGTH = 160; +const MAX_ARRAY_ITEMS = 5; +const MAX_OBJECT_KEYS = 20; +const MAX_DEPTH = 4; + +function truncateString(value: string): string { + if (value.length <= MAX_STRING_LENGTH) return value; + return `${value.slice(0, MAX_STRING_LENGTH)}…[truncated ${value.length - MAX_STRING_LENGTH} chars]`; +} + +function redactValue(value: unknown, depth: number): unknown { + if (typeof value === "string") return truncateString(value); + if (typeof value !== "object" || value === null) return value; + if (depth >= MAX_DEPTH) return "[truncated]"; + + if (Array.isArray(value)) { + const preview = value.slice(0, MAX_ARRAY_ITEMS).map((item) => redactValue(item, depth + 1)); + if (value.length > MAX_ARRAY_ITEMS) preview.push(`[+${value.length - MAX_ARRAY_ITEMS} more]`); + return preview; + } + + const output: Record = {}; + const entries = Object.entries(value as Record); + for (const [key, child] of entries.slice(0, MAX_OBJECT_KEYS)) { + output[key] = SENSITIVE_KEY_RE.test(key) ? "[redacted]" : redactValue(child, depth + 1); + } + if (entries.length > MAX_OBJECT_KEYS) { + output.__truncatedKeys = entries.length - MAX_OBJECT_KEYS; + } + return output; +} + +export function redact(value: unknown): { keys: string[]; preview: unknown } { + const keys = value && typeof value === "object" && !Array.isArray(value) + ? Object.keys(value as Record) + : []; + return { keys, preview: redactValue(value, 0) }; +} + diff --git a/src/onboarding-messages.test.ts b/src/onboarding-messages.test.ts new file mode 100644 index 0000000..bfe0341 --- /dev/null +++ b/src/onboarding-messages.test.ts @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for onboarding-messages.ts — the NON-BLOCKING onboarding/consent + * guidance strings (PR #3 review). These are pure functions: they only build + * text, so the tests assert URL shape, the admin/non-admin explanation, the + * missing-tenant fallback, and — critically — that no secret ever leaks into the + * admin-consent URL (only tenant id + public client id). + */ + +import { describe, it, expect } from "vitest"; +import { + adminConsentUrl, + adminConsentSection, + byoAppStartupNote, + azLoginNotSignedInMessage, +} from "./onboarding-messages.js"; + +describe("adminConsentUrl", () => { + it("builds the tenant-wide admin-consent URL from tenant id + client id", () => { + expect(adminConsentUrl("client-abc", "tenant-123")).toBe( + "https://login.microsoftonline.com/tenant-123/adminconsent?client_id=client-abc", + ); + }); + + it("uses the REAL tenant id (not `common`/`organizations`) when available", () => { + const url = adminConsentUrl("app-1", "00000000-0000-0000-0000-000000000009"); + expect(url).toContain("/00000000-0000-0000-0000-000000000009/adminconsent"); + expect(url).not.toContain("/common/"); + expect(url).not.toContain("/organizations/"); + }); + + it("falls back to `organizations` when the tenant id is missing or blank", () => { + expect(adminConsentUrl("app-1")).toBe( + "https://login.microsoftonline.com/organizations/adminconsent?client_id=app-1", + ); + expect(adminConsentUrl("app-1", "")).toContain("/organizations/adminconsent"); + expect(adminConsentUrl("app-1", " ")).toContain("/organizations/adminconsent"); + }); + + it("never includes a token/secret — only tenant id and public client id", () => { + const url = adminConsentUrl("public-client-id", "tenant-1"); + expect(url).not.toMatch(/secret/i); + expect(url).not.toMatch(/token/i); + expect(url).not.toMatch(/password/i); + // The only query parameter is client_id. + const query = url.split("?")[1] ?? ""; + expect(query).toBe("client_id=public-client-id"); + }); +}); + +describe("adminConsentSection", () => { + it("embeds the copy-paste admin-consent URL with the given tenant + client id", () => { + const section = adminConsentSection("client-abc", "tenant-123"); + expect(section).toContain( + "https://login.microsoftonline.com/tenant-123/adminconsent?client_id=client-abc", + ); + expect(section).toContain("Grant admin consent"); + }); + + it("explains BOTH the Global Admin (tenant-wide) and non-admin (forward the link) paths", () => { + const section = adminConsentSection("client-abc", "tenant-123"); + expect(section).toContain("Global Administrator"); + expect(section).toMatch(/entire tenant/i); + expect(section).toMatch(/NOT an admin/i); + expect(section).toMatch(/send it to your tenant admin/i); + }); + + it("is informational / non-blocking (states provisioning is not blocked on consent)", () => { + const section = adminConsentSection("client-abc", "tenant-123"); + expect(section).toMatch(/informational/i); + expect(section).toMatch(/not blocked on consent/i); + }); + + it("notes the fallback when the tenant id is unavailable", () => { + const section = adminConsentSection("client-abc"); + expect(section).toContain("/organizations/adminconsent"); + expect(section).toMatch(/tenant id was unavailable/i); + }); + + it("does not leak any secret into the rendered section", () => { + const section = adminConsentSection("public-client-id", "tenant-1"); + expect(section).not.toMatch(/client_secret/i); + expect(section).not.toMatch(/access_token/i); + }); +}); + +describe("byoAppStartupNote", () => { + it("clarifies the bring-your-own pre-created owning app scenario", () => { + const note = byoAppStartupNote("byo-client", "byo-tenant"); + expect(note).toContain("[SPE MCP Server]"); + expect(note).toMatch(/bring-your-own-app/i); + expect(note).toMatch(/pre-created owning app/i); + expect(note).toContain("byo-client"); + expect(note).toContain("byo-tenant"); + expect(note).toMatch(/no owning app will be provisioned/i); + }); +}); + +describe("azLoginNotSignedInMessage", () => { + it("tells the user to run az login AND restart the server afterward", () => { + const msg = azLoginNotSignedInMessage(); + expect(msg).toContain("az login"); + expect(msg).toMatch(/restart/i); + // Ties the restart to startup-stamped session/auth (re-primes on restart). + expect(msg).toMatch(/fresh session/i); + expect(msg).toMatch(/re-primes authentication/i); + }); +}); diff --git a/src/onboarding-messages.ts b/src/onboarding-messages.ts new file mode 100644 index 0000000..d862706 --- /dev/null +++ b/src/onboarding-messages.ts @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * NON-BLOCKING onboarding / consent guidance strings for the owning-app flow. + * + * These helpers only produce human-readable text — they never open a browser, + * block provisioning, throw, or change control flow. They exist so the + * user-visible copy for three onboarding moments lives in one tested place: + * + * 1. `adminConsentSection` — appended to `project_app_create` output so the + * caller gets a copy-paste **tenant-wide admin-consent** link right after + * the owning app is created (or reused) and its SPE permissions requested. + * The app's SPE permissions still need an admin to consent; a Global Admin + * can grant tenant-wide with the link, and a non-admin can forward it. + * 2. `byoAppStartupNote` — the startup line for the **bring-your-own-app** + * path (a pre-created owning app supplied via `--client-id`/`SPE_CLIENT_ID`). + * 3. `azLoginNotSignedInMessage` — the bootstrap-mode "not signed in" line, + * which now tells the user to **restart** the server after `az login` so the + * new sign-in is picked up (auth/session is stamped at startup). + * + * The admin-consent URL only ever contains a tenant id and a PUBLIC client id — + * never a token, secret, or credential. (PR #3 review.) + */ + +/** + * Microsoft identity platform **tenant-wide admin consent** endpoint. Hitting + * this URL (as an admin) grants the requested delegated/application permissions + * for the whole tenant in one step. See: + * https://learn.microsoft.com/entra/identity-platform/v2-admin-consent + */ +const LOGIN_AUTHORITY = "https://login.microsoftonline.com"; + +/** + * Tenant fallback used only when the real signed-in tenant id is genuinely + * unavailable. `organizations` targets "any work/school account" — a valid + * multi-tenant authority — so the link still works; we call out the fallback so + * the caller can substitute their exact tenant id/GUID for a tenant-scoped grant. + */ +const TENANT_FALLBACK = "organizations"; + +/** + * Build the tenant-wide admin-consent URL for an owning app. + * + * Prefers the REAL signed-in tenant id so consent is scoped to the caller's + * tenant; falls back to {@link TENANT_FALLBACK} only when the tenant id is + * missing/blank. The URL contains solely the tenant id and the app's PUBLIC + * client id — no secret is ever included. + * + * @param clientId The owning app's Application (client) ID. + * @param tenantId The signed-in tenant id/GUID. Falls back when missing. + * @returns `https://login.microsoftonline.com/{tenant}/adminconsent?client_id={clientId}` + */ +export function adminConsentUrl(clientId: string, tenantId?: string): string { + const tenant = tenantId && tenantId.trim() !== "" ? tenantId.trim() : TENANT_FALLBACK; + return `${LOGIN_AUTHORITY}/${tenant}/adminconsent?client_id=${clientId}`; +} + +/** + * The Markdown "Grant admin consent" section appended to owning-app tool output. + * + * Presents a copy-paste tenant-wide admin-consent link and explains both roles: + * a Global Administrator can grant consent for the entire tenant with the link, + * while a non-admin should forward the link to their tenant admin. Purely + * informational — it does NOT block, open a browser, or throw; the first SPE + * call still prompts for any consent that has not been granted yet. + * + * @param clientId The owning app's Application (client) ID (public). + * @param tenantId The signed-in tenant id/GUID (preferred); a fallback is noted. + * @returns A Markdown section to append to the tool's user-visible output. + */ +export function adminConsentSection(clientId: string, tenantId?: string): string { + const url = adminConsentUrl(clientId, tenantId); + const usedFallback = !(tenantId && tenantId.trim() !== ""); + const fallbackNote = usedFallback + ? "\n\n> ℹ️ The signed-in tenant id was unavailable, so this link targets " + + "`organizations` (any work/school account). Replace it with your tenant id/GUID " + + "for a tenant-scoped grant." + : ""; + return ( + "\n\n### Grant admin consent\n\n" + + "The owning app's SharePoint Embedded permissions must be **admin-consented** before the app " + + "can sign in. If consent has not already been granted for this app, open this **tenant-wide " + + "admin-consent** link (copy-paste):\n\n" + + `\`\`\`text\n${url}\n\`\`\`\n\n` + + "- **If you are a Global Administrator** (or Privileged Role Administrator), opening this link " + + "grants consent for the **entire tenant** in one step.\n" + + "- **If you are NOT an admin**, copy the link above and send it to your tenant admin so they can " + + "grant tenant-wide consent on your behalf.\n\n" + + "> This step is **informational** — provisioning is not blocked on consent. The first " + + "SharePoint Embedded call will still prompt for any consent that has not yet been granted." + + fallbackNote + ); +} + +/** + * Startup line for the **bring-your-own-app** path: the caller has already + * pre-created an owning Entra application (its client id supplied via + * `--client-id` / `SPE_CLIENT_ID`) and wants the server to sign in AS that app, + * so no owning app is provisioned. Emitted on stderr at startup. + * + * @param clientId The pre-created owning app's client id. + * @param tenantId The resolved tenant id. + * @returns A single stderr status line. + */ +export function byoAppStartupNote(clientId: string, tenantId: string): string { + return ( + `[SPE MCP Server] Bring-your-own-app mode — signing in as your pre-created owning app ` + + `${clientId} (tenant ${tenantId}). No owning app will be provisioned; the server uses this app for ` + + `all SharePoint Embedded operations.` + ); +} + +/** + * Bootstrap-mode "Azure CLI installed but not signed in" line, extended with + * restart guidance. Auth and session state are stamped at server startup, so + * after `az login` completes the user must **restart** the MCP server for the + * new sign-in to take effect — a restart begins a fresh session and re-primes + * authentication. + * + * @returns The full stderr status line including restart guidance. + */ +export function azLoginNotSignedInMessage(): string { + return ( + "[SPE MCP Server] Azure CLI installed but not signed in. Run `az login --allow-no-subscriptions`, " + + "then RESTART the MCP server so it picks up your new sign-in — auth and session state are stamped at " + + "startup, so a restart begins a fresh session and re-primes authentication." + ); +} diff --git a/src/packaging.test.ts b/src/packaging.test.ts new file mode 100644 index 0000000..82578b6 --- /dev/null +++ b/src/packaging.test.ts @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Packaging / OSS-compliance regression tests. + * + * Each assertion below encodes an OSS pre-publish acceptance criterion: + * - MIT LICENSE present and shipped + * - publish intent decided (publishConfig.access) + * - THIRD-PARTY-NOTICES generated and shipped + * - complete package metadata (repository/bugs/homepage/author/keywords) + * - no deprecated uuid@8 in the resolved tree (overrides pin >= 11) + * + * These are intentionally filesystem/manifest assertions (not unit logic) so a + * regression that drops the LICENSE, weakens metadata, or reintroduces uuid@8 + * fails CI. + */ +import { describe, it, expect } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); + +function readJson(rel: string): Record { + return JSON.parse(readFileSync(join(pkgRoot, rel), "utf8")); +} + +const pkg = readJson("package.json"); + +describe("packaging: MIT LICENSE", () => { + it("declares the MIT license", () => { + expect(pkg.license).toBe("MIT"); + }); + + it("ships a LICENSE file with Microsoft copyright", () => { + const licensePath = join(pkgRoot, "LICENSE"); + expect(existsSync(licensePath)).toBe(true); + const text = readFileSync(licensePath, "utf8"); + expect(text).toContain("MIT License"); + expect(text).toContain("Microsoft Corporation"); + }); + + it("includes LICENSE in the published files allow-list", () => { + expect(pkg.files).toContain("LICENSE"); + }); +}); + +describe("packaging: publish intent decided", () => { + it("declares an explicit public publish access", () => { + expect(pkg.publishConfig).toBeDefined(); + expect(pkg.publishConfig.access).toBe("public"); + }); +}); + +describe("packaging: THIRD-PARTY-NOTICES", () => { + const noticesPath = join(pkgRoot, "THIRD-PARTY-NOTICES"); + + it("exists and is non-trivial", () => { + expect(existsSync(noticesPath)).toBe(true); + expect(readFileSync(noticesPath, "utf8").length).toBeGreaterThan(200); + }); + + it("is included in the published files allow-list", () => { + expect(pkg.files).toContain("THIRD-PARTY-NOTICES"); + }); + + it("attributes every direct production dependency", () => { + const notices = readFileSync(noticesPath, "utf8"); + for (const dep of Object.keys(pkg.dependencies ?? {})) { + expect(notices, `missing attribution for ${dep}`).toContain(dep); + } + }); +}); + +describe("packaging: complete metadata", () => { + it("has repository with url", () => { + expect(pkg.repository).toBeDefined(); + expect(typeof pkg.repository.url).toBe("string"); + expect(pkg.repository.url.length).toBeGreaterThan(0); + }); + + it("has bugs, homepage and author", () => { + expect(pkg.bugs?.url ?? pkg.bugs).toBeTruthy(); + expect(pkg.homepage).toBeTruthy(); + expect(pkg.author).toBeTruthy(); + }); + + it("has meaningful keywords", () => { + expect(Array.isArray(pkg.keywords)).toBe(true); + expect(pkg.keywords.length).toBeGreaterThanOrEqual(3); + }); +}); + +describe("dependency hygiene: no deprecated uuid@8", () => { + const major = (v: string): number => { + const m = String(v).match(/\d+/); + return m ? parseInt(m[0], 10) : NaN; + }; + + it("pins a supported uuid (>= 11) via overrides under @azure/msal-node", () => { + const override = pkg.overrides?.["@azure/msal-node"]?.uuid ?? pkg.overrides?.uuid; + expect(override, "expected an overrides pin for uuid").toBeTruthy(); + expect(major(override)).toBeGreaterThanOrEqual(11); + }); + + it("resolves no uuid@8.x anywhere in the lockfile", () => { + const lock = readJson("package-lock.json"); + const offenders: string[] = []; + for (const [path, meta] of Object.entries<{ version?: string }>(lock.packages ?? {})) { + if (/(^|\/)node_modules\/uuid$/.test(path) && meta?.version) { + if (major(meta.version) < 11) offenders.push(`${path}@${meta.version}`); + } + } + expect(offenders, `deprecated uuid found: ${offenders.join(", ")}`).toHaveLength(0); + }); +}); diff --git a/src/paths.test.ts b/src/paths.test.ts new file mode 100644 index 0000000..91d5e7d --- /dev/null +++ b/src/paths.test.ts @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join, normalize } from "node:path"; +import { + resolveDataDir, + setDataDirOverride, + getDataDir, + getStateFile, + getCacheDir, + getCacheFile, + getLegacyCacheFile, + sanitizeForFilename, + __testing, +} from "./paths.js"; + +const DEFAULT = normalize(join(homedir(), ".spe-mcp")); + +describe("paths — data directory resolution", () => { + const savedEnv = process.env.SPE_DATA_DIR; + + beforeEach(() => { + // Each test starts from a clean slate: no memoized dir, no env override. + __testing.reset(); + delete process.env.SPE_DATA_DIR; + }); + + afterEach(() => { + __testing.reset(); + if (savedEnv === undefined) delete process.env.SPE_DATA_DIR; + else process.env.SPE_DATA_DIR = savedEnv; + }); + + it("defaults to ~/.spe-mcp (byte-identical to the legacy hardcoded path)", () => { + expect(getDataDir()).toBe(DEFAULT); + expect(getStateFile()).toBe(join(DEFAULT, "state.json")); + expect(getCacheDir()).toBe(DEFAULT); + }); + + it("resolveDataDir(undefined/empty/whitespace) returns the default", () => { + expect(resolveDataDir()).toBe(DEFAULT); + expect(resolveDataDir("")).toBe(DEFAULT); + expect(resolveDataDir(" ")).toBe(DEFAULT); + }); + + it("honors an explicit override (flag) via setDataDirOverride", () => { + const dir = mkdtempSync(join(tmpdir(), "spe-mcp-paths-A-")); + try { + const resolved = setDataDirOverride(dir); + expect(resolved).toBe(normalize(dir)); + expect(getDataDir()).toBe(normalize(dir)); + expect(getStateFile()).toBe(join(normalize(dir), "state.json")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("honors SPE_DATA_DIR env when no explicit override is set", () => { + const dir = mkdtempSync(join(tmpdir(), "spe-mcp-paths-E-")); + try { + process.env.SPE_DATA_DIR = dir; + expect(getDataDir()).toBe(normalize(dir)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("gives the explicit override precedence over the env var (flag > env)", () => { + const envDir = mkdtempSync(join(tmpdir(), "spe-mcp-paths-env-")); + const flagDir = mkdtempSync(join(tmpdir(), "spe-mcp-paths-flag-")); + try { + process.env.SPE_DATA_DIR = envDir; + // Mirror the CLI's `options.dataDir || process.env.SPE_DATA_DIR`. + setDataDirOverride(flagDir || process.env.SPE_DATA_DIR); + expect(getDataDir()).toBe(normalize(flagDir)); + } finally { + rmSync(envDir, { recursive: true, force: true }); + rmSync(flagDir, { recursive: true, force: true }); + } + }); + + it("expands a leading ~ against the home directory", () => { + expect(resolveDataDir("~")).toBe(normalize(homedir())); + expect(resolveDataDir("~/spe-alt")).toBe(normalize(join(homedir(), "spe-alt"))); + }); + + it("rejects a CWD-relative path so secrets never land in the working directory", () => { + expect(() => resolveDataDir("relative/dir")).toThrow(/absolute/i); + expect(() => resolveDataDir("./foo")).toThrow(/absolute/i); + expect(() => resolveDataDir("../foo")).toThrow(/absolute/i); + }); + + it("re-resolves lazily after a later override (no import-time freeze)", () => { + const a = mkdtempSync(join(tmpdir(), "spe-mcp-paths-lazy-a-")); + const b = mkdtempSync(join(tmpdir(), "spe-mcp-paths-lazy-b-")); + try { + setDataDirOverride(a); + expect(getDataDir()).toBe(normalize(a)); + // A subsequent override wins and getters re-resolve to it. + setDataDirOverride(b); + expect(getDataDir()).toBe(normalize(b)); + } finally { + rmSync(a, { recursive: true, force: true }); + rmSync(b, { recursive: true, force: true }); + } + }); + + it("partitions the token cache by tenant and client within the data dir", () => { + const dir = mkdtempSync(join(tmpdir(), "spe-mcp-paths-cache-")); + try { + setDataDirOverride(dir); + const a = getCacheFile("tenant-A", "client-1"); + const b = getCacheFile("tenant-B", "client-1"); + const c = getCacheFile("tenant-A", "client-2"); + expect(a).not.toBe(b); + expect(a).not.toBe(c); + expect(a).toContain("tenant-A"); + expect(a.startsWith(normalize(dir))).toBe(true); + expect(getLegacyCacheFile()).toBe(join(normalize(dir), "token-cache.json")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("sanitizeForFilename replaces path-unsafe characters (keeps [A-Za-z0-9._-])", () => { + expect(sanitizeForFilename("abc-123_DEF.xyz")).toBe("abc-123_DEF.xyz"); + expect(sanitizeForFilename("a/b\\c:d*e")).toBe("a_b_c_d_e"); + // A traversal-looking value cannot introduce separators into the filename. + expect(sanitizeForFilename("../../etc/passwd")).toBe(".._.._etc_passwd"); + }); +}); diff --git a/src/paths.ts b/src/paths.ts new file mode 100644 index 0000000..fa042ac --- /dev/null +++ b/src/paths.ts @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Per-instance data-directory resolution — the SINGLE source of truth for where + * the SPE MCP Server keeps its provisioning state (`state.json`) and MSAL token + * cache (`token-cache.*.json`). + * + * Historically both `state.ts` and `auth.ts` hard-coded `~/.spe-mcp` in a + * module-level `const` evaluated at import time. That froze the location before + * any CLI flag / env var could be parsed, and it meant two MCP server instances + * sharing a home directory would clobber each other's single, unpartitioned + * `state.json`. This module replaces those consts with a lazy, memoized resolver + * so a caller can select the directory per-instance via `--data-dir` / + * `SPE_DATA_DIR`. + * + * Precedence (highest first): explicit override (`setDataDirOverride`, set by the + * CLI from `--data-dir` or `SPE_DATA_DIR`) > `SPE_DATA_DIR` env > default + * `~/.spe-mcp`. The default is byte-for-byte identical to the previous behavior. + * + * IMPORTANT — import-order safety: nothing here captures the resolved path in a + * module-load-time constant. Resolution happens lazily on the first getter call + * (or when the CLI calls `setDataDirOverride`), so the flag/env parsed in the CLI + * action always wins even though `state.ts`/`auth.ts` are imported first. + * + * Cross-platform: every path is built with `node:path` + `os.homedir()`, so it + * resolves correctly on Windows (`%USERPROFILE%\.spe-mcp`) and POSIX + * (`~/.spe-mcp`) alike. The override is treated as UNTRUSTED input: it must be + * absolute after explicit `~` expansion, it is normalized, and a CWD-relative + * path is rejected outright (we never resolve against `process.cwd()`). + */ + +import { homedir } from "node:os"; +import { isAbsolute, join, normalize, sep } from "node:path"; +import { AppError } from "./errors.js"; + +/** Directory name kept under the home directory by default. */ +const DEFAULT_DIR_NAME = ".spe-mcp"; + +/** + * The default data directory: `~/.spe-mcp`. Computed via a function (not a + * module-level const) so tests can exercise a changed `homedir()` and so nothing + * is frozen at import time. + */ +function defaultDataDir(): string { + return join(homedir(), DEFAULT_DIR_NAME); +} + +/** + * Memoized resolved data directory. `null` means "not yet resolved" — the next + * `getDataDir()` will resolve it (from an override set via `setDataDirOverride`, + * else `SPE_DATA_DIR`, else the default). `setDataDirOverride` overwrites it so + * a later override re-resolves lazily on demand. + */ +let memoizedDataDir: string | null = null; + +/** + * Expand a leading `~` against the HOME directory only. `~` alone → home; + * `~/foo` or `~\foo` → `/foo`. A `~user` form is intentionally NOT + * expanded (we don't resolve other users' homes) and will fall through to the + * absolute-path check, which rejects it. + */ +function expandTilde(input: string): string { + if (input === "~") return homedir(); + if (input.startsWith("~/") || input.startsWith("~\\")) { + return join(homedir(), input.slice(2)); + } + return input; +} + +/** Strip a single trailing path separator so the default compares byte-identically. */ +function stripTrailingSep(p: string): string { + if (p.length > 1 && p.endsWith(sep)) return p.slice(0, -1); + return p; +} + +/** + * Resolve a raw data-directory value (from a flag, env var, or nothing) to an + * absolute, normalized path. + * + * - Empty / whitespace / undefined → the default `~/.spe-mcp`. + * - A leading `~` is expanded against `homedir()`. + * - The result MUST be absolute. A CWD-relative path (e.g. `foo`, `./foo`, + * `../foo`) is REJECTED — we never resolve against `process.cwd()`, because an + * attacker-influenced working directory must not be able to redirect where + * refresh tokens are written. + * + * Exported for unit testing and reuse by the CLI. + */ +export function resolveDataDir(input?: string): string { + const raw = (input ?? "").trim(); + if (raw === "") { + return stripTrailingSep(normalize(defaultDataDir())); + } + const expanded = expandTilde(raw); + if (!isAbsolute(expanded)) { + throw new AppError( + "INVALID_DATA_DIR", + `Data directory must be an absolute path (got '${raw}'). Use an absolute path or a '~/...'-relative path; CWD-relative paths are rejected so the token store cannot be redirected by the working directory.`, + { + safeMessage: + "Data directory must be an absolute path (or '~/...'); CWD-relative paths are rejected.", + }, + ); + } + return stripTrailingSep(normalize(expanded)); +} + +/** + * Record an explicit data-directory override (highest precedence). The CLI calls + * this once per invocation from `--data-dir` (falling back to `SPE_DATA_DIR`) + * BEFORE `state.ts`/`auth.ts` first read the directory through the seam. Returns + * the resolved absolute path so the caller can also propagate it (e.g. by + * setting `process.env.SPE_DATA_DIR`) and log it. + * + * This overwrites the memoized value, so a subsequent `getDataDir()` re-resolves + * to the new location (lazy re-resolution). + */ +export function setDataDirOverride(input?: string): string { + memoizedDataDir = resolveDataDir(input); + return memoizedDataDir; +} + +/** + * The resolved, absolute data directory for this process. Lazily resolved and + * memoized on first use: an override set via `setDataDirOverride` wins; otherwise + * `SPE_DATA_DIR` is honored; otherwise the default `~/.spe-mcp` is used. + */ +export function getDataDir(): string { + if (memoizedDataDir === null) { + memoizedDataDir = resolveDataDir(process.env.SPE_DATA_DIR); + } + return memoizedDataDir; +} + +/** Absolute path to the provisioning state file (`/state.json`). */ +export function getStateFile(): string { + return join(getDataDir(), "state.json"); +} + +/** + * The token-cache directory. This is the SAME directory as the data dir — the + * cache and state co-locate under `~/.spe-mcp` — but it is exposed under its own + * name so `auth.ts` reads intent-revealing code. + */ +export function getCacheDir(): string { + return getDataDir(); +} + +/** Make a value safe to embed in a filename (GUIDs are already safe; be defensive). */ +export function sanitizeForFilename(value: string): string { + return value.replace(/[^a-zA-Z0-9._-]/g, "_"); +} + +/** + * Token-cache file path partitioned by tenant + client: + * `/token-cache...json`. Partitioning guarantees + * accounts from different tenants (or client apps) never co-mingle. + */ +export function getCacheFile(tenant: string, client: string): string { + return join( + getDataDir(), + `token-cache.${sanitizeForFilename(tenant)}.${sanitizeForFilename(client)}.json`, + ); +} + +/** + * Legacy single-file token cache (`/token-cache.json`) used before + * per-tenant partitioning. Kept only so logout can clean it up; never read on the + * hot path. + */ +export function getLegacyCacheFile(): string { + return join(getDataDir(), "token-cache.json"); +} + +/** + * Test-only hooks. Not part of the public API. Used to reset the memoized state + * between unit tests so env-var precedence and lazy re-resolution can be asserted + * deterministically. + */ +export const __testing = { + /** Clear the memoized data dir so the next getter re-resolves from env/default. */ + reset(): void { + memoizedDataDir = null; + }, + /** The default data directory (`~/.spe-mcp`), for golden-path assertions. */ + defaultDataDir, +}; diff --git a/src/policy.test.ts b/src/policy.test.ts new file mode 100644 index 0000000..4ae9a6a --- /dev/null +++ b/src/policy.test.ts @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, it, expect } from "vitest"; +import { + buildToolPolicy, + isToolListed, + checkToolCallAllowed, + resolveToolAllowlist, + TOOL_PROFILES, +} from "./policy.js"; +import type { McpTool } from "./types.js"; + +function tool(name: string, annotations?: McpTool["annotations"]): McpTool { + return { + name, + description: name, + annotations, + inputSchema: { type: "object", properties: {} }, + handler: async () => ({ content: [{ type: "text", text: "ok" }] }), + }; +} + +const registry: McpTool[] = [ + tool("container_list", { readOnly: true }), + tool("status", { readOnly: true }), + tool("container_create", { destructive: true }), + tool("container_delete", { destructive: true, plane: "control" }), + tool("content_search", { readOnly: true, plane: "content", requiresConsent: true }), + tool("content_access_grant"), + tool("content_access_revoke"), + tool("docs_search", { readOnly: true }), + tool("docs_fetch", { readOnly: true }), +]; + +describe("read-only mode (SAFE-003 read-only tool policy)", () => { + it("lists only read-only tools and rejects mutating calls", () => { + const policy = buildToolPolicy(registry, true, undefined); + + expect(isToolListed(tool("container_list", { readOnly: true }), policy)).toBe(true); + expect(isToolListed(tool("container_create", { destructive: true }), policy)).toBe(false); + + const denied = checkToolCallAllowed(tool("container_create", { destructive: true }), policy); + expect(denied?.isError).toBe(true); + expect(denied?.content[0].text).toContain("read-only"); + + const allowed = checkToolCallAllowed(tool("container_list", { readOnly: true }), policy); + expect(allowed).toBeNull(); + }); +}); + +describe("tool profiles (SAFE-004 tool allowlist)", () => { + it("docsOnly profile exposes only the docs tools", () => { + const { allow, profile } = resolveToolAllowlist(registry, "docsOnly"); + expect(profile).toBe("docsOnly"); + expect([...allow].sort()).toEqual(["docs_fetch", "docs_search"]); + }); + + it("content profile includes content-plane tools plus grant/revoke", () => { + const { allow } = resolveToolAllowlist(registry, "content"); + expect(allow.has("content_search")).toBe(true); + expect(allow.has("content_access_grant")).toBe(true); + expect(allow.has("content_access_revoke")).toBe(true); + expect(allow.has("container_create")).toBe(false); + }); + + it("admin profile allows everything", () => { + const { allow } = resolveToolAllowlist(registry, "admin"); + expect(allow.size).toBe(registry.length); + }); + + it("a CSV spec builds an explicit allowlist and rejects others at call time", () => { + const policy = buildToolPolicy(registry, false, "status,container_list"); + expect(isToolListed(tool("status", { readOnly: true }), policy)).toBe(true); + const denied = checkToolCallAllowed(tool("container_create", { destructive: true }), policy); + expect(denied?.isError).toBe(true); + expect(denied?.content[0].text).toContain("allowlist"); + }); + + it("exposes the documented built-in profiles", () => { + expect(Object.keys(TOOL_PROFILES).sort()).toEqual( + ["admin", "content", "docsOnly", "provisioning", "readOnly"], + ); + }); + + it("no policy means every tool is listed and allowed", () => { + expect(isToolListed(tool("container_create", { destructive: true }), null)).toBe(true); + expect(checkToolCallAllowed(tool("container_create", { destructive: true }), null)).toBeNull(); + }); + + it("inherited object keys are not treated as profiles (prototype-pollution / allowlist bypass)", () => { + for (const reserved of ["toString", "hasOwnProperty", "constructor", "__proto__", "valueOf"]) { + const { allow, profile } = resolveToolAllowlist(registry, reserved); + // Must NOT resolve to a built-in profile... + expect(profile).toBeUndefined(); + // ...must NOT expose all tools (bypass) and must NOT deny-all via an + // inherited predicate: it is an unknown tool name, so the allowlist is + // exactly that single (non-existent) name. + expect([...allow]).toEqual([reserved]); + expect(allow.size).toBe(1); + expect(allow.has("container_create")).toBe(false); + } + }); + + it("a reserved key as --tools rejects every real tool at call time", () => { + // `toString` would previously map to Object.prototype.toString (truthy for + // every tool) and expose the whole registry. It must now reject. + const policy = buildToolPolicy(registry, false, "toString"); + expect(policy.profile).toBeUndefined(); + for (const t of registry) { + const denied = checkToolCallAllowed(t, policy); + expect(denied?.isError).toBe(true); + expect(denied?.content[0].text).toContain("allowlist"); + } + }); +}); diff --git a/src/policy.ts b/src/policy.ts new file mode 100644 index 0000000..e03da39 --- /dev/null +++ b/src/policy.ts @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Runtime tool-exposure policy (SAFE-003 read-only mode + SAFE-004 profiles / + * allowlist). + * + * Pure, side-effect-free helpers so the filter + reject behavior can be unit + * tested without standing up the MCP server. index.ts wires these into the + * ListTools filter and the CallTool dispatcher. + * + * Why restrict which tools are exposed? An MCP client — including an autonomous + * "autopilot" agent — can only see and call the tools the server advertises, so + * narrowing that surface is a safety / least-privilege control: + * + * - Read-only mode (`--read-only` / `SPE_READ_ONLY`) hides and rejects every + * mutating tool, so an unattended or autopilot run can inspect and report but + * cannot create, delete, deploy, or otherwise make destructive changes. + * - Profiles / allowlist (`--tools ` / `SPE_TOOLS`) advertise only + * a curated subset. For example: `docsOnly` for a documentation-lookup + * assistant, `readOnly` for a status dashboard, `content` for file + * operations, or an explicit CSV such as `container_list,container_get` to + * scope a client to a single task. + * + * The two layers compose: to be listed or called, a tool must be in the + * allowlist AND (when read-only mode is on) be annotated read-only. + */ + +import type { McpTool, McpToolResult } from "./types.js"; +import { fail } from "./responses.js"; + +/** A tool is read-only when explicitly annotated `readOnly: true`. */ +export function isReadOnlyTool(tool: McpTool): boolean { + return tool.annotations?.readOnly === true; +} + +/** A tool is content-plane when annotated `plane: "content"`. */ +function isContentPlane(tool: McpTool): boolean { + return tool.annotations?.plane === "content"; +} + +/** + * Built-in tool profiles for `--tools ` / `SPE_TOOLS`. Each predicate + * decides whether a given tool is included in the profile. + */ +export const TOOL_PROFILES: Record boolean> = { + /** Only read/list/get/search/status tools. */ + readOnly: (t) => isReadOnlyTool(t), + /** Documentation lookup only. */ + docsOnly: (t) => t.name === "docs_search" || t.name === "docs_fetch", + /** Control-plane provisioning + status (everything that is not content-plane). */ + provisioning: (t) => !isContentPlane(t), + /** Content-plane file operations plus the content-access grant/revoke toggles. */ + content: (t) => + isContentPlane(t) || t.name === "content_access_grant" || t.name === "content_access_revoke", + /** Everything (no restriction). */ + admin: () => true, +}; + +export const PROFILE_NAMES = Object.keys(TOOL_PROFILES); + +export interface ResolvedToolPolicy { + /** Reject any non-readOnly tool at call time + hide it from ListTools. */ + readOnly: boolean; + /** + * Allowed tool names. `undefined` means "no allowlist" (all tools allowed, + * subject to readOnly). Built from a profile predicate or an explicit CSV. + */ + allow?: Set; + /** The profile name, when a built-in profile was used (for logging). */ + profile?: string; +} + +/** + * Resolve a `--tools` / `SPE_TOOLS` spec (a built-in profile name or a CSV of + * tool names) against the full tool registry into a concrete allowlist. + */ +export function resolveToolAllowlist( + tools: McpTool[], + spec: string, +): { allow: Set; profile?: string } { + const trimmed = spec.trim(); + // Only treat OWN, enumerable keys as profiles. Indexing the object literal + // directly would resolve inherited Object.prototype members (e.g. `toString`, + // `hasOwnProperty`, `constructor`, `__proto__`) to functions and let a crafted + // `--tools` value bypass the SAFE-004 allowlist. Unknown names fall through to + // the CSV tool-name path below. + const predicate = Object.hasOwn(TOOL_PROFILES, trimmed) ? TOOL_PROFILES[trimmed] : undefined; + if (predicate) { + return { allow: new Set(tools.filter(predicate).map((t) => t.name)), profile: trimmed }; + } + const names = trimmed + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + return { allow: new Set(names) }; +} + +/** + * Build the runtime policy from config/env values. + * + * @param tools full tool registry (used to expand a profile into names) + * @param readOnly read-only flag (CLI flag OR truthy SPE_READ_ONLY) + * @param toolsSpec optional profile name or CSV (CLI flag OR SPE_TOOLS) + */ +export function buildToolPolicy( + tools: McpTool[], + readOnly: boolean, + toolsSpec: string | undefined, +): ResolvedToolPolicy { + const policy: ResolvedToolPolicy = { readOnly }; + if (toolsSpec && toolsSpec.trim().length > 0) { + const { allow, profile } = resolveToolAllowlist(tools, toolsSpec); + policy.allow = allow; + policy.profile = profile; + } + return policy; +} + +/** Whether a tool should be advertised in ListTools under the given policy. */ +export function isToolListed(tool: McpTool, policy: ResolvedToolPolicy | null): boolean { + if (!policy) return true; + if (policy.readOnly && !isReadOnlyTool(tool)) return false; + if (policy.allow && !policy.allow.has(tool.name)) return false; + return true; +} + +/** + * Check whether a tool call is permitted under the policy. Returns a `fail(...)` + * result to short-circuit the dispatcher, or `null` when the call may proceed. + */ +export function checkToolCallAllowed( + tool: McpTool, + policy: ResolvedToolPolicy | null, +): McpToolResult | null { + if (!policy) return null; + if (policy.readOnly && !isReadOnlyTool(tool)) { + return fail( + "READ_ONLY_MODE", + `Tool '${tool.name}' is not available: the server is running in read-only mode.`, + "Restart without --read-only (or unset SPE_READ_ONLY) to allow mutating operations.", + ); + } + if (policy.allow && !policy.allow.has(tool.name)) { + return fail( + "TOOL_NOT_ALLOWED", + `Tool '${tool.name}' is not in the active tool allowlist${policy.profile ? ` (profile '${policy.profile}')` : ""}.`, + "Adjust --tools / SPE_TOOLS to include this tool, or use the 'admin' profile.", + ); + } + return null; +} diff --git a/src/prompts.ts b/src/prompts.ts new file mode 100644 index 0000000..0fb0ca3 --- /dev/null +++ b/src/prompts.ts @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * MCP Prompts for the SPE Builder — guided end-to-end flows. + * + * Prompts encode agent instructions, including **agent-guided elicitation** + * (present choices to the user in chat, then call the tool with the + * selection), so guided flows are consistent across MCP hosts. + */ + +interface PromptDef { + name: string; + description: string; + arguments: Array<{ name: string; description: string; required: boolean }>; +} + +export const SPE_PROMPTS: PromptDef[] = [ + { + name: "provision_spe_app", + description: + "Guided end-to-end: build a SharePoint Embedded app from a natural-language idea — " + + "provision resources, scaffold a reference architecture, run locally, and deploy to Azure.", + arguments: [ + { name: "idea", description: "What the app should do (e.g., 'manage construction documents for teams').", required: false }, + ], + }, + { + name: "setup_standard_billing", + description: "Guide a developer through standard Azure billing setup for an already-standard SPE container type.", + arguments: [ + { name: "containerTypeId", description: "Container type ID to attach standard billing to. Defaults from state if omitted.", required: false }, + { name: "region", description: "Azure region for the Microsoft.Syntex account (e.g., eastus).", required: false }, + ], + }, + { + name: "scaffold_sample_app", + description: "Choose and scaffold a reference SPE app, then hydrate its local configuration.", + arguments: [ + { name: "projectName", description: "Project name to use for scaffolding. Default: spe-app.", required: false }, + { name: "targetDir", description: "Directory to scaffold into. Default is derived from projectName.", required: false }, + ], + }, + { + name: "run_local", + description: "Run a scaffolded and hydrated reference app locally.", + arguments: [ + { name: "projectDir", description: "Scaffolded project directory. Default: current directory.", required: false }, + ], + }, + { + name: "deploy_to_azure", + description: "Deploy a scaffolded SPE reference app to Azure using azd.", + arguments: [ + { name: "projectDir", description: "Scaffolded project directory. Default: current directory.", required: false }, + { name: "location", description: "Azure region required by the non-interactive azd deployment (e.g., eastus).", required: false }, + ], + }, + { + name: "seed_sample_content", + description: "Opt into content-plane access and seed sample containers/documents for demos.", + arguments: [ + { name: "containerTypeId", description: "Container type ID to seed. Defaults from state if omitted.", required: false }, + ], + }, + { + name: "cleanup_project", + description: "Safely clean up resources provisioned by the SPE Builder.", + arguments: [], + }, + { + name: "troubleshoot_auth_or_billing", + description: "Diagnose Azure CLI sign-in, consent, Conditional Access, billing, and content-access issues.", + arguments: [ + { name: "symptom", description: "Error text or observed symptom to troubleshoot.", required: false }, + ], + }, +]; + +const PROVISION_GUIDE = (idea: string) => `You are helping a developer build a **SharePoint Embedded (SPE)** app${ + idea ? ` that will: ${idea}` : "" +}. Drive this end to end with the SPE Builder MCP tools, pausing to ask the user at each choice point. + +Follow this flow: + +1. **Check prerequisites** — call \`status_get\`. If not signed in, tell the user to run \`az login --allow-no-subscriptions\`. +2. **Billing** — ask the user: *Trial* (free, 30 days) or *Standard* (Azure subscription)? + - If **Standard**: call \`azure_subscriptions_list\`, present the options, ask which one; then \`azure_resource_groups_list\` for that subscription, present and ask which one. +3. **Provision** — call \`project_provision\` with the app name, chosen \`billingClassification\`, and (for standard) \`azureSubscriptionId\` + \`resourceGroup\`. + - If a previously-used owning app is remembered, the tool asks whether to **reuse** it or use a **different** app — present that choice and re-run with \`appSelection\` (\`reuse\` or \`new\`); for a different app also pass \`appDisplayName\` with its name. Never silently reuse the last app. +4. **Choose a reference architecture** — call \`project_scaffold\` with no \`architecture\` first to list options, present them, ask the user, then call \`project_scaffold\` again with the choice and a \`targetDir\`. +5. **Hydrate config** — call \`project_hydrate_config\` targeting the scaffolded project. +6. **Seed sample data** — ask if they want sample containers + documents; if yes, explain this is content-plane access, call \`content_access_grant\` with \`confirm=true\` only after user approval, then call \`project_seed_sample_data\`. +7. **Run locally** — ask if they want to run it now; if yes, call \`project_run_local\` and share the local URL. +8. **Deploy** — when the user asks to deploy, call \`project_deploy\` and share the live URL. + +Always present choices clearly and wait for the user's selection before proceeding. Use \`docs_search\` if the user asks conceptual SPE questions.`; + +const standardBillingGuide = (containerTypeId: string, region: string) => { + const ct = containerTypeId ? ` with \`containerTypeId=${containerTypeId}\`` : " using the container type from state"; + const loc = region || "eastus"; + return `Guide the developer through **standard Azure billing** for SPE. + +Use this flow: +1. Call \`status_get\`. If Azure CLI is not signed in, tell the user to run \`az login --allow-no-subscriptions\`. +2. Call \`azure_subscriptions_list\`; present enabled subscriptions and ask which subscription to bill. +3. Call \`azure_resource_groups_list\` for the selected subscription; present resource groups and ask which one to use. +4. Explain standard billing is irreversible and only works for a container type created with \`billingClassification=standard\`; trial container types cannot be converted. +5. Preview first: call \`billing_setup\`${ct}, selected subscription/resource group, and \`region=${loc}\` without \`confirm\`. +6. If the user explicitly agrees, call \`billing_setup\` again with the same values and \`confirm=true\`. +7. Finish with \`billing_check\` to verify the billing state. + +If ARM returns Conditional Access or claims-challenge errors, tell the user to re-run \`az login --scope https://management.core.windows.net//.default --tenant \` and retry.`; +}; + +const scaffoldGuide = (projectName: string, targetDir: string) => `Help the developer scaffold a runnable SPE reference app. + +Use this flow: +1. Call \`project_scaffold\` with no \`architecture\` to list options. +2. Present the options and ask the user to choose one. +3. Call \`project_scaffold\` again with the chosen \`architecture\`${projectName ? `, \`projectName=${projectName}\`` : ""}${targetDir ? `, and \`targetDir=${targetDir}\`` : ""}. +4. Call \`project_hydrate_config\` for the scaffolded directory so SPE IDs and app settings are written. +5. Offer next steps: \`project_run_local\`, \`project_deploy\`, or sample content via \`seed_sample_content\`. + +Do not overwrite user files without highlighting the target directory first.`; + +const runLocalGuide = (projectDir: string) => `Run the scaffolded SPE app locally. + +Use this flow: +1. Call \`status_get\` and confirm provisioning state is present. +2. If config has not been hydrated, call \`project_hydrate_config\` for ${projectDir ? `\`${projectDir}\`` : "the project directory"}. +3. Call \`project_run_local\`${projectDir ? ` with \`projectDir=${projectDir}\`` : ""}. +4. Share the returned local URL and any sign-in note. + +If startup fails, report the tool's actionable error (missing Node/.NET SDK, port conflict, or missing scaffold) and suggest retrying after fixing it.`; + +const deployGuide = (projectDir: string, location: string) => `Deploy the SPE reference app to Azure. + +Use this flow: +1. Call \`status_get\` and confirm Azure CLI is signed in. +2. Ensure the app is scaffolded and hydrated; if needed, call \`project_hydrate_config\`. +3. Ask for an Azure region if none was provided. \`project_deploy\` needs a \`location\` for non-interactive \`azd up\`. +4. Call \`project_deploy\`${projectDir ? ` with \`projectDir=${projectDir}\`` : ""}${location ? ` and \`location=${location}\`` : ""}. +5. Share the live URL. If the tool reports redirect URI or auth guidance, include it. + +Deployment uses Azure resources and may incur cost; get user confirmation before starting.`; + +const seedContentGuide = (containerTypeId: string) => `Seed sample SPE content for demos and regression tests. + +Use this flow: +1. Explain that seeding creates containers and uploads sample documents, so it is content-plane access. +2. Call \`content_access_grant\` without \`confirm\` if the user has not already opted in; only call it with \`confirm=true\` after explicit user approval. +3. Call \`project_seed_sample_data\`${containerTypeId ? ` with \`containerTypeId=${containerTypeId}\`` : ""}. +4. If the user wants ad-hoc content operations, use \`content_folder_create\`, \`content_file_upload\`, \`content_search\`, \`content_file_preview\`, or \`content_sharing_manage\` after the grant. +5. Remind the user they can revoke content access with \`content_access_revoke\`. + +Never call content tools before content access is granted; they are intentionally fail-closed.`; + +const CLEANUP_GUIDE = `Guide safe SPE project cleanup. + +Use this flow: +1. Call \`project_cleanup\` without \`confirm\` to preview what would be deleted. +2. Explain the result: trial container types and their owning app can be deleted; standard/direct-to-customer resources are protected unless \`deleteStandard=true\`. +3. Ask for explicit confirmation before deletion. +4. If confirmed, call \`project_cleanup\` with \`confirm=true\`. Only pass \`deleteStandard=true\` if the user explicitly asks to delete billed/protected resources. +5. Report what was deleted or preserved. + +This is destructive. Never run the confirmed cleanup silently.`; + +const troubleshootGuide = (symptom: string) => `Troubleshoot SPE auth, consent, billing, or content-access issues${symptom ? ` for this symptom: ${symptom}` : ""}. + +Use this flow: +1. Call \`status_get\` first and inspect Azure CLI sign-in plus recorded provisioning state. +2. For Azure CLI errors, tell the user to run \`az login --allow-no-subscriptions\`; for ARM/standard billing claims challenges, use \`az login --scope https://management.core.windows.net//.default --tenant \`. +3. For Graph scope/consent errors, explain the owning public-client app needs delegated SPE permissions and admin/user consent. +4. For billing issues, call \`billing_check\`; if standard setup is needed, use \`azure_subscriptions_list\`, \`azure_resource_groups_list\`, then guarded \`billing_setup\`. +5. For new container/container-type/search misses, explain eventual consistency and retry after propagation. +6. For file operation failures, verify \`content_access_grant\` has been confirmed before using content tools. +7. Use \`docs_search\` for authoritative SPE/Graph documentation if the user asks why a requirement exists.`; + +function textPrompt(description: string, text: string) { + return { + description, + messages: [ + { + role: "user" as const, + content: { type: "text" as const, text }, + }, + ], + }; +} + +export function getPromptMessages(name: string, args: Record) { + const arg = (key: string) => (typeof args[key] === "string" ? args[key] as string : ""); + + switch (name) { + case "provision_spe_app": + return textPrompt("Guided SharePoint Embedded app build", PROVISION_GUIDE(arg("idea"))); + case "setup_standard_billing": + return textPrompt("Guided standard billing setup", standardBillingGuide(arg("containerTypeId"), arg("region"))); + case "scaffold_sample_app": + return textPrompt("Guided reference app scaffolding", scaffoldGuide(arg("projectName"), arg("targetDir"))); + case "run_local": + return textPrompt("Guided local run", runLocalGuide(arg("projectDir"))); + case "deploy_to_azure": + return textPrompt("Guided Azure deployment", deployGuide(arg("projectDir"), arg("location"))); + case "seed_sample_content": + return textPrompt("Guided sample content seeding", seedContentGuide(arg("containerTypeId"))); + case "cleanup_project": + return textPrompt("Guided safe cleanup", CLEANUP_GUIDE); + case "troubleshoot_auth_or_billing": + return textPrompt("Guided auth and billing troubleshooting", troubleshootGuide(arg("symptom"))); + default: + throw new Error(`Unknown prompt: ${name}`); + } +} diff --git a/src/protocol-e2e.test.ts b/src/protocol-e2e.test.ts new file mode 100644 index 0000000..315a809 --- /dev/null +++ b/src/protocol-e2e.test.ts @@ -0,0 +1,213 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * TEST-001 — MCP protocol-level end-to-end harness. + * + * HARNESS APPROACH: spawn the built server (`dist/cli.js start`) as a child + * process and drive it with the real MCP SDK `Client` over the SDK's + * `StdioClientTransport`. This exercises the genuine wire protocol — the + * `initialize` handshake, JSON-RPC framing, ListTools serialization (handler + * stripping), and the CallTool dispatch/gating pipeline — exactly as a real MCP + * host would. We prefer spawn over an in-memory transport pair because the + * server (`src/index.ts`) owns a module-level `Server` singleton that always + * connects its own `StdioServerTransport`; there is no exported handle to bind + * an in-memory pair to without refactoring production code. Spawning proved + * reliable on Windows (verified before committing). + * + * OFFLINE/DETERMINISTIC: the server connects its transport BEFORE auth and only + * LOGS (never throws) on Azure-CLI failure, so list/unknown/gate paths answer + * without any tenant, Graph, or network access. We point the child's HOME / + * USERPROFILE at an isolated empty dir so persisted provisioning state is empty + * and the content-plane gate is reliably CLOSED. Every `tools/call` carries a + * per-request timeout so a hang fails the individual assertion rather than + * stalling the whole suite. + */ + +import { mkdtempSync, rmSync, existsSync } from "node:fs"; +import { execSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +// repo root = parent of src/ +const REPO_ROOT = resolve(__dirname, ".."); +const CLI_ENTRY = join(REPO_ROOT, "dist", "cli.js"); + +// Per-call timeout (ms): a gate/dispatch hang should fail the specific +// assertion fast, not block the suite. Generous enough to absorb child-process +// spawn jitter on CI. +const CALL_TIMEOUT_MS = 8000; + +interface StructuredError { + ok?: boolean; + error?: { code?: string; message?: string; suggestion?: string }; + durationMs?: number; +} + +describe("MCP protocol-level e2e (spawned dist/cli.js start)", () => { + let client: Client; + let transport: StdioClientTransport; + let isolatedHome: string; + + beforeAll(async () => { + // Self-build guard: this suite drives the *built* server (dist/cli.js). The + // `ci` script and CI workflow build before test, but a direct `vitest` run + // (or test-before-build ordering) may not have dist/ yet — so build it once + // here if missing, rather than failing with an opaque "Connection closed". + if (!existsSync(CLI_ENTRY)) { + execSync("npm run build", { cwd: REPO_ROOT, stdio: "ignore" }); + } + + // Isolated, empty HOME/USERPROFILE => empty persisted state => content gate + // is closed and no prior run can leak `contentAccessGranted: true`. + isolatedHome = mkdtempSync(join(tmpdir(), "spe-mcp-e2e-home-")); + + const env: Record = {}; + for (const [k, v] of Object.entries(process.env)) { + if (typeof v === "string") env[k] = v; + } + env.USERPROFILE = isolatedHome; + env.HOME = isolatedHome; + // Force bootstrap mode (no pre-provisioned app); keeps auth non-blocking. + delete env.SPE_CLIENT_ID; + delete env.SPE_TENANT_ID; + delete env.SPE_READ_ONLY; + delete env.SPE_TOOLS; + + transport = new StdioClientTransport({ + command: process.execPath, + args: [CLI_ENTRY, "start"], + env, + cwd: REPO_ROOT, + // Swallow the server's stderr diagnostics so they don't pollute test output. + stderr: "ignore", + }); + + client = new Client({ name: "spe-mcp-e2e-test", version: "0.0.0" }, {}); + // connect() performs the MCP `initialize` handshake. + await client.connect(transport); + }, 90000); + + afterAll(async () => { + try { + await client?.close(); + } catch { + /* ignore */ + } + try { + await transport?.close(); + } catch { + /* ignore */ + } + try { + if (isolatedHome) rmSync(isolatedHome, { recursive: true, force: true }); + } catch { + /* ignore */ + } + }); + + // (a) initialize handshake succeeds; serverInfo name is `spe-mcp-server`. + it("completes the initialize handshake and reports serverInfo name", () => { + const info = client.getServerVersion(); + expect(info).toBeDefined(); + expect(info?.name).toBe("spe-mcp-server"); + // Capabilities negotiated during initialize should advertise tools. + expect(client.getServerCapabilities()?.tools).toBeDefined(); + }); + + // (a2) The `initialize` result carries the server `instructions` primer over + // the real wire, so clients can prime the model before any tool call. + it("returns the SPE domain primer via the initialize instructions field", () => { + const instructions = client.getInstructions(); + expect(instructions).toBeTruthy(); + // Anchor on stable, load-bearing content: the routing precondition and the + // samples-repo citation a client is expected to relay to the model. + expect(instructions).toContain("OWNING_APP_REQUIRED"); + expect(instructions).toContain( + "https://github.com/microsoft/SharePoint-Embedded-Samples", + ); + }); + + // (b) tools/list returns tools with NO handler field, each has inputSchema; + // spot-check a known tool and that container_delete is destructive. + it("lists tools without leaking handlers and with correct annotations", async () => { + const { tools } = await client.listTools(undefined, { timeout: CALL_TIMEOUT_MS }); + expect(tools.length).toBeGreaterThan(0); + + for (const tool of tools) { + // Handler must never cross the wire. + expect(tool).not.toHaveProperty("handler"); + expect(tool.inputSchema).toBeDefined(); + expect(tool.inputSchema.type).toBe("object"); + } + + // Spot-check a stable, well-known tool exists. + const names = tools.map((t) => t.name); + expect(names).toContain("container_list"); + + // Destructive tool carries destructiveHint:true via annotations. + const del = tools.find((t) => t.name === "container_delete"); + expect(del).toBeDefined(); + expect(del?.annotations?.destructiveHint).toBe(true); + }); + + // (c) tools/call happy path on a SAFE, no-network tool. content_access_grant + // with no confirm returns guidance content and touches no Graph/Azure/state. + it("calls a safe no-network tool (content_access_grant) successfully", async () => { + const res = await client.callTool( + { name: "content_access_grant", arguments: {} }, + undefined, + { timeout: CALL_TIMEOUT_MS }, + ); + expect(res.isError).toBeFalsy(); + const content = res.content as Array<{ type: string; text: string }>; + expect(content[0].text).toContain("Enable content access?"); + }); + + // (d) tools/call unknown tool => isError, code UNKNOWN_TOOL. + it("returns isError + UNKNOWN_TOOL for an unknown tool", async () => { + const res = await client.callTool( + { name: "this_tool_does_not_exist", arguments: {} }, + undefined, + { timeout: CALL_TIMEOUT_MS }, + ); + expect(res.isError).toBe(true); + const sc = res.structuredContent as StructuredError | undefined; + expect(sc?.error?.code).toBe("UNKNOWN_TOOL"); + const content = res.content as Array<{ type: string; text: string }>; + expect(content.some((c) => /unknown tool/i.test(c.text))).toBe(true); + }); + + // (e) Content-gate fail-closed: a content tool BEFORE any grant => isError, + // returns the gate message, does NOT hang, and makes no Graph call. + it("fails closed on a content-plane tool before content access is granted", async () => { + const res = await client.callTool( + { name: "content_search", arguments: { containerId: "no-such-container", query: "anything" } }, + undefined, + { timeout: CALL_TIMEOUT_MS }, + ); + expect(res.isError).toBe(true); + const content = res.content as Array<{ type: string; text: string }>; + // Gate message — proves we stopped at the wrapper, not at a Graph error. + expect(content[0].text).toContain("Content access not enabled"); + }); + + // (f) Confirm-gate: permanent-delete WITHOUT confirm => isError, + // CONFIRMATION_REQUIRED, returns quickly with no real delete. + it("requires confirmation for container_delete permanent-delete", async () => { + const res = await client.callTool( + { name: "container_delete", arguments: { containerId: "x", action: "permanent-delete" } }, + undefined, + { timeout: CALL_TIMEOUT_MS }, + ); + expect(res.isError).toBe(true); + const sc = res.structuredContent as StructuredError | undefined; + expect(sc?.error?.code).toBe("CONFIRMATION_REQUIRED"); + }); +}); diff --git a/src/reference-architectures.test.ts b/src/reference-architectures.test.ts new file mode 100644 index 0000000..2d88797 --- /dev/null +++ b/src/reference-architectures.test.ts @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for the reference-architecture catalog. + * + * The scaffolder sources REAL committed sample apps under `samples/` (the + * single source of truth — built, linted, and CVE-scanned in CI) and applies the + * caller's project name. These tests assert the catalog reads those samples and + * that the C# sample keeps the ODSP security-approved azd shape (managed identity + * + federated credential, no secrets, least-privilege AcrPull, subscription-scoped + * main.bicep) — coverage carried over from the former azd-template generator test. + */ + +import { describe, it, expect } from "vitest"; +import { findArchitecture, REFERENCE_ARCHITECTURES, buildAzureYaml } from "./reference-architectures.js"; + +describe("reference-architecture catalog", () => { + it("exposes the two v1 stacks", () => { + expect(REFERENCE_ARCHITECTURES.map((a) => a.id).sort()).toEqual(["csharp-web", "react-spa-functions"]); + }); +}); + +describe("react-spa-functions sample", () => { + const files = findArchitecture("react-spa-functions")!.files("my-app"); + + it("reads the committed React sample tree", () => { + for (const path of ["package.json", "index.html", "vite.config.ts", "tsconfig.json", "src/main.tsx", "src/App.tsx"]) { + expect(Object.keys(files)).toContain(path); + } + }); + + it("applies the caller's project name and ships hardened deps", () => { + const pkg = JSON.parse(files["package.json"]) as { name: string; overrides?: Record }; + expect(pkg.name).toBe("my-app"); + // The sample ships Node 18-safe, 0-npm-audit deps (Vite 6 + esbuild override). + expect(pkg.overrides?.esbuild).toMatch(/0\.25/); + }); + + it("regenerates azure.yaml for the staticwebapp host", () => { + expect(files["azure.yaml"]).toContain("host: staticwebapp"); + expect(files["azure.yaml"]).toContain("name: my-app"); + expect(files["azure.yaml"]).toContain("dist: dist"); + }); + + it("does not leak build outputs (node_modules/dist) into the scaffold", () => { + expect(Object.keys(files).some((f) => f.includes("node_modules/") || f.startsWith("dist/"))).toBe(false); + }); +}); + +describe("csharp-web sample (ODSP security-approved azd)", () => { + const arch = findArchitecture("csharp-web"); + const files = arch!.files("demo-app"); + + it("targets Azure Container Apps with the full secure infra file set", () => { + expect(arch?.host).toBe("containerapp"); + expect(arch?.language).toBe("dotnet"); + for (const path of [ + "Program.cs", "appsettings.json", "Dockerfile", ".dockerignore", "bicepconfig.json", + "infra/main.bicep", "infra/main.parameters.json", "infra/abbreviations.json", + "infra/shared/identity.bicep", "infra/shared/registry.bicep", "infra/shared/apps-env.bicep", + "infra/modules/fetch-container-image.bicep", "infra/app/web.bicep", + ]) { + expect(Object.keys(files)).toContain(path); + } + }); + + it("renames the .csproj to the project name", () => { + expect(Object.keys(files).filter((f) => f.endsWith(".csproj"))).toEqual(["demo-app.csproj"]); + }); + + it("regenerates azure.yaml with the Dockerfile build", () => { + const yaml = files["azure.yaml"]; + expect(yaml).toContain("host: containerapp"); + expect(yaml).toContain("docker:"); + expect(yaml).toContain("path: ./Dockerfile"); + }); + + it("main.bicep is subscription-scoped and provisions its own resource group", () => { + const main = files["infra/main.bicep"]; + expect(main).toContain("targetScope = 'subscription'"); + expect(main).toContain("resource rg 'Microsoft.Resources/resourceGroups"); + expect(main).toContain("name: 'rg-${environmentName}'"); + expect(main).toContain("module identity './shared/identity.bicep'"); + expect(main).toContain("module web './app/web.bicep'"); + expect(main).toContain("loadJsonContent('./abbreviations.json')"); + }); + + it("app/web.bicep uses a managed identity federated to the Entra app (no secret)", () => { + const web = files["infra/app/web.bicep"]; + expect(web).toContain("Microsoft.ManagedIdentity/userAssignedIdentities"); + expect(web).toContain("type: 'UserAssigned'"); + expect(web).toContain("Microsoft.Graph/applications@v1.0"); + expect(web).toContain("federatedIdentityCredentials@v1.0"); + expect(web).toContain("SignedAssertionFromManagedIdentity"); + expect(web).toContain("085ca537-6565-41c2-aca7-db852babc212"); // FileStorageContainer.Selected + expect(web).toContain("'azd-service-name': 'web'"); + }); + + it("grants only least-privilege AcrPull via RBAC (ServicePrincipal)", () => { + const web = files["infra/app/web.bicep"]; + expect(web).toContain("Microsoft.Authorization/roleAssignments"); + expect(web).toContain("7f951dda-4ed3-4680-a7ca-43fe172d538d"); // AcrPull role definition id + expect(web).toContain("principalType: 'ServicePrincipal'"); + }); + + it("contains no client secrets or registry admin credentials", () => { + const blob = Object.values(files).join("\n").toLowerCase(); + expect(blob).not.toContain("clientsecret"); + expect(blob).not.toContain("client_secret"); + expect(blob).not.toContain('"password"'); + expect(files["infra/shared/registry.bicep"]).toContain("adminUserEnabled bool = false"); + }); + + it("Dockerfile + csproj agree on the published assembly name (app.dll)", () => { + expect(files["Dockerfile"]).toContain('ENTRYPOINT ["dotnet", "app.dll"]'); + expect(files["demo-app.csproj"]).toContain("app"); + }); + + it("main.parameters.json wires the azd substitution tokens", () => { + const params = files["infra/main.parameters.json"]; + expect(params).toContain("${AZURE_ENV_NAME}"); + expect(params).toContain("${AZURE_LOCATION}"); + expect(params).toContain("${AZURE_PRINCIPAL_ID}"); + expect(() => JSON.parse(params)).not.toThrow(); + }); +}); + +describe("buildAzureYaml", () => { + it("emits a docker build section for the containerapp host", () => { + expect(buildAzureYaml("x", "dotnet", "containerapp")).toContain("path: ./Dockerfile"); + }); + it("emits a dist output for the staticwebapp host", () => { + expect(buildAzureYaml("x", "js", "staticwebapp")).toContain("dist: dist"); + }); +}); diff --git a/src/reference-architectures.ts b/src/reference-architectures.ts new file mode 100644 index 0000000..3f9681b --- /dev/null +++ b/src/reference-architectures.ts @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Reference architecture catalog for the SPE Builder. + * + * Each architecture is a small, runnable, Azure-deployable starter that the + * `project_scaffold` tool materializes and that is also exposed as an MCP Resource + * (so any MCP client can enumerate/inspect them). The React quick-start is an + * intentionally minimal starter; the C# full-stack arch deploys the ODSP + * security-approved azd template (`microsoft/app-with-sharepoint-knowledge`) — + * Azure Container Apps with a user-assigned managed identity + federated + * credential (no secrets). + * + * The apps themselves are REAL committed projects under `../samples/` (built, + * linted, and CVE-scanned in CI). They are the single source of truth: the + * scaffolder reads them here instead of from generated `.ts` string templates, + * and `samples/` ships in the npm package next to `dist/`. + */ + +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join, relative, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +export interface ReferenceArchitecture { + id: string; + name: string; + description: string; + language: string; + /** azd host target for this architecture (e.g., 'staticwebapp', 'appservice'). */ + host: string; + /** Returns the file map (relative path → contents) for a project name. */ + files: (projectName: string) => Record; +} + +/** + * Build an `azure.yaml` (azd) descriptor for a project. The `name`, `language`, + * and `host` are architecture-specific, so callers MUST pass the values for the + * scaffolded architecture rather than hard-coding a single host (which would + * flip, e.g., a C# Container Apps app to a Static Web App). Exported so + * `project_hydrate_config` can regenerate a descriptor that matches the + * scaffolded architecture. For `containerapp` hosts a `docker:` build section is + * emitted so azd builds the image from the project's Dockerfile. + */ +export function buildAzureYaml(name: string, language: string, host: string): string { + const lines = [ + "# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json", + `name: ${name}`, + "metadata:", + " template: spe-builder-mcp", + "services:", + " web:", + " project: .", + ` language: ${language}`, + ` host: ${host}`, + ]; + if (host === "containerapp") { + lines.push(" docker:", " path: ./Dockerfile"); + } + if (host === "staticwebapp") { + // Vite build output (dist/) that azd deploys to the Static Web App. + lines.push(" dist: dist"); + } + lines.push(""); + return lines.join("\n"); +} + +const SAMPLES_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "samples"); + +// Build outputs that may exist if a sample was built locally; never scaffold them. +const SAMPLE_SKIP_DIRS = new Set([ + "node_modules", "dist", "bin", "obj", ".publish", ".vs", ".vscode", ".azure", ".git", +]); + +/** Read a committed sample app into a `{ relPath -> contents }` map. */ +function readSampleTree(architectureId: string): Record { + const root = join(SAMPLES_ROOT, architectureId); + const files: Record = {}; + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!SAMPLE_SKIP_DIRS.has(entry.name)) walk(join(dir, entry.name)); + } else if (entry.isFile()) { + const full = join(dir, entry.name); + files[relative(root, full).split(sep).join("/")] = readFileSync(full, "utf-8"); + } + } + }; + walk(root); + return files; +} + +/** + * Apply the caller's project name to the generic committed sample: the npm + * package name, the `.csproj` filename, and a freshly-generated `azure.yaml` + * (host/language are catalog facts, so the descriptor is regenerated rather than + * string-substituted). + */ +function withProjectName( + files: Record, + project: string, + yamlLanguage: string, + host: string, +): Record { + const out: Record = { ...files }; + + if (typeof out["package.json"] === "string") { + try { + const pkg = JSON.parse(out["package.json"]) as Record; + pkg.name = project; + out["package.json"] = JSON.stringify(pkg, null, 2) + "\n"; + } catch { + /* leave the committed package.json as-is if it is not valid JSON */ + } + } + + for (const key of Object.keys(out)) { + if (key.endsWith(".csproj") && !key.includes("/") && key !== `${project}.csproj`) { + out[`${project}.csproj`] = out[key]; + delete out[key]; + } + } + + out["azure.yaml"] = buildAzureYaml(project, yamlLanguage, host); + return out; +} + +export const REFERENCE_ARCHITECTURES: ReferenceArchitecture[] = [ + { + id: "react-spa-functions", + name: "React SPA + Azure Functions", + description: "A React single-page app with an Azure Functions API backend. Deploys to Azure Static Web Apps. (Recommended)", + language: "ts", + host: "staticwebapp", + files: (project) => withProjectName(readSampleTree("react-spa-functions"), project, "js", "staticwebapp"), + }, + { + id: "csharp-web", + name: "C# Web App on Azure Container Apps (security-approved)", + description: + "An ASP.NET Core app wired to SPE via Microsoft Graph, deployed to Azure Container Apps " + + "with a user-assigned managed identity + federated credential (no secrets). Based on the " + + "ODSP security-approved azd template. Enterprise-friendly.", + language: "dotnet", + host: "containerapp", + files: (project) => withProjectName(readSampleTree("csharp-web"), project, "dotnet", "containerapp"), + }, +]; + +export function findArchitecture(id: string): ReferenceArchitecture | undefined { + return REFERENCE_ARCHITECTURES.find((a) => a.id === id); +} diff --git a/src/resources.ts b/src/resources.ts new file mode 100644 index 0000000..7af91a4 --- /dev/null +++ b/src/resources.ts @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * MCP Resources — reference architectures and copy/paste operational guides. + * Each reference architecture is exposed as a JSON manifest. Static guides are + * inline text resources so clients can retrieve them without network access. + */ + +import { REFERENCE_ARCHITECTURES, findArchitecture } from "./reference-architectures.js"; + +const ARCH_URI_PREFIX = "spe://reference-architectures/"; + +interface StaticResource { + uri: string; + name: string; + description: string; + mimeType: string; + text: string; +} + +const STATIC_RESOURCES: StaticResource[] = [ + { + uri: "spe://client-config/vscode", + name: "VS Code MCP configuration", + description: "Copy/paste .vscode/mcp.json block for @microsoft/spe-mcp.", + mimeType: "application/json", + text: `{ + "servers": { + "spe": { + "type": "stdio", + "command": "npx", + "args": ["-y", "-p", "@microsoft/spe-mcp", "spe-mcp", "start"] + } + } +} +`, + }, + { + uri: "spe://client-config/claude-desktop", + name: "Claude Desktop MCP configuration", + description: "Copy/paste claude_desktop_config.json block for @microsoft/spe-mcp.", + mimeType: "application/json", + text: `{ + "mcpServers": { + "spe": { + "command": "npx", + "args": ["-y", "-p", "@microsoft/spe-mcp", "spe-mcp", "start"] + } + } +} +`, + }, + { + uri: "spe://client-config/cursor", + name: "Cursor MCP configuration", + description: "Copy/paste Cursor MCP server block for @microsoft/spe-mcp.", + mimeType: "application/json", + text: `{ + "mcpServers": { + "spe": { + "command": "npx", + "args": ["-y", "-p", "@microsoft/spe-mcp", "spe-mcp", "start"] + } + } +} +`, + }, + { + uri: "spe://guides/auth-consent-model", + name: "SPE auth and consent model", + description: "Explains control-plane provisioning, content-plane opt-in access, and step-up consent.", + mimeType: "text/markdown", + text: `# SPE MCP auth and consent model + +## Bootstrap mode + +By default the server uses the developer's Azure CLI session for bootstrap/control-plane work. Sign in once before starting an MCP client: + +\`\`\`bash +az login --allow-no-subscriptions +\`\`\` + +## Control plane + +Control-plane tools create and manage SPE infrastructure: owning Entra app, container type, registration, containers, permissions, and billing. Examples include \`status_get\`, \`project_provision\`, \`container_type_create\`, \`container_type_register\`, \`container_create\`, \`billing_setup\`, and \`project_cleanup\`. + +## Content plane + +Content-plane tools read or manage files inside containers. They are off by default and fail closed until the user opts in with \`content_access_grant\` and \`confirm=true\`. Examples include \`project_seed_sample_data\`, \`content_file_upload\`, \`content_folder_create\`, \`content_search\`, \`content_file_preview\`, and \`content_sharing_manage\`. Access can be revoked with \`content_access_revoke\`. + +## Step-up consent and Conditional Access + +Standard billing performs Azure Resource Manager writes. If Conditional Access requires MFA or an auth-context step-up, retry after an interactive ARM-scoped sign-in: + +\`\`\`bash +az login --scope https://management.core.windows.net//.default --tenant +\`\`\` + +Graph/SPE scope errors usually mean the owning public-client app needs the delegated SPE permissions and tenant/user consent. +`, + }, + { + uri: "spe://runbooks/billing-trial-vs-standard", + name: "Billing runbook: trial vs standard", + description: "Operational guidance for trial billing, standard billing, and billing troubleshooting.", + mimeType: "text/markdown", + text: `# SPE billing runbook + +## Trial billing + +- Choose \`billingClassification=trial\` for a no-cost developer evaluation. +- Trial container types expire after 30 days. +- Trial cleanup is safe by default: \`project_cleanup\` previews first, then deletes trial resources only when re-run with \`confirm=true\`. + +## Standard billing + +- Standard billing must be selected when the container type is created: \`billingClassification=standard\`. +- A trial container type cannot be converted to standard by this server. +- Use \`azure_subscriptions_list\` and \`azure_resource_groups_list\` to choose the billing location. +- Run \`billing_setup\` first without \`confirm\` to preview. Re-run with \`confirm=true\` only after explicit approval. +- \`billing_setup\` registers the \`Microsoft.Syntex\` resource provider and creates a \`Microsoft.Syntex/accounts\` billing account that links the container type to the selected subscription/resource group/region. +- Verify with \`billing_check\`. + +## Common failures + +- Azure CLI not signed in: run \`az login --allow-no-subscriptions\`. +- ARM Conditional Access claims challenge: run \`az login --scope https://management.core.windows.net//.default --tenant \`. +- \`Microsoft.Syntex\` RP not registered or still registering: retry after registration completes. +- Container type is trial: create a new standard container type; do not call \`billing_setup\` on the trial type. +`, + }, +]; + +export const SPE_RESOURCES = [ + ...REFERENCE_ARCHITECTURES.map((a) => ({ + uri: `${ARCH_URI_PREFIX}${a.id}`, + name: a.name, + description: a.description, + mimeType: "application/json", + })), + ...STATIC_RESOURCES.map(({ uri, name, description, mimeType }) => ({ uri, name, description, mimeType })), +]; + +export function readResource(uri: string) { + const staticResource = STATIC_RESOURCES.find((r) => r.uri === uri); + if (staticResource) { + return { + contents: [ + { + uri, + mimeType: staticResource.mimeType, + text: staticResource.text, + }, + ], + }; + } + + if (!uri.startsWith(ARCH_URI_PREFIX)) { + throw new Error(`Unknown resource URI: ${uri}`); + } + const id = uri.slice(ARCH_URI_PREFIX.length); + const arch = findArchitecture(id); + if (!arch) { + throw new Error(`Unknown reference architecture: ${id}`); + } + const manifest = { + id: arch.id, + name: arch.name, + description: arch.description, + language: arch.language, + files: Object.keys(arch.files(arch.id)), + }; + return { + contents: [ + { + uri, + mimeType: "application/json", + text: JSON.stringify(manifest, null, 2), + }, + ], + }; +} diff --git a/src/responses.ts b/src/responses.ts new file mode 100644 index 0000000..1d60dd7 --- /dev/null +++ b/src/responses.ts @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { McpToolResult } from "./types.js"; + +export function ok(data: T, summary = "OK"): McpToolResult { + const structuredContent = { ok: true, data }; + return { + content: [ + { type: "text" as const, text: summary }, + { type: "text" as const, text: JSON.stringify(structuredContent, null, 2) }, + ], + structuredContent, + }; +} + +export function fail(code: string, message: string, suggestion?: string): McpToolResult { + const structuredContent = { + ok: false, + error: { + code, + message, + ...(suggestion ? { suggestion } : {}), + }, + }; + return { + content: [ + { type: "text" as const, text: `Error: ${message}${suggestion ? `\nSuggestion: ${suggestion}` : ""}` }, + { type: "text" as const, text: JSON.stringify(structuredContent, null, 2) }, + ], + structuredContent, + isError: true, + }; +} + diff --git a/src/secure-fs.test.ts b/src/secure-fs.test.ts new file mode 100644 index 0000000..e06825e --- /dev/null +++ b/src/secure-fs.test.ts @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + existsSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir, platform } from "node:os"; +import { join } from "node:path"; +import { ensureSecureDir, writeSecureFile, readSecureFile } from "./secure-fs.js"; + +// POSIX permission bits under test, named for readability (see secure-fs.ts). +// 0o700 = rwx------ (owner-only, directories) 0o600 = rw------- (owner-only, files) +const OWNER_RWX = 0o700; +const OWNER_RW = 0o600; +// Mask that keeps only the 9 low permission bits (rwxrwxrwx), stripping the +// file-type / setuid bits from statSync().mode so we can compare perms directly. +const PERMISSION_MASK = 0o777; + +// POSIX mode bits are only enforced off-Windows. On Windows these assertions +// are skipped (ACL governs instead); the cross-platform tests below still run. +const isPosix = platform() !== "win32"; + +describe("secure-fs (SEC-003 owner-only credential/state files)", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "spe-mcp-securefs-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + // Runs on every platform: verifies the happy path works and, importantly, + // does not throw on Windows where chmod/POSIX modes are no-ops. + it("creates a directory and writes a file without throwing (cross-platform)", () => { + const sub = join(dir, "nested", "cache"); + expect(() => ensureSecureDir(sub)).not.toThrow(); + expect(existsSync(sub)).toBe(true); + + const file = join(sub, "token-cache.json"); + expect(() => writeSecureFile(file, '{"secret":"x"}')).not.toThrow(); + expect(existsSync(file)).toBe(true); + }); + + // Re-writing an existing file must also succeed on every platform (on POSIX + // this exercises the chmod-repair branch; on Windows it must simply not throw). + it("overwrites an existing file without throwing (cross-platform)", () => { + const file = join(dir, "token-cache.json"); + writeSecureFile(file, "first"); + expect(() => writeSecureFile(file, "second")).not.toThrow(); + expect(existsSync(file)).toBe(true); + }); + + it.runIf(isPosix)("writes the file with owner-only (0o600) permissions", () => { + const file = join(dir, "token-cache.json"); + writeSecureFile(file, "data"); + const mode = statSync(file).mode & PERMISSION_MASK; + expect(mode).toBe(OWNER_RW); + }); + + it.runIf(isPosix)("creates the directory with owner-only (0o700) permissions", () => { + const sub = join(dir, "secure-dir"); + ensureSecureDir(sub); + const mode = statSync(sub).mode & PERMISSION_MASK; + expect(mode).toBe(OWNER_RWX); + }); + + it.runIf(isPosix)("repairs permissions on a pre-existing world-readable file", () => { + const file = join(dir, "legacy-cache.json"); + writeFileSync(file, "old", { mode: 0o644 }); // rw-r--r-- (world-readable) + expect(statSync(file).mode & PERMISSION_MASK).toBe(0o644); + + writeSecureFile(file, "new"); + expect(statSync(file).mode & PERMISSION_MASK).toBe(OWNER_RW); + }); +}); + +describe("secure-fs — fail-closed hardening (symlink / TOCTOU / perms)", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "spe-mcp-securefs-h-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("readSecureFile returns null for a missing file and round-trips content (cross-platform)", () => { + const file = join(dir, "cache.json"); + expect(readSecureFile(file)).toBeNull(); + writeSecureFile(file, "hello"); + expect(readSecureFile(file)).toBe("hello"); + }); + + it.runIf(isPosix)("writeSecureFile refuses to follow a symlinked target (O_NOFOLLOW)", () => { + const real = join(dir, "outside.json"); + writeFileSync(real, "original", { mode: 0o600 }); + const link = join(dir, "link.json"); + symlinkSync(real, link); + expect(() => writeSecureFile(link, "attacker")).toThrow(); + // The real file must NOT have been overwritten through the symlink. + expect(readFileSync(real, "utf-8")).toBe("original"); + }); + + it.runIf(isPosix)("readSecureFile refuses to follow a symlinked cache file", () => { + const real = join(dir, "secret.json"); + writeFileSync(real, "secret", { mode: 0o600 }); + const link = join(dir, "cache-link.json"); + symlinkSync(real, link); + expect(() => readSecureFile(link)).toThrow(); + }); + + it.runIf(isPosix)("ensureSecureDir refuses a symlinked directory", () => { + const realDir = join(dir, "real"); + mkdirSync(realDir, { mode: 0o700 }); + const linkDir = join(dir, "link"); + symlinkSync(realDir, linkDir); + expect(() => ensureSecureDir(linkDir)).toThrow(); + }); + + it.runIf(isPosix)("ensureSecureDir repairs a group/other-accessible directory to 0o700", () => { + const sub = join(dir, "loose"); + mkdirSync(sub, { mode: 0o755 }); + ensureSecureDir(sub); // owner can repair -> must not throw + expect(statSync(sub).mode & PERMISSION_MASK).toBe(0o700); + }); +}); diff --git a/src/secure-fs.ts b/src/secure-fs.ts new file mode 100644 index 0000000..8255658 --- /dev/null +++ b/src/secure-fs.ts @@ -0,0 +1,266 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Restrictive filesystem helpers for credential / state material (SEC-003). + * + * The token cache (MSAL refresh tokens) and provisioning state live under the + * resolved data directory (default `~/.spe-mcp/`, or a `--data-dir` / + * `SPE_DATA_DIR` override). On POSIX, default umask yields world-readable + * `0644` files in a `0755` directory — so on a shared host another local user + * could read the refresh token. We therefore create the directory `0o700` and + * write files `0o600`. + * + * Historically the data directory was ALWAYS the user-owned `~/.spe-mcp`, so a + * fail-open, symlink-following implementation was safe. Now that the directory + * is caller-supplied (potentially from untrusted workspace config), that path + * crosses a trust boundary and these helpers are hardened to FAIL CLOSED: + * - `ensureSecureDir` refuses a directory that is a symlink, not owned by the + * current user, or accessible to group/other (POSIX). On Windows an override + * outside `%USERPROFILE%` gets an owner-only DACL applied via `icacls`, or is + * refused. + * - `writeSecureFile` / `readSecureFile` open the final component with + * `O_NOFOLLOW` and verify the resulting fd with `fstat` (regular file, owner) + * BEFORE writing/reading, and `chmod` the fd — never the path — to defeat + * symlink/TOCTOU swaps. + * A refusal throws, so callers that persist secrets (the MSAL cache writer) + * simply skip persistence and force a fresh interactive sign-in rather than + * writing a refresh token to an insecure location. + * + * On Windows the POSIX mode bits are largely ignored by the FS; protection + * comes from the profile ACL (default path) or the icacls-applied owner-only + * DACL (off-profile override). + */ + +import { execFileSync } from "node:child_process"; +import { + chmodSync, + closeSync, + constants as fsConstants, + existsSync, + fchmodSync, + fstatSync, + ftruncateSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { resolve, sep } from "node:path"; +import { AppError } from "./errors.js"; + +/** + * POSIX permission modes as octal literals (the leading `0o` is octal in JS). + * Each octal digit is a 3-bit rwx group for owner/group/other respectively: + * 0o700 = rwx------ → owner read/write/execute(traverse), no group/other. + * 0o600 = rw------- → owner read/write, no group/other. + * Directories need the execute bit (`x`) to be traversable, hence 0o700 for + * dirs vs 0o600 for files. + */ +const OWNER_RWX = 0o700; // rwx------ (directories) +const OWNER_RW = 0o600; // rw------- (files) + +/** + * These POSIX mode bits are only meaningful on POSIX platforms. On Windows the + * FS ignores them and `fs.chmod` is a near no-op (it can only toggle the + * read-only attribute), so we gate the permission calls behind an explicit + * platform check. On Windows protection instead comes from the per-user + * profile ACL on `%USERPROFILE%\.spe-mcp`. + */ +const IS_POSIX = process.platform !== "win32"; + +/** + * `O_NOFOLLOW` makes `open` fail with `ELOOP` if the final path component is a + * symlink, instead of following it to an attacker-chosen target. It is a POSIX + * flag; on Windows Node leaves it `undefined`, so we coalesce to `0` (no-op) and + * rely on the directory ACL there instead. + */ +const O_NOFOLLOW = (fsConstants.O_NOFOLLOW as number | undefined) ?? 0; + +/** + * Per-process memo of directories already validated as secure. Avoids repeating + * the stat / icacls work on the hot path (every state + cache write). The + * per-write fd checks in {@link writeSecureFile} still run every time, so this + * only caches the directory-level decision, not the file-level TOCTOU defense. + */ +const validatedDirs = new Set(); + +function insecureDir(dir: string, reason: string): AppError { + return new AppError("INSECURE_DATA_DIR", `Refusing to use data directory '${dir}': ${reason}.`, { + safeMessage: `Refusing to use an insecure data directory: ${reason}.`, + suggestion: + "Point --data-dir / SPE_DATA_DIR at a directory you own with owner-only permissions (a fresh directory under your home directory is safest).", + }); +} + +function insecureFile(path: string, reason: string): AppError { + return new AppError("INSECURE_CACHE_FILE", `Refusing to use file '${path}': ${reason}.`, { + safeMessage: `Refusing to use an insecure credential/state file: ${reason}.`, + }); +} + +/** True when `dir` resolves to the home directory or something beneath it. */ +function isUnderHome(dir: string): boolean { + const home = resolve(homedir()); + const d = resolve(dir); + return d === home || d.startsWith(home + sep); +} + +/** + * Windows: apply an owner-only DACL to an off-profile override directory, or + * throw. `/inheritance:r` strips inherited ACEs; `/grant:r :(OI)(CI)F` + * replaces the user's ACE with full control inherited by files + subdirs. + * + * icacls is invoked by absolute path (not a bare name) so a planted + * `icacls.exe` on PATH / in the CWD cannot be run in its place. + * + * KNOWN LIMITATION (tracked as a follow-up under Feature AB#3116729): this does + * NOT remove pre-existing *explicit* ACEs and does not verify the directory + * owner (Node has no cheap owner read on Windows). An attacker who can + * pre-create the exact override path with a permissive explicit ACE is not + * fully mitigated here. The default `~/.spe-mcp` (under %USERPROFILE%) is + * unaffected — it inherits the per-user profile ACL and never reaches this path. + */ +function secureWindowsDirAclOrThrow(dir: string): void { + const user = process.env.USERDOMAIN + ? `${process.env.USERDOMAIN}\\${process.env.USERNAME}` + : process.env.USERNAME; + if (!user) { + throw insecureDir(dir, "the current Windows user could not be determined to set an owner-only ACL"); + } + const icacls = process.env.SystemRoot + ? `${process.env.SystemRoot}\\System32\\icacls.exe` + : "C:\\Windows\\System32\\icacls.exe"; + try { + execFileSync(icacls, [dir, "/inheritance:r", "/grant:r", `${user}:(OI)(CI)F`], { + stdio: "ignore", + }); + } catch { + throw insecureDir(dir, "an owner-only ACL could not be applied to this off-profile path"); + } +} + +/** + * Create a directory (recursively) with owner-only permissions and FAIL CLOSED + * if it cannot be verified as owner-only. Safe to call repeatedly (memoized). + */ +export function ensureSecureDir(dir: string): void { + const key = resolve(dir); + if (validatedDirs.has(key)) return; + + if (!existsSync(dir)) { + // `mode` is honored on POSIX at creation time; ignored (harmless) on Windows. + mkdirSync(dir, { recursive: true, mode: OWNER_RWX }); + } + + // Fail-closed validation. lstat ONLY the final component (not the whole + // chain) so legitimately symlinked parents (e.g. macOS /var -> /private/var, + // or a symlinked home) don't trip the check. + const st = lstatSync(key); + if (st.isSymbolicLink()) throw insecureDir(dir, "it is a symlink"); + if (!st.isDirectory()) throw insecureDir(dir, "it is not a directory"); + + if (IS_POSIX) { + // Repair perms that may predate this hardening, then re-verify. If we are + // not the owner, chmod throws EPERM and the ownership check below rejects. + try { + chmodSync(key, OWNER_RWX); + } catch { + /* fall through to the ownership/mode check, which will reject */ + } + const uid = process.getuid?.(); + if (uid !== undefined && st.uid !== uid) { + throw insecureDir(dir, "it is owned by another user"); + } + const mode = lstatSync(key).mode & 0o777; + if (mode & 0o077 && !isUnderHome(dir)) { + // Group/other-accessible. For an explicit off-home override (the + // untrusted-input case) this is fail-closed. For the user's own home tree + // (the default ~/.spe-mcp) we stay best-effort: a mode-ignoring filesystem + // (WSL DrvFs, some NFS/CIFS) must not turn the default path into a hard + // failure — ownership + symlink checks above still apply there. + throw insecureDir(dir, "it is accessible to group or other (expected 0o700)"); + } + } else if (!isUnderHome(dir)) { + // Windows override outside %USERPROFILE% has no inherited profile ACL. + secureWindowsDirAclOrThrow(dir); + } + + validatedDirs.add(key); +} + +/** + * Write a file with owner-only (0o600) permissions, opening with `O_NOFOLLOW` + * and verifying the fd (regular file, owner) before writing. Repairs a + * pre-existing world-readable file via `fchmod` on the fd (never the path). + * Throws (fail-closed) if the target is a symlink or owned by another user. + */ +export function writeSecureFile(path: string, data: string): void { + // No O_TRUNC: we truncate only AFTER verifying the fd below, so a + // foreign-owned/symlinked target is never emptied before the refusal throws. + const flags = fsConstants.O_WRONLY | fsConstants.O_CREAT | O_NOFOLLOW; + let fd: number; + try { + fd = openSync(path, flags, OWNER_RW); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === "ELOOP") { + throw insecureFile(path, "it is a symlink"); + } + throw err; + } + try { + if (IS_POSIX) { + const st = fstatSync(fd); + if (!st.isFile()) throw insecureFile(path, "it is not a regular file"); + const uid = process.getuid?.(); + if (uid !== undefined && st.uid !== uid) { + throw insecureFile(path, "it is owned by another user"); + } + // chmod the fd (never the path) so a swap between check and change can't + // redirect us. `mode` on open only applies when creating a NEW file, so + // this also repairs a pre-existing world-readable file. + fchmodSync(fd, OWNER_RW); + } + // Truncate only now (post-verification), then write. writeFileSync(fd, …) + // loops until every byte is flushed, handling short writes / EINTR that a + // single writeSync could leave partially written. + ftruncateSync(fd, 0); + writeFileSync(fd, data, "utf-8"); + } finally { + closeSync(fd); + } +} + +/** + * Read a credential/state file, opening with `O_NOFOLLOW` and verifying the fd + * (regular file, owner) before reading. Returns `null` when the file does not + * exist; throws (fail-closed) if the final component is a symlink or is owned + * by another user, so a planted symlink is never read through. + */ +export function readSecureFile(path: string): string | null { + if (!existsSync(path)) return null; + let fd: number; + try { + fd = openSync(path, fsConstants.O_RDONLY | O_NOFOLLOW); + } catch (err) { + const code = (err as NodeJS.ErrnoException)?.code; + if (code === "ELOOP") throw insecureFile(path, "it is a symlink"); + if (code === "ENOENT") return null; + throw err; + } + try { + if (IS_POSIX) { + const st = fstatSync(fd); + if (!st.isFile()) throw insecureFile(path, "it is not a regular file"); + const uid = process.getuid?.(); + if (uid !== undefined && st.uid !== uid) { + throw insecureFile(path, "it is owned by another user"); + } + } + return readFileSync(fd, { encoding: "utf-8" }); + } finally { + closeSync(fd); + } +} diff --git a/src/server-instructions.test.ts b/src/server-instructions.test.ts new file mode 100644 index 0000000..3be3fc8 --- /dev/null +++ b/src/server-instructions.test.ts @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, it, expect } from "vitest"; +import { SPE_SERVER_INSTRUCTIONS } from "./server-instructions.js"; + +/** + * The primer is prepended to model context on every session, so it must stay + * accurate and concise. These assertions lock in the load-bearing content (the + * pieces a client relays to the model) and guard against silent drift or bloat. + */ +describe("SPE_SERVER_INSTRUCTIONS primer", () => { + it("is a non-trivial, single string", () => { + expect(typeof SPE_SERVER_INSTRUCTIONS).toBe("string"); + expect(SPE_SERVER_INSTRUCTIONS.length).toBeGreaterThan(200); + }); + + it("stays concise enough to be a cheap per-session context tax", () => { + // ~4 chars/token heuristic; keep well under ~600 tokens so the primer never + // becomes an expensive prefix on every request. + expect(SPE_SERVER_INSTRUCTIONS.length).toBeLessThan(2400); + }); + + it("teaches the SPE mental model in build order", () => { + const concepts = [ + "Owning application", + "Container type", + "Registration", + "Containers", + "Content", + ]; + // Assert both presence AND the load-bearing order the primer relies on to + // route "what must exist before X" — not just that the words appear. + let prevIndex = -1; + for (const concept of concepts) { + const idx = SPE_SERVER_INSTRUCTIONS.indexOf(concept); + expect(idx, `"${concept}" missing from primer`).toBeGreaterThan(-1); + expect(idx, `"${concept}" out of order in primer`).toBeGreaterThan(prevIndex); + prevIndex = idx; + } + }); + + it("gives routing-first guidance that matches the real toolset", () => { + // Every tool referenced here must exist in the registry; these are the + // first-request entry points an agent should reach for. + for (const tool of [ + "status_get", + "project_provision", + "project_app_create", + "billing_check", + "billing_setup", + "docs_search", + "docs_fetch", + ]) { + expect(SPE_SERVER_INSTRUCTIONS).toContain(tool); + } + }); + + it("states the owning-app precondition using the typed error code", () => { + // Keeps the primer consistent with the OWNING_APP_REQUIRED error tools throw. + expect(SPE_SERVER_INSTRUCTIONS).toContain("OWNING_APP_REQUIRED"); + expect(SPE_SERVER_INSTRUCTIONS).toMatch(/no restart|does NOT leave/i); + }); + + it("cites the SharePoint Embedded Samples repo for solving customer problems", () => { + expect(SPE_SERVER_INSTRUCTIONS).toContain( + "https://github.com/microsoft/SharePoint-Embedded-Samples", + ); + }); +}); diff --git a/src/server-instructions.ts b/src/server-instructions.ts new file mode 100644 index 0000000..d7896f7 --- /dev/null +++ b/src/server-instructions.ts @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Server-level primer surfaced through the MCP `instructions` field. + * + * The MCP `initialize` handshake lets a server return a short natural-language + * `instructions` string that clients (VS Code, Claude, Cursor, …) hand to the + * model as context BEFORE any tool is called. Production servers such as + * github-mcp-server use this to teach the model the product's mental model and + * how to route the first request. We do the same so an agent understands how + * SharePoint Embedded is structured, what must exist before container/content + * work can succeed, and where to point a developer who is solving a real + * customer problem. + * + * Keep this concise (~300-400 tokens): it is prepended to context on every + * session, so length is a per-request tax. It complements — but never replaces — + * the per-tool descriptions, the pull-based `resources`, and the guided + * `prompts`; those remain the authoritative, on-demand detail. + */ +export const SPE_SERVER_INSTRUCTIONS = `SharePoint Embedded (SPE) is cloud-managed, Microsoft 365-backed document storage that a developer's own app owns and builds on. This server drives SPE end to end from a local machine. + +HOW SPE WORKS (build in this order): +1. Owning application — an Entra app you own that owns the storage. Nothing else can exist until this does. +2. Container type — the billable "class" of storage your app defines: a free trial type for evaluation, or a pay-as-you-go ("standard") type metered through Azure (Syntex/RaaS) for production. The trial-vs-standard choice is made when the container type is created and cannot be changed later. +3. Registration — authorizes a container type to operate in a tenant. +4. Containers — the storage instances (like drives) that actually hold content. +5. Content — files, folders, search, and sharing inside a container. + +START HERE (route the first request, don't guess): +- Unsure of the current state? Call status_get first — it reports whether an owning app, container type, registration, and billing already exist. +- No owning app yet? Container-type, container, and content tools fail with OWNING_APP_REQUIRED until one is configured. Run project_provision for the guided end-to-end flow, or project_app_create for just the app. Sign-in is interactive and in-process — the developer does NOT leave the chat or restart the server. +- Heading to production? Configure metered billing with billing_check / billing_setup before creating production containers. +- Need authoritative facts (APIs, limits, permissions)? Prefer docs_search / docs_fetch (Microsoft Learn) over recalling from memory. +- Destructive actions (deletes, teardown) are confirmation-gated, and the server can be launched in --read-only mode. + +SOLVING CUSTOMER PROBLEMS — cite runnable patterns from the SharePoint Embedded Samples repo (https://github.com/microsoft/SharePoint-Embedded-Samples) when a developer asks how to apply SPE to a real scenario: +- "Custom Apps/" — boilerplate web apps demonstrating end-to-end SPE integration. +- "AI/ocr" — webhook-triggered document processing with Azure Document Intelligence. +- "AI/copilot" — surface container content in Microsoft 365 Copilot. +- "Tools/migrate-from-blob-storage" — move existing files from Azure Blob Storage into SPE.`; diff --git a/src/server-readiness.test.ts b/src/server-readiness.test.ts new file mode 100644 index 0000000..5f8d30f --- /dev/null +++ b/src/server-readiness.test.ts @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for the TCP readiness probe. + * + * node:net is mocked so no real sockets open. `connectBehavior` controls whether + * a connection attempt succeeds ('connect') or fails ('error'); timeouts are + * kept tiny so the never-ready path resolves fast. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { EventEmitter } from "node:events"; + +let connectBehavior: "connect" | "error" = "connect"; +// Optional per-host override: when set, a host present here uses its mapped +// behavior; hosts not present fall back to `connectBehavior`. +let hostBehavior: Record | null = null; + +vi.mock("node:net", () => ({ + connect: vi.fn((opts: { host?: string }) => { + const host = opts?.host ?? ""; + const socket = new EventEmitter() as EventEmitter & { + destroy: () => void; + setTimeout: (ms: number, cb: () => void) => void; + }; + socket.destroy = () => {}; + socket.setTimeout = () => {}; + queueMicrotask(() => { + const behavior = hostBehavior && host in hostBehavior ? hostBehavior[host] : connectBehavior; + if (behavior === "connect") socket.emit("connect"); + else socket.emit("error", new Error("ECONNREFUSED")); + }); + return socket; + }), +})); + +import { waitForServerReady } from "./server-readiness.js"; + +beforeEach(() => { + vi.clearAllMocks(); + connectBehavior = "connect"; + hostBehavior = null; +}); + +describe("waitForServerReady", () => { + it("resolves true as soon as a connection is accepted", async () => { + connectBehavior = "connect"; + const ready = await waitForServerReady(5173, { timeoutMs: 100, intervalMs: 5 }); + expect(ready).toBe(true); + }); + + it("resolves false when the port never accepts a connection within the timeout", async () => { + connectBehavior = "error"; + const ready = await waitForServerReady(5173, { timeoutMs: 20, intervalMs: 5 }); + expect(ready).toBe(false); + }); + + it("makes at least one attempt even with a zero timeout", async () => { + connectBehavior = "connect"; + const ready = await waitForServerReady(5173, { timeoutMs: 0 }); + expect(ready).toBe(true); + }); + + it("detects a server bound to IPv6 ::1 only (Vite default) even though 127.0.0.1 refuses", async () => { + // Vite 6 binds `localhost`, which on Windows is IPv6 `::1` only. An IPv4-only + // probe would miss it; the dual-stack probe must still succeed. + hostBehavior = { "127.0.0.1": "error", "::1": "connect" }; + const ready = await waitForServerReady(5173, { timeoutMs: 100, intervalMs: 5 }); + expect(ready).toBe(true); + }); + + it("detects a server bound to IPv4 127.0.0.1 only even though ::1 refuses", async () => { + hostBehavior = { "127.0.0.1": "connect", "::1": "error" }; + const ready = await waitForServerReady(5173, { timeoutMs: 100, intervalMs: 5 }); + expect(ready).toBe(true); + }); +}); diff --git a/src/server-readiness.ts b/src/server-readiness.ts new file mode 100644 index 0000000..ade75d4 --- /dev/null +++ b/src/server-readiness.ts @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * TCP readiness probe for a locally-launched dev server. + * + * `project_run_local` spawns the dev server detached and previously returned the + * URL as soon as the process *launched* — even though the server may not yet be + * accepting connections (or may crash during startup after the spawn grace + * window). Handing back a URL that 404s/refuses is a false success. + * + * `waitForServerReady` polls the derived port with a bounded TCP connect until + * the server accepts a connection (ready) or the timeout elapses (not ready). + * It is isolated in its own module so `project_run_local` can be unit-tested + * with the probe mocked, while production uses a real `node:net` connect. + */ + +import { connect } from "node:net"; + +export interface ReadinessOptions { + /** + * Host to probe. When set, ONLY this host is probed (back-compat / explicit + * override). When omitted, both IPv4 and IPv6 loopback are probed (see + * `hosts`), because dev servers differ in which family they bind: Vite 6 + * defaults to `localhost`, which on Windows resolves to IPv6 `::1` only, so an + * IPv4-only `127.0.0.1` probe would never connect even though the server is up. + */ + host?: string; + /** Loopback hosts to probe when `host` is not set. Default: ['127.0.0.1', '::1']. */ + hosts?: string[]; + /** Total time to wait for readiness before giving up. Default: 15000ms. */ + timeoutMs?: number; + /** Delay between connection attempts. Default: 500ms. */ + intervalMs?: number; + /** Per-attempt connect timeout. Default: 1000ms. */ + connectTimeoutMs?: number; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Attempt a single TCP connection to `host:port`. Resolves true if the + * connection is accepted (server is listening), false on any error/timeout. + */ +function tryConnect(host: string, port: number, connectTimeoutMs: number): Promise { + return new Promise((resolve) => { + let settled = false; + const socket = connect({ host, port }); + const finish = (ok: boolean): void => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(ok); + }; + socket.once("connect", () => finish(true)); + socket.once("error", () => finish(false)); + socket.setTimeout(connectTimeoutMs, () => finish(false)); + }); +} + +/** + * Poll `port` until it accepts a TCP connection or the overall timeout elapses. + * Each round tries every probe host (IPv4 + IPv6 loopback by default) and + * returns true if ANY accepts — so a server bound to only one address family + * (e.g. Vite on IPv6 `::1`) is still detected. Returns false if no host became + * reachable within `timeoutMs`. + */ +export async function waitForServerReady(port: number, options: ReadinessOptions = {}): Promise { + const { + host, + hosts, + timeoutMs = 15_000, + intervalMs = 500, + connectTimeoutMs = 1_000, + } = options; + + const probeHosts = host + ? [host] + : hosts && hosts.length > 0 + ? hosts + : ["127.0.0.1", "::1"]; + + const deadline = Date.now() + timeoutMs; + // Always make at least one attempt, even if timeoutMs is 0. + for (;;) { + for (const h of probeHosts) { + if (await tryConnect(h, port, connectTimeoutMs)) return true; + } + if (Date.now() >= deadline) return false; + await sleep(intervalMs); + } +} diff --git a/src/session.test.ts b/src/session.test.ts new file mode 100644 index 0000000..11a7523 --- /dev/null +++ b/src/session.test.ts @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for the per-process session identity + confirmation helpers + * (session.ts) — the core of the always-ask-on-restart behavior (GitHub PR #3 + * review, r-appgate). + * + * state.js is mocked with an in-memory store so stampContextConfirmed persists + * through the same writeState the real helper uses, without touching disk. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { ProvisioningState } from "./state.js"; + +let stateStore: ProvisioningState; + +vi.mock("./state.js", () => ({ + readState: () => ({ ...stateStore }), + writeState: (patch: Partial) => { + stateStore = { ...stateStore, ...patch }; + return { ...stateStore }; + }, +})); + +import { + getSessionId, + isContextConfirmedThisSession, + stampContextConfirmed, + isSessionConfirmed, +} from "./session.js"; + +beforeEach(() => { + stateStore = {}; +}); + +describe("getSessionId", () => { + it("returns a stable, non-empty id within a process (two calls are equal)", () => { + const a = getSessionId(); + const b = getSessionId(); + expect(a).toBeTruthy(); + expect(typeof a).toBe("string"); + expect(a).toBe(b); + }); +}); + +describe("isContextConfirmedThisSession", () => { + it("is false when no confirmedSessionId is present", () => { + expect(isContextConfirmedThisSession({})).toBe(false); + expect(isContextConfirmedThisSession({ appId: "app-1" })).toBe(false); + }); + + it("is false when confirmedSessionId is a DIFFERENT (prior-session) id", () => { + // Simulates state written by a previous process (restart => new SESSION_ID). + expect(isContextConfirmedThisSession({ confirmedSessionId: "some-other-session" })).toBe(false); + }); + + it("is true only when confirmedSessionId matches the current session id", () => { + expect(isContextConfirmedThisSession({ confirmedSessionId: getSessionId() })).toBe(true); + }); +}); + +describe("stampContextConfirmed", () => { + it("writes confirmedSessionId (current session) and an ISO contextConfirmedAt", () => { + stampContextConfirmed(); + + expect(stateStore.confirmedSessionId).toBe(getSessionId()); + expect(stateStore.contextConfirmedAt).toBeTruthy(); + // Round-trips as a valid ISO-8601 timestamp. + expect(new Date(stateStore.contextConfirmedAt as string).toISOString()).toBe( + stateStore.contextConfirmedAt, + ); + }); + + it("merges an optional patch (e.g. resolved app fields) alongside the stamp", () => { + stampContextConfirmed({ appId: "app-xyz", appDisplayName: "Contoso Docs App" }); + + expect(stateStore.appId).toBe("app-xyz"); + expect(stateStore.appDisplayName).toBe("Contoso Docs App"); + expect(stateStore.confirmedSessionId).toBe(getSessionId()); + }); + + it("makes the session read as confirmed afterward", () => { + expect(isSessionConfirmed()).toBe(false); + stampContextConfirmed(); + expect(isSessionConfirmed()).toBe(true); + expect(isContextConfirmedThisSession({ ...stateStore })).toBe(true); + }); +}); diff --git a/src/session.ts b/src/session.ts new file mode 100644 index 0000000..930845f --- /dev/null +++ b/src/session.ts @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Per-process session identity + owning-context confirmation helpers. + * + * Motivation (GitHub PR #3 review, r-appgate): the server must ALWAYS re-ask + * the user "create a NEW owning app, or USE the EXISTING one from state?" on a + * fresh start — a restart must never silently reuse whatever app the persisted + * state happens to remember. Because each server restart is a new OS process, + * we mint one stable id per process at module load: it differs across restarts + * but is constant for the life of a single process. We treat the remembered + * owning app + container type as "confirmed" only once the user has answered + * under the CURRENT session id; a process that has not yet been confirmed is, + * by definition, freshly restarted and must ask again. + * + * `SESSION_ID` is intentionally a module-level constant — do NOT regenerate it + * per call, or every tool invocation would look like a new session and the + * confirmation would never "stick" within a run. + */ + +import { randomUUID } from "node:crypto"; +import { readState, writeState, type ProvisioningState } from "./state.js"; + +/** Stable per-process id. New process (restart) ⇒ new id. */ +const SESSION_ID = randomUUID(); + +/** The current process's session id (stable for the life of the process). */ +export function getSessionId(): string { + return SESSION_ID; +} + +/** + * True only when the given state was confirmed under THIS process's session id. + * Takes state as a parameter (rather than reading it) so callers can pass an + * already-loaded snapshot and tests can exercise it without stubbing readState. + */ +export function isContextConfirmedThisSession(state: ProvisioningState): boolean { + return !!state.confirmedSessionId && state.confirmedSessionId === getSessionId(); +} + +/** + * Mark the active owning app + container type as confirmed for THIS session and + * persist any accompanying state (e.g., the resolved app fields). Subsequent + * calls within the same process then proceed without re-asking; the next + * restart starts unconfirmed again. Merges through writeState (0o600 secure + * write) so existing state is preserved. + */ +export function stampContextConfirmed(patch?: Partial): void { + writeState({ + confirmedSessionId: getSessionId(), + contextConfirmedAt: new Date().toISOString(), + ...patch, + }); +} + +/** Convenience: read state and report confirmation in one call. */ +export function isSessionConfirmed(): boolean { + return isContextConfirmedThisSession(readState()); +} diff --git a/src/state.test.ts b/src/state.test.ts new file mode 100644 index 0000000..0277143 --- /dev/null +++ b/src/state.test.ts @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join, normalize } from "node:path"; +import { readState, writeState, clearState } from "./state.js"; +import { setDataDirOverride, getStateFile, __testing } from "./paths.js"; + +describe("state — per-instance isolation via the data-dir seam", () => { + const savedEnv = process.env.SPE_DATA_DIR; + let dirA: string; + let dirB: string; + + beforeEach(() => { + __testing.reset(); + delete process.env.SPE_DATA_DIR; + dirA = mkdtempSync(join(tmpdir(), "spe-mcp-state-A-")); + dirB = mkdtempSync(join(tmpdir(), "spe-mcp-state-B-")); + }); + + afterEach(() => { + __testing.reset(); + if (savedEnv === undefined) delete process.env.SPE_DATA_DIR; + else process.env.SPE_DATA_DIR = savedEnv; + rmSync(dirA, { recursive: true, force: true }); + rmSync(dirB, { recursive: true, force: true }); + }); + + it("writes state under the resolved data dir, not ~/.spe-mcp", () => { + setDataDirOverride(dirA); + writeState({ tenantId: "tenant-A" }); + expect(getStateFile()).toBe(join(normalize(dirA), "state.json")); + expect(readState().tenantId).toBe("tenant-A"); + }); + + it("keeps two instances isolated: writing dir B leaves dir A byte-identical", () => { + // Instance A writes its state. + setDataDirOverride(dirA); + writeState({ tenantId: "tenant-A", appId: "app-A" }); + const stateFileA = getStateFile(); + const bytesA = readFileSync(stateFileA); + + // Instance B (different data dir) writes DIFFERENT state. + __testing.reset(); + setDataDirOverride(dirB); + writeState({ tenantId: "tenant-B", appId: "app-B" }); + + // A's file is unchanged — no cross-instance clobber. + const bytesA2 = readFileSync(stateFileA); + expect(bytesA2.equals(bytesA)).toBe(true); + + // And each dir reflects only its own writes. + __testing.reset(); + setDataDirOverride(dirA); + expect(readState().tenantId).toBe("tenant-A"); + __testing.reset(); + setDataDirOverride(dirB); + expect(readState().tenantId).toBe("tenant-B"); + }); + + it("clearState removes only the resolving instance's state file", () => { + setDataDirOverride(dirA); + writeState({ tenantId: "tenant-A" }); + __testing.reset(); + setDataDirOverride(dirB); + writeState({ tenantId: "tenant-B" }); + + // Clear A; B must survive. + __testing.reset(); + setDataDirOverride(dirA); + clearState(); + expect(readState()).toEqual({}); + + __testing.reset(); + setDataDirOverride(dirB); + expect(readState().tenantId).toBe("tenant-B"); + }); + + it("golden default: with no override, state resolves to ~/.spe-mcp/state.json", () => { + // No setDataDirOverride, no env — the default path is byte-identical to the + // pre-feature hardcoded location. + expect(getStateFile()).toBe(join(normalize(join(homedir(), ".spe-mcp")), "state.json")); + }); +}); diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..d01bbc9 --- /dev/null +++ b/src/state.ts @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Provisioning state persistence. + * + * The SPE Builder flow spans multiple tool calls (create app → CT → register → + * container). We persist the resulting IDs to `~/.spe-mcp/state.json` so the + * flow is resumable/idempotent and `status_get` can report what exists. This is + * the MCP analogue of the full-setup skill's `.env.spe`. + * + * Cross-platform: there are no shell-command invocations here; the data + * directory and state-file paths come from the resolve-once seam in `paths.ts` + * (built with `node:path` + `os.homedir()`), so they resolve correctly on + * Windows (`%USERPROFILE%\.spe-mcp`) and POSIX (`~/.spe-mcp`) alike, and honor a + * `--data-dir` / `SPE_DATA_DIR` override. + */ + +import { existsSync, rmSync } from "node:fs"; +import { getDataDir, getStateFile } from "./paths.js"; +import { ensureSecureDir, readSecureFile, writeSecureFile } from "./secure-fs.js"; +import type { BillingClassification, OwnerScope } from "./types.js"; + +export interface ProvisioningState { + tenantId?: string; + /** Owning Entra app client (application) ID. */ + appId?: string; + /** Owning Entra app object ID. */ + appObjectId?: string; + appDisplayName?: string; + containerTypeId?: string; + containerTypeName?: string; + billingClassification?: BillingClassification; + azureSubscriptionId?: string; + resourceGroup?: string; + /** ARM resource id of the Microsoft.Syntex/accounts (RaaS) billing account. */ + syntexAccountResourceId?: string; + containerId?: string; + containerName?: string; + /** Whether content-plane (file read/manage) access has been granted. */ + contentAccessGranted?: boolean; + /** Reference architecture id last scaffolded (e.g., 'react-spa-functions', 'csharp-web'). */ + scaffoldArchitecture?: string; + /** Project name used by the last scaffold (drives azure.yaml service name on hydrate). */ + projectName?: string; + // ── Session confirmation (GitHub PR #3 review, r-appgate) ────────────────── + /** + * SESSION_ID (see session.ts) under which the user last confirmed the active + * owning app + container type. Because each restart is a new process with a + * new SESSION_ID, a value here that does NOT match the current session means + * the remembered context is unconfirmed and must be re-asked. + */ + confirmedSessionId?: string; + /** ISO-8601 timestamp of that confirmation (may be from a prior session). */ + contextConfirmedAt?: string; + /** + * Whether the confirmed owning app holds `FileStorageContainerType.Manage.All` + * (i.e., can enumerate ALL container types). When false/unknown, any cached + * container-type context may be stale. Left undefined when it cannot be + * cheaply inferred (undefined = "unknown", which suppresses the staleness + * warning; only an explicit `false` triggers it). + */ + owningAppManagesAllContainerTypes?: boolean; + /** + * The owning app's captured container-type authority intent (PR #3 review). + * Drives the least-privilege Graph scope set requested at app-create / + * provision time. Persisted so a resumed session reuses the same intent + * without re-eliciting. Defaults to "selected" (least privilege) when unset. + */ + ownerScope?: OwnerScope; +} + +export function readState(): ProvisioningState { + try { + // O_NOFOLLOW + owner check (readSecureFile): a symlinked or foreign-owned + // state.json is refused (throws → treated as empty) rather than followed, + // consistent with the writeState hardening. Returns null when absent. + const raw = readSecureFile(getStateFile()); + if (raw !== null) { + return JSON.parse(raw) as ProvisioningState; + } + } catch { + /* ignore corrupt or insecure state — treat as empty */ + } + return {}; +} + +export function writeState(patch: Partial): ProvisioningState { + const next = { ...readState(), ...patch }; + ensureSecureDir(getDataDir()); + // SEC-003: state can hold tenant/app/subscription IDs — owner-only (0o600). + writeSecureFile(getStateFile(), JSON.stringify(next, null, 2)); + return next; +} + +/** Delete the persisted provisioning state (used by cleanup). */ +export function clearState(): void { + try { + const stateFile = getStateFile(); + if (existsSync(stateFile)) { + rmSync(stateFile); + } + } catch { + /* ignore */ + } +} diff --git a/src/tooling/define-tool.ts b/src/tooling/define-tool.ts new file mode 100644 index 0000000..7941080 --- /dev/null +++ b/src/tooling/define-tool.ts @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * `defineTool` — the canonical factory for MCP tools in this server. + * + * WHY THIS EXISTS: every tool must advertise a JSON `inputSchema` (so clients + * know how to call it) AND validate the arguments it actually receives at + * runtime. Hand-writing both means two declarations of the same contract that + * silently drift — a field marked `required` in the advertised schema but only + * truthiness-checked in the handler, an `as string` cast that throws on a number, + * a JSON Schema that says `number` while the handler coerces strings, etc. + * + * `defineTool` collapses that to ONE source of truth: a Zod object schema. From + * that single declaration it derives all three things, guaranteeing they cannot + * diverge: + * 1. the advertised `inputSchema` — generated via `zodToJsonSchema`, so the + * published JSON Schema always matches what is enforced; + * 2. runtime validation — `schema.parse()` runs before the handler body; a + * failure becomes a standard `fail("INVALID_ARGS", …)` envelope (`isError: + * true`) instead of an uncaught `TypeError`; + * 3. the handler's argument TYPE — `handler` receives `z.infer`, + * so validated, correctly-typed args flow in with no casts. + * + * Compose schemas from the shared field builders in `./fields.ts` + * (`nonEmptyString`, `guid`, `positiveInt`, `folderPath`) so validation + * semantics (trimming, GUID shape, integer clamping, path normalization) stay + * identical across every tool. + * + * IDEMPOTENCY CONTRACT: the server dispatch (`index.ts`) calls `validateArgs` + * once and the returned tool `handler` parses again, so any `.transform()` in a + * schema MUST be a fixed point — `parse(parse(x))` has to equal `parse(x)`. The + * `fields.ts` builders honor this (e.g. `folderPath` normalizes to a string, not + * an array). + * + * @example + * const schema = z.object({ + * containerId: nonEmptyString("containerId", "The container ID."), + * folderPath: folderPath("folderPath", { required: true }), + * }); + * export const createFolderTool = defineTool({ + * name: "content_folder_create", + * description: "Create a folder …", + * schema, + * handler: async (args) => { + * // args.containerId: string, args.folderPath: string — already validated. + * return ok(…); + * }, + * }); + */ + +import { z, ZodError, type ZodObject, type ZodRawShape } from "zod/v3"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import { ValidationError } from "../errors.js"; +import { fail } from "../responses.js"; +import type { McpTool, McpToolAnnotations, McpToolResult } from "../types.js"; + +type ObjectSchema = ZodObject; + +interface DefineToolOptions { + name: string; + description: string; + annotations?: McpToolAnnotations; + schema: TSchema; + validationErrorMessage?: (error: ZodError) => string; + handler: (args: z.infer) => Promise; +} + +function inputSchemaFromZod(schema: ObjectSchema): McpTool["inputSchema"] { + const json = zodToJsonSchema(schema, { + $refStrategy: "none", + target: "jsonSchema7", + }) as Record; + + return { + type: "object", + properties: (json.properties as Record | undefined) ?? {}, + ...(Array.isArray(json.required) ? { required: json.required as string[] } : {}), + }; +} + +function validationMessage(error: ZodError, custom?: (error: ZodError) => string): string { + if (custom) return custom(error); + return error.issues[0]?.message ?? "Invalid tool arguments"; +} + +function parseArgs( + schema: TSchema, + args: Record, + custom?: (error: ZodError) => string, +): z.infer { + try { + return schema.parse(args); + } catch (error) { + if (error instanceof ZodError) { + throw new ValidationError(validationMessage(error, custom)); + } + throw error; + } +} + +export function defineTool(options: DefineToolOptions): McpTool { + const inputSchema = inputSchemaFromZod(options.schema); + return { + name: options.name, + description: options.description, + annotations: options.annotations, + inputSchema, + validateArgs: (args) => parseArgs(options.schema, args, options.validationErrorMessage), + handler: async (args) => { + let parsed: z.infer; + try { + parsed = parseArgs(options.schema, args, options.validationErrorMessage); + } catch (error) { + if (error instanceof ValidationError) { + return fail(error.code, error.message, error.suggestion); + } + throw error; + } + return options.handler(parsed); + }, + }; +} + +export { z }; + diff --git a/src/tooling/fields.test.ts b/src/tooling/fields.test.ts new file mode 100644 index 0000000..f80f90d --- /dev/null +++ b/src/tooling/fields.test.ts @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for the shared Zod field builders (WI-07). + * + * These builders are the one source of truth for tool-argument validation, so + * they get direct coverage for the trimming / GUID / clamping / path-normalizing + * semantics AND for the idempotency contract the server dispatch relies on + * (`parse(parse(x)) === parse(x)`). + */ + +import { describe, it, expect } from "vitest"; +import { + GUID_REGEX, + guid, + nonEmptyString, + positiveInt, + folderPath, + folderSegments, +} from "./fields.js"; + +describe("nonEmptyString", () => { + it("accepts and trims a non-empty string", () => { + const r = nonEmptyString("query").safeParse(" hello "); + expect(r.success).toBe(true); + if (r.success) expect(r.data).toBe("hello"); + }); + + it.each([undefined, null, 123, {}, [], true])( + "rejects non-string / missing value (%p) with ' is required'", + (value) => { + const r = nonEmptyString("query").safeParse(value); + expect(r.success).toBe(false); + if (!r.success) expect(r.error.issues[0].message).toBe("query is required"); + }, + ); + + it.each(["", " ", "\t\n"])("rejects empty / whitespace-only (%p)", (value) => { + const r = nonEmptyString("url").safeParse(value); + expect(r.success).toBe(false); + if (!r.success) expect(r.error.issues[0].message).toBe("url is required"); + }); +}); + +describe("guid", () => { + const CANONICAL = "475485dd-63d4-4f8c-af70-60f7a6c74940"; + + it("accepts a canonical GUID and trims surrounding whitespace", () => { + const r = guid("tenantId").safeParse(` ${CANONICAL} `); + expect(r.success).toBe(true); + if (r.success) expect(r.data).toBe(CANONICAL); + }); + + it("accepts upper-case hex", () => { + expect(guid("id").safeParse(CANONICAL.toUpperCase()).success).toBe(true); + }); + + it.each(["not-a-guid", "12345", "", `${CANONICAL}-extra`, "475485dd63d44f8caf7060f7a6c74940"])( + "rejects a non-GUID value (%p) with ' must be a GUID'", + (value) => { + const r = guid("tenantId").safeParse(value); + expect(r.success).toBe(false); + // "" trips the required check; everything else trips the regex. + if (!r.success) expect(r.error.issues[0].message).toMatch(/tenantId (must be a GUID|is required)/); + }, + ); + + it("rejects a non-string value with ' is required'", () => { + const r = guid("tenantId").safeParse(1234); + expect(r.success).toBe(false); + if (!r.success) expect(r.error.issues[0].message).toBe("tenantId is required"); + }); + + it("GUID_REGEX is anchored (no partial matches)", () => { + expect(GUID_REGEX.test(CANONICAL)).toBe(true); + expect(GUID_REGEX.test(`prefix ${CANONICAL}`)).toBe(false); + expect(GUID_REGEX.test(`${CANONICAL} suffix`)).toBe(false); + }); +}); + +describe("positiveInt", () => { + const schema = positiveInt({ default: 25, max: 200 }); + + it("falls back to default for missing / non-finite / negative input", () => { + expect(schema.parse(undefined)).toBe(25); + expect(schema.parse(null)).toBe(25); + expect(schema.parse("not a number")).toBe(25); + expect(schema.parse(-5)).toBe(25); + expect(schema.parse(Infinity)).toBe(25); + }); + + it("floors fractional input", () => { + expect(schema.parse(3.9)).toBe(3); + }); + + it("coerces numeric strings", () => { + expect(schema.parse("42")).toBe(42); + }); + + it("clamps to [1, max]", () => { + expect(schema.parse(0)).toBe(1); + expect(schema.parse(500)).toBe(200); + }); + + it("is idempotent: parse(parse(x)) === parse(x)", () => { + for (const input of [0, 3.9, 500, -1, "42", undefined]) { + const once = schema.parse(input); + expect(schema.parse(once)).toBe(once); + } + }); +}); + +describe("folderPath (required)", () => { + const schema = folderPath("folderPath", { required: true }); + + it.each([ + ["a/b/c", "a/b/c"], + ["a//b", "a/b"], + ["/leading", "leading"], + ["trailing/", "trailing"], + [" spaced/ segments ", "spaced/segments"], + ["Docs/Reports/Q1", "Docs/Reports/Q1"], + ])("normalizes %p -> %p (empty segments dropped)", (input, expected) => { + const r = schema.safeParse(input); + expect(r.success).toBe(true); + if (r.success) expect(r.data).toBe(expected); + }); + + it.each(["/", "///", " ", "", "//"])( + "rejects a path that normalizes to zero segments (%p)", + (input) => { + const r = schema.safeParse(input); + expect(r.success).toBe(false); + if (!r.success) expect(r.error.issues[0].message).toBe("folderPath is required"); + }, + ); + + it("rejects a non-string value", () => { + expect(schema.safeParse(123).success).toBe(false); + expect(schema.safeParse(undefined).success).toBe(false); + }); + + it("is idempotent for valid input", () => { + const once = schema.parse("a//b"); + expect(once).toBe("a/b"); + expect(schema.parse(once)).toBe("a/b"); + }); +}); + +describe("folderPath (optional)", () => { + const schema = folderPath("folderPath", {}); + + it("allows undefined (root)", () => { + const r = schema.safeParse(undefined); + expect(r.success).toBe(true); + if (r.success) expect(r.data).toBeUndefined(); + }); + + it("normalizes to an empty string when the path is all slashes (root)", () => { + const r = schema.safeParse("///"); + expect(r.success).toBe(true); + if (r.success) expect(r.data).toBe(""); + }); + + it("normalizes a real path", () => { + const r = schema.safeParse("Documents//Reports/"); + expect(r.success).toBe(true); + if (r.success) expect(r.data).toBe("Documents/Reports"); + }); +}); + +describe("folderSegments", () => { + it("splits a normalized path into non-empty segments", () => { + expect(folderSegments("a/b/c")).toEqual(["a", "b", "c"]); + }); + + it("returns [] for empty / nullish input (root)", () => { + expect(folderSegments("")).toEqual([]); + expect(folderSegments(undefined)).toEqual([]); + expect(folderSegments(null)).toEqual([]); + }); +}); diff --git a/src/tooling/fields.ts b/src/tooling/fields.ts new file mode 100644 index 0000000..5a05e2b --- /dev/null +++ b/src/tooling/fields.ts @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Shared Zod field builders for MCP tool argument schemas. + * + * These are the reusable primitives that tool schemas compose so that a single + * Zod declaration is the one source of truth for BOTH the advertised JSON + * `inputSchema` and the enforced runtime validation (see `define-tool.ts`). + * Centralizing them keeps validation semantics — trimming, GUID shape, integer + * clamping, folder-path normalization — identical across every tool instead of + * being re-implemented (and drifting) per handler. + * + * All builders target `zod/v3`, matching the `ZodObject` contract + * that `defineTool` consumes. + */ + +import { z } from "zod/v3"; + +/** + * Canonical 8-4-4-4-12 hyphenated GUID, case-insensitive. Anchored so partial + * matches are rejected. Shared by the `guid` builder and by tests that assert + * identifiers are real GUIDs. + */ +export const GUID_REGEX = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + +/** + * A required, non-empty string. Leading/trailing whitespace is trimmed; a value + * that is missing, not a string, or whitespace-only fails with `" is + * required"` so a non-string argument never throws an uncaught TypeError. + */ +export function nonEmptyString(fieldName: string, description?: string) { + const schema = z + .string({ required_error: `${fieldName} is required`, invalid_type_error: `${fieldName} is required` }) + .trim() + .min(1, `${fieldName} is required`); + return description ? schema.describe(description) : schema; +} + +/** + * A string constrained to the canonical GUID shape. Trims first, then validates + * against {@link GUID_REGEX}. Fails with `" must be a GUID"`. + */ +export function guid(fieldName: string, description?: string) { + const schema = z + .string({ required_error: `${fieldName} is required`, invalid_type_error: `${fieldName} is required` }) + .trim() + .regex(GUID_REGEX, `${fieldName} must be a GUID`); + return description ? schema.describe(description) : schema; +} + +/** + * A sanitized, positive integer that mirrors the clamping done by + * `pagination.ts` (`toPositiveInt`): coerces numbers/numeric strings, requires a + * finite value `>= 0`, floors fractional input, then clamps to `[1, max]`. Any + * missing / non-finite / negative input falls back to `default`. + */ +export function positiveInt(opts: { default: number; max: number; description?: string }) { + const { default: fallback, max, description } = opts; + const schema = z + .unknown() + .transform((value) => { + const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN; + const base = !Number.isFinite(n) || n < 0 ? fallback : Math.floor(n); + return Math.min(Math.max(base, 1), max); + }); + return description ? schema.describe(description) : schema; +} + +/** + * Normalize a slash-delimited folder path. + * + * Splits on `/`, trims each segment, and FILTERS OUT empty segments, so `"/"`, + * `"///"`, and `"a//b"` never produce a blank segment. The transform returns the + * surviving segments re-joined by `/` (an empty string when nothing remains). + * + * Why return a normalized STRING rather than a `string[]`? The transform must be + * idempotent: the server validates arguments once at dispatch and `defineTool` + * re-parses them inside the handler, so `parse(parse(x)) === parse(x)` has to + * hold. `normalize("a//b") === "a/b"` and `normalize("a/b") === "a/b"`, so a + * normalized string is a fixed point; a `string[]` would fail the second parse + * (an array is not a `string`). Use {@link folderSegments} to split the result. + * + * @param opts.required when true, a path that normalizes to zero segments is + * rejected with `" is required"`; when false (default) an empty + * result is allowed (callers treat it as "root"). + */ +export function folderPath( + fieldName: string, + opts: { required?: boolean; description?: string } = {}, +) { + const { required = false, description } = opts; + + const normalize = (raw: string): string => + raw + .split("/") + .map((s) => s.trim()) + .filter((s) => s.length > 0) + .join("/"); + + const transformed = z + .string({ required_error: `${fieldName} is required`, invalid_type_error: `${fieldName} is required` }) + .transform((raw) => normalize(raw)); + + const validated = required + ? transformed.refine((normalized) => normalized.length > 0, { message: `${fieldName} is required` }) + : transformed; + + const schema = required ? validated : validated.optional(); + return description ? schema.describe(description) : schema; +} + +/** + * Split a normalized folder path (the output of a {@link folderPath} field) into + * its non-empty segments. A missing or empty path yields `[]` (i.e. "root"). + */ +export function folderSegments(normalized: string | undefined | null): string[] { + return normalized ? normalized.split("/") : []; +} + +export { z }; diff --git a/src/tools/archive-restore.ts b/src/tools/archive-restore.ts new file mode 100644 index 0000000..45cc9ec --- /dev/null +++ b/src/tools/archive-restore.ts @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tool: container_archive_restore + * + * Archive (lock) or restore (unlock) a container. + */ + +import { getContainer, lockContainer, unlockContainer } from "../graph-client.js"; +import type { McpTool } from "../types.js"; + +export const archiveRestoreTool: McpTool = { + name: "container_archive_restore", + annotations: { plane: "control" }, + description: + "Archive (lock to read-only) or restore (unlock) a SharePoint Embedded container. " + + "Archived containers are in cold storage — content is read-only.", + inputSchema: { + type: "object" as const, + properties: { + containerId: { + type: "string", + description: "The container ID.", + }, + action: { + type: "string", + enum: ["archive", "restore"], + description: "'archive' to lock (read-only), 'restore' to unlock.", + }, + }, + required: ["containerId", "action"], + }, + handler: async (args) => { + const containerId = args.containerId as string; + const action = args.action as string; + + if (!containerId || !action) { + return { + content: [{ type: "text", text: "Error: containerId and action are required" }], + isError: true, + }; + } + + const container = await getContainer(containerId); + + if (action === "archive") { + if (container.lockState === "lockedReadOnly") { + return { + content: [{ + type: "text", + text: `Container "${container.displayName}" is already archived (lockState: lockedReadOnly).`, + }], + }; + } + await lockContainer(containerId); + return { + content: [{ + type: "text", + text: `Container "${container.displayName}" archived (locked to read-only). Use action 'restore' to unlock.`, + }], + }; + } + + if (action === "restore") { + if (container.lockState === "unlocked" || !container.lockState) { + return { + content: [{ + type: "text", + text: `Container "${container.displayName}" is already unlocked.`, + }], + }; + } + await unlockContainer(containerId); + return { + content: [{ + type: "text", + text: `Container "${container.displayName}" restored (unlocked). Content is now writable.`, + }], + }; + } + + return { + content: [{ type: "text", text: `Unknown action: ${action}. Use 'archive' or 'restore'.` }], + isError: true, + }; + }, +}; diff --git a/src/tools/billing.test.ts b/src/tools/billing.test.ts new file mode 100644 index 0000000..1acf43e --- /dev/null +++ b/src/tools/billing.test.ts @@ -0,0 +1,264 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for billing tools. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../graph-client.js", () => ({ + getContainerType: vi.fn(), + listContainerTypes: vi.fn(), +})); +vi.mock("../azure-cli.js", () => ({ + ensureSyntexProviderRegistered: vi.fn(async () => ({ namespace: "Microsoft.Syntex", registrationState: "Registered" })), + createSyntexAccount: vi.fn(async () => "/subscriptions/sub-1/resourceGroups/rg-test/providers/Microsoft.Syntex/accounts/acc-1"), + getSyntexAccounts: vi.fn(async () => []), +})); +vi.mock("../state.js", () => ({ + readState: vi.fn(() => ({})), + writeState: vi.fn(), +})); + +import * as graph from "../graph-client.js"; +import * as azureCli from "../azure-cli.js"; +import * as state from "../state.js"; +import { checkBillingTool } from "../tools/check-billing.js"; +import { setupBillingTool } from "../tools/setup-billing.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// ─── billing_check ────────────────────────────────────────────────────── + +describe("billing_check", () => { + it("shows trial billing with expiry info", async () => { + const created = new Date(); + created.setDate(created.getDate() - 5); + vi.mocked(graph.getContainerType).mockResolvedValue({ + containerTypeId: "ct1", + owningAppId: "app1", + displayName: "Test CT", + billingClassification: "trial", + createdDateTime: created.toISOString(), + }); + vi.mocked(graph.listContainerTypes).mockResolvedValue([ + { containerTypeId: "ct1", owningAppId: "app1", displayName: "Test CT", billingClassification: "trial" }, + { containerTypeId: "ct2", owningAppId: "app2", displayName: "CT 2", billingClassification: "trial" }, + ]); + + const result = await checkBillingTool.handler({ containerTypeId: "ct1" }); + expect(result.content[0].text).toContain("trial"); + expect(result.content[0].text).toContain("days remaining"); + expect(result.content[0].text).toContain("2 of 3 max"); + }); + + it("shows standard billing with subscription", async () => { + vi.mocked(graph.getContainerType).mockResolvedValue({ + containerTypeId: "ct1", + owningAppId: "app1", + displayName: "Prod CT", + billingClassification: "standard", + azureSubscriptionId: "sub-123", + }); + + const result = await checkBillingTool.handler({ containerTypeId: "ct1" }); + expect(result.content[0].text).toContain("standard"); + expect(result.content[0].text).toContain("sub-123"); + }); + + it("requires containerTypeId", async () => { + const r = await checkBillingTool.handler({}); + expect(r.isError).toBe(true); + }); + + it("defaults containerTypeId from provisioning state when omitted", async () => { + vi.mocked(state.readState).mockReturnValueOnce({ containerTypeId: "ct-from-state" }); + vi.mocked(graph.getContainerType).mockResolvedValue({ + containerTypeId: "ct-from-state", + owningAppId: "app1", + displayName: "State CT", + billingClassification: "standard", + }); + const r = await checkBillingTool.handler({}); + expect(r.isError).toBeUndefined(); + expect(graph.getContainerType).toHaveBeenCalledWith("ct-from-state"); + }); +}); + +// ─── billing_setup ────────────────────────────────────────────────────── + +describe("billing_setup", () => { + it("creates the Microsoft.Syntex billing account for a standard CT (confirm=true)", async () => { + vi.mocked(graph.getContainerType).mockResolvedValue({ + containerTypeId: "ct1", owningAppId: "app1", displayName: "Test", + billingClassification: "standard", + }); + vi.mocked(azureCli.getSyntexAccounts).mockResolvedValue([]); + + const result = await setupBillingTool.handler({ + containerTypeId: "ct1", azureSubscriptionId: "sub-1", resourceGroup: "rg-test", + region: "eastus", confirm: true, + }); + + expect(result.content[0].text).toContain("Standard Billing Configured"); + expect(result.content[0].text).toContain("standard"); + expect(result.content[0].text).toContain("irreversible"); + expect(azureCli.ensureSyntexProviderRegistered).toHaveBeenCalledWith("sub-1"); + expect(azureCli.createSyntexAccount).toHaveBeenCalledWith("sub-1", "rg-test", "eastus", "ct1"); + }); + + // Owner decision: no SPO-admin plane -> a trial CT cannot be converted. + it("refuses a non-standard (trial) CT and explains standard must be chosen at create time", async () => { + vi.mocked(graph.getContainerType).mockResolvedValue({ + containerTypeId: "ct1", owningAppId: "app1", displayName: "Test", + billingClassification: "trial", + }); + + const result = await setupBillingTool.handler({ + containerTypeId: "ct1", azureSubscriptionId: "sub-1", resourceGroup: "rg-test", + region: "eastus", confirm: true, + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("trial"); + expect(result.content[0].text).toMatch(/CREATE|created/); + expect(azureCli.ensureSyntexProviderRegistered).not.toHaveBeenCalled(); + expect(azureCli.createSyntexAccount).not.toHaveBeenCalled(); + }); + + // the irreversible standard setup must be gated by confirm. + it("requires confirm=true before creating the billing account (preview only)", async () => { + vi.mocked(graph.getContainerType).mockResolvedValue({ + containerTypeId: "ct1", owningAppId: "app1", displayName: "Test", + billingClassification: "standard", + }); + vi.mocked(azureCli.getSyntexAccounts).mockResolvedValue([]); + + const result = await setupBillingTool.handler({ + containerTypeId: "ct1", azureSubscriptionId: "sub-1", resourceGroup: "rg-test", region: "eastus", + }); + + expect(result.content[0].text).toContain("confirm=true"); + expect(result.content[0].text).toContain("CANNOT be reverted"); + expect(azureCli.ensureSyntexProviderRegistered).not.toHaveBeenCalled(); + expect(azureCli.createSyntexAccount).not.toHaveBeenCalled(); + }); + + it("lists only the missing required fields (not ones that were provided)", async () => { + // containerTypeId provided; subscription + resource group are missing from args and state. + const result = await setupBillingTool.handler({ containerTypeId: "ct1" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("missing required arguments"); + expect(result.content[0].text).toContain("azureSubscriptionId"); + expect(result.content[0].text).toContain("resourceGroup"); + expect(azureCli.createSyntexAccount).not.toHaveBeenCalled(); + }); + + it("is an idempotent no-op when a Succeeded Syntex account already exists for the CT", async () => { + vi.mocked(graph.getContainerType).mockResolvedValue({ + containerTypeId: "ct1", owningAppId: "app1", displayName: "Test", + billingClassification: "standard", + }); + vi.mocked(azureCli.getSyntexAccounts).mockResolvedValue([ + { id: "/subscriptions/sub-1/resourceGroups/rg-test/providers/Microsoft.Syntex/accounts/acc-1", + name: "acc-1", properties: { identityId: "ct1", provisioningState: "Succeeded" } }, + ]); + + const result = await setupBillingTool.handler({ + containerTypeId: "ct1", azureSubscriptionId: "sub-1", resourceGroup: "rg-test", region: "eastus", + }); + + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toMatch(/already attached/i); + expect(azureCli.createSyntexAccount).not.toHaveBeenCalled(); + }); + + it("requires containerTypeId, subscription, and resource group", async () => { + const r = await setupBillingTool.handler({ containerTypeId: "ct1" }); + expect(r.isError).toBe(true); + expect(azureCli.createSyntexAccount).not.toHaveBeenCalled(); + }); +}); + +// createSyntexAccount (az rest PUT shape) — exercises the REAL implementation +// via injected seams (no shelling out) to assert the ARM PUT url + body match +// the VS Code extension exactly. +describe("createSyntexAccount", () => { + it("builds the exact ARM PUT url + body (api-version 2023-01-04-preview)", async () => { + const { createSyntexAccount: realCreate } = + await vi.importActual("../azure-cli.js"); + + let capturedUrl = ""; + let capturedBody: unknown; + const resourceId = await realCreate("sub-1", "rg-1", "eastus", "ct-1", { + newAccountName: () => "11111111-1111-1111-1111-111111111111", + putAccount: async (url, body) => { + capturedUrl = url; + capturedBody = body; + return { + id: "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Syntex/accounts/11111111-1111-1111-1111-111111111111", + properties: { provisioningState: "Succeeded", identityId: "ct-1" }, + }; + }, + }); + + expect(capturedUrl).toBe( + "https://management.azure.com/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Syntex/accounts/11111111-1111-1111-1111-111111111111?api-version=2023-01-04-preview", + ); + expect(capturedBody).toEqual({ + location: "eastus", + properties: { + friendlyName: "CT_ct-1", + service: "SPO", + identityType: "ContainerType", + identityId: "ct-1", + feature: "RaaS", + scope: "Global", + }, + }); + expect(resourceId).toContain("/providers/Microsoft.Syntex/accounts/11111111-1111-1111-1111-111111111111"); + }); + + it("polls until provisioningState=Succeeded when the PUT returns a non-terminal state", async () => { + const { createSyntexAccount: realCreate } = + await vi.importActual("../azure-cli.js"); + + const states = ["Provisioning", "Provisioning", "Succeeded"]; + let i = 0; + const resourceId = await realCreate("sub-1", "rg-1", "eastus", "ct-1", { + newAccountName: () => "acc-uuid", + sleep: async () => undefined, + putAccount: async () => ({ + id: "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Syntex/accounts/acc-uuid", + properties: { provisioningState: "Provisioning", identityId: "ct-1" }, + }), + getAccount: async (id) => ({ id, properties: { provisioningState: states[Math.min(i++, 2)], identityId: "ct-1" } }), + }); + + expect(resourceId).toContain("acc-uuid"); + }); + + it("cleans up the partial account and throws when provisioning Fails", async () => { + const { createSyntexAccount: realCreate } = + await vi.importActual("../azure-cli.js"); + + const deleted: string[] = []; + await expect( + realCreate("sub-1", "rg-1", "eastus", "ct-1", { + newAccountName: () => "bad-uuid", + sleep: async () => undefined, + putAccount: async () => ({ + id: "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Syntex/accounts/bad-uuid", + properties: { provisioningState: "Provisioning", identityId: "ct-1" }, + }), + getAccount: async (id) => ({ id, properties: { provisioningState: "Failed", identityId: "ct-1" } }), + deleteAccount: async (id) => { deleted.push(id); }, + }), + ).rejects.toThrow(/Failed/); + expect(deleted).toHaveLength(1); + }); +}); diff --git a/src/tools/check-billing.ts b/src/tools/check-billing.ts new file mode 100644 index 0000000..9d45275 --- /dev/null +++ b/src/tools/check-billing.ts @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tool: billing_check + * + * Check the billing configuration for a container type. + */ + +import { getContainerType, listContainerTypes } from "../graph-client.js"; +import { readState } from "../state.js"; +import type { McpTool } from "../types.js"; + +export const checkBillingTool: McpTool = { + name: "billing_check", + annotations: { readOnly: true, localRequired: true }, + description: + "Check the billing configuration for a SharePoint Embedded container type. " + + "Shows billing classification, trial expiry, and Azure subscription info. " + + "Defaults to the container type from the current provisioning state when none is given.", + inputSchema: { + type: "object" as const, + properties: { + containerTypeId: { + type: "string", + description: + "The container type ID to check billing for. Defaults to the provisioned container type in state.", + }, + }, + }, + handler: async (args) => { + const containerTypeId = (args.containerTypeId as string) ?? readState().containerTypeId; + if (!containerTypeId) { + return { + content: [ + { + type: "text", + text: + "Error: containerTypeId is required (none provided and none in provisioning state). " + + "Provision an SPE app first (project_provision) or pass a containerTypeId.", + }, + ], + isError: true, + }; + } + + const ct = await getContainerType(containerTypeId); + const billing = ct.billingClassification ?? "unknown"; + + let output = `## Billing Configuration\n\n`; + output += `| Property | Value |\n|----------|-------|\n`; + output += `| **Container Type ID** | \`${containerTypeId}\` |\n`; + output += `| **Name** | ${ct.displayName ?? "—"} |\n`; + output += `| **Owning App** | \`${ct.owningAppId}\` |\n`; + output += `| **Billing** | ${billing} |\n`; + + if (ct.azureSubscriptionId) { + output += `| **Azure Subscription** | \`${ct.azureSubscriptionId}\` |\n`; + } + + if (ct.createdDateTime && billing === "trial") { + const created = new Date(ct.createdDateTime); + const expiry = new Date(created.getTime() + 30 * 24 * 60 * 60 * 1000); + const remaining = Math.ceil((expiry.getTime() - Date.now()) / (24 * 60 * 60 * 1000)); + output += `| **Trial Expires** | ${expiry.toISOString().split("T")[0]} (${remaining > 0 ? `${remaining} days remaining` : "EXPIRED"}) |\n`; + } + + // Count trial CTs + if (billing === "trial") { + try { + const allCts = await listContainerTypes(); + const trialCount = allCts.filter(c => c.billingClassification === "trial").length; + output += `| **Trial CTs** | ${trialCount} of 3 max |\n`; + } catch { + // Skip if listing fails + } + } + + return { content: [{ type: "text", text: output }] }; + }, +}; diff --git a/src/tools/cleanup.ts b/src/tools/cleanup.ts new file mode 100644 index 0000000..c296eab --- /dev/null +++ b/src/tools/cleanup.ts @@ -0,0 +1,238 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tool: project_cleanup + * + * Tears down what the SPE Builder provisioned and clears local provisioning + * state. Deletion policy (deliberately conservative): only a TRIAL container + * type and its owning Entra app are ever auto-deleted. Standard and + * direct-to-customer (DTC) container types are billed, tenant-level production + * resources whose owning app is shared with live containers, so they are + * PRESERVED unless the caller passes an explicit `deleteStandard=true` override + * (strongly discouraged, but not blocked). Requires confirm=true so an agent + * cannot delete resources unprompted. Ports the full-setup skill `06-cleanup.ps1`. + */ + +import { bootstrapTokenProvider } from "../bootstrap.js"; +import { setAuthConfig } from "../auth.js"; +import { AppError } from "../errors.js"; +import { + deleteApplication, + deleteContainerType, + deleteContainerTypeRegistration, + listContainers, + listDeletedContainers, +} from "../graph-client.js"; +import { clearState, readState } from "../state.js"; +import type { McpTool } from "../types.js"; + +/** + * The only billing classification that is safe to tear down automatically. SPE + * classifications are "trial" | "standard" | "directToCustomer"; everything that + * is not exactly "trial" (including unknown/missing state) is treated as a + * PROTECTED, billed resource so we never delete it without an explicit override. + */ +function isTrialContainerType(billingClassification?: string): boolean { + return billingClassification === "trial"; +} + +export const cleanupTool: McpTool = { + name: "project_cleanup", + annotations: { destructive: true, localRequired: true }, + description: + "Delete the SharePoint Embedded resources provisioned by the SPE Builder and clear local state. " + + "By default only a TRIAL container type and its owning Entra app are removed; standard / " + + "direct-to-customer container types are preserved (they are billed, production resources) unless you " + + "explicitly pass deleteStandard=true. Destructive — requires confirm=true.", + inputSchema: { + type: "object" as const, + properties: { + confirm: { type: "boolean", description: "Must be true to actually delete. Without it, shows what would be deleted." }, + deleteStandard: { + type: "boolean", + description: + "Override required to delete a STANDARD or direct-to-customer (DTC) container type and its " + + "owning app. Strongly discouraged — these are billed, tenant-level production resources shared " + + "with live containers. Not needed for trial container types. Default false.", + }, + }, + }, + handler: async (args) => { + const state = readState(); + const confirm = args.confirm === true; + const deleteStandard = args.deleteStandard === true; + + if (!state.appId && !state.containerTypeId) { + return { content: [{ type: "text" as const, text: "Nothing to clean up — no provisioned resources in state." }] }; + } + + // Classify the teardown. We only auto-delete a container type we are SURE is + // trial; standard, direct-to-customer, or unknown classifications are + // PROTECTED and require an explicit deleteStandard=true override. + const classLabel = state.billingClassification ?? "unknown"; + const ctProtected = !!state.containerTypeId && !isTrialContainerType(state.billingClassification); + + if (!confirm) { + const lines: string[] = ["### Confirm cleanup\n\n"]; + if (ctProtected) { + lines.push( + `> ⚠️ **Protected:** container type \`${state.containerTypeId}\` is **${classLabel}** (not trial). ` + + `Standard / direct-to-customer container types are billed, tenant-level production resources and the ` + + `owning app is shared with any live containers, so cleanup will **preserve** them.\n\n`, + ); + } + lines.push("This will delete:\n\n"); + if (state.containerTypeId) { + lines.push( + ctProtected + ? `- ~~Container type \`${state.containerTypeId}\` (${classLabel})~~ — **preserved**\n` + : `- Container type \`${state.containerTypeId}\` (trial)\n`, + ); + } + if (state.appId) { + lines.push( + ctProtected + ? `- ~~Owning app \`${state.appId}\`${state.appDisplayName ? ` (${state.appDisplayName})` : ""}~~ — **preserved**\n` + : `- Owning app \`${state.appId}\`${state.appDisplayName ? ` (${state.appDisplayName})` : ""}\n`, + ); + } + lines.push("- Local provisioning state\n\n"); + lines.push( + ctProtected + ? "> Re-run with `confirm=true` to clear local state. To ALSO delete the standard/DTC container type " + + "and its owning app (strongly discouraged), pass `confirm=true` **and** `deleteStandard=true`." + : "> Re-run `project_cleanup` with `confirm=true` to proceed.", + ); + return { content: [{ type: "text" as const, text: lines.join("") }] }; + } + + // confirm === true. A protected (standard/DTC) container type is left fully + // intact — including local state — unless the explicit override is present. + if (ctProtected && !deleteStandard) { + return { + content: [{ + type: "text" as const, + text: + "## Cleanup skipped — protected container type\n\n" + + `\`${state.containerTypeId}\` is a **${classLabel}** container type. Standard and ` + + "direct-to-customer container types are billed, tenant-level production resources, and the owning app " + + `\`${state.appId}\` is shared with any live containers and their data. To avoid breaking production, ` + + "cleanup will **not** delete them.\n\n" + + "Nothing was deleted. If you are certain, re-run with `confirm=true` **and** `deleteStandard=true` " + + "to override (strongly discouraged).", + }], + }; + } + + const results: string[] = []; + if (ctProtected && deleteStandard) { + results.push(`⚠️ Override: deleting a **${classLabel}** container type and its owning app as explicitly requested.`); + } + + // Tracks whether teardown is clean enough to also delete the owning app and + // clear local state. If containers still block the container type, we must + // PRESERVE the app (it's shared with those containers) and keep state so the + // user can resume after purging containers. + let blockedByContainers = false; + + // Container-type deletion uses the owning-app token. In bootstrap mode, + // restore auth config from persisted state so getAccessToken() is usable. + if (state.appId && state.tenantId) { + setAuthConfig({ clientId: state.appId, tenantId: state.tenantId }); + } + + if (state.containerTypeId) { + // Container type deletion requires that NO registration is associated, and + // a registration can only be deleted once it has no live or recycle-bin + // containers. Run the teardown in order: detect container blockers, delete + // the registration (best-effort), then the container type. We do NOT + // auto-purge containers here (a much larger destructive surface) — instead + // we report them and point at the right tools. + const ctId = state.containerTypeId; + // "unknown" must not be treated as "empty": if a listing call throws, we + // cannot prove there are no containers, so we treat it as a blocker rather + // than risk deleting the owning app and orphaning surviving containers. + let liveCount = 0; + let deletedCount = 0; + let listingUncertain = false; + try { liveCount = (await listContainers(ctId)).length; } catch { listingUncertain = true; } + try { deletedCount = (await listDeletedContainers(ctId)).length; } catch { listingUncertain = true; } + + if (liveCount || deletedCount || listingUncertain) { + blockedByContainers = true; + const blockers: string[] = []; + if (liveCount) blockers.push(`${liveCount} live container(s)`); + if (deletedCount) blockers.push(`${deletedCount} recycle-bin container(s)`); + const what = blockers.length > 0 ? blockers.join(" and ") : "containers that could not be enumerated"; + results.push( + `⚠️ Container type \`${ctId}\` still has ${what} — preserved. ` + + "Permanently delete them first (container_delete soft-delete then permanent-delete; " + + "container_deleted_list to find recycle-bin containers), then re-run project_cleanup.", + ); + } else { + // No containers: delete the registration (unblocks the CT delete), then the CT. + try { + await deleteContainerTypeRegistration(ctId); + results.push(`✅ Deleted container type registration \`${ctId}\``); + } catch (error) { + if (error instanceof AppError && error.code === "NOT_FOUND") { + results.push(`✓ Container type registration \`${ctId}\` already removed`); + } else if (error instanceof AppError && error.code === "CONFLICT") { + // Registration still has containers (a race vs the listing above) — + // treat as a blocker so the app + state are preserved. + blockedByContainers = true; + results.push( + `⚠️ Registration \`${ctId}\` could not be deleted: it still has containers. ` + + "Purge them (container_deleted_list, container_delete), then re-run project_cleanup.", + ); + } else { + blockedByContainers = true; + results.push(`⚠️ Registration delete failed: ${error instanceof AppError ? error.safeMessage ?? error.message : String(error)}`); + } + } + if (!blockedByContainers) { + try { + await deleteContainerType(ctId); + results.push(`✅ Deleted container type \`${ctId}\``); + } catch (error) { + // Any failure here means the CT survives — preserve the app + state + // rather than orphaning it. + blockedByContainers = true; + if (error instanceof AppError && error.code === "CONFLICT") { + results.push( + `⚠️ Container type \`${ctId}\` delete blocked (existing registration). ` + + "Delete it with container_type_registration_delete, then retry.", + ); + } else { + results.push(`⚠️ Container type delete failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + } + } + } + + if (blockedByContainers) { + // Preserve the owning app (shared with the surviving containers) and local + // state so the user can purge containers and resume. + results.push( + "↩️ Owning app and local state **preserved** — finish purging the containers above, then re-run project_cleanup.", + ); + return { content: [{ type: "text" as const, text: `## Cleanup Paused\n\n${results.join("\n")}` }] }; + } + + if (state.appObjectId) { + try { + await deleteApplication(state.appObjectId, bootstrapTokenProvider); + results.push(`✅ Deleted owning app \`${state.appId}\``); + } catch (error) { + results.push(`⚠️ App delete failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + + clearState(); + results.push("✅ Cleared local provisioning state"); + + return { content: [{ type: "text" as const, text: `## Cleanup Complete\n\n${results.join("\n")}` }] }; + }, +}; diff --git a/src/tools/confirmation.test.ts b/src/tools/confirmation.test.ts new file mode 100644 index 0000000..51d7435 --- /dev/null +++ b/src/tools/confirmation.test.ts @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, it, expect, vi } from "vitest"; +import { withConfirmation, requiresConfirmation } from "./confirmation.js"; +import type { McpTool } from "../types.js"; + +function makeTool(handler = vi.fn(async () => ({ content: [{ type: "text" as const, text: "ok" }] }))): McpTool { + return { + name: "demo_tool", + description: "demo tool for confirmation tests", + inputSchema: { type: "object", properties: {} }, + handler, + }; +} + +describe("requiresConfirmation", () => { + it("always requires confirmation when no actions filter is given", () => { + expect(requiresConfirmation({})).toBe(true); + expect(requiresConfirmation({ action: "anything" })).toBe(true); + }); + + it("only requires confirmation for listed actions", () => { + const opts = { actions: ["revoke"] }; + expect(requiresConfirmation({ action: "revoke" }, opts)).toBe(true); + expect(requiresConfirmation({ action: "grant" }, opts)).toBe(false); + expect(requiresConfirmation({}, opts)).toBe(false); + }); + + it("honors a custom actionArg", () => { + expect(requiresConfirmation({ op: "wipe" }, { actionArg: "op", actions: ["wipe"] })).toBe(true); + }); +}); + +describe("withConfirmation", () => { + it("blocks a destructive call without confirm and does not invoke the handler", async () => { + const handler = vi.fn(async () => ({ content: [{ type: "text" as const, text: "ran" }] })); + const wrapped = withConfirmation(makeTool(handler)); + const result = await wrapped.handler({}); + expect(result.isError).toBe(true); + expect((result.structuredContent as { error?: { code?: string } }).error?.code).toBe("CONFIRMATION_REQUIRED"); + expect(handler).not.toHaveBeenCalled(); + }); + + it("proceeds when confirm=true", async () => { + const handler = vi.fn(async () => ({ content: [{ type: "text" as const, text: "ran" }] })); + const wrapped = withConfirmation(makeTool(handler)); + const result = await wrapped.handler({ confirm: true }); + expect(result.content[0].text).toBe("ran"); + expect(handler).toHaveBeenCalledOnce(); + }); + + it("only gates the configured actions", async () => { + const handler = vi.fn(async () => ({ content: [{ type: "text" as const, text: "ran" }] })); + const wrapped = withConfirmation(makeTool(handler), { actions: ["revoke"] }); + + // non-gated action passes through untouched + await wrapped.handler({ action: "grant" }); + expect(handler).toHaveBeenCalledOnce(); + + handler.mockClear(); + // gated action requires confirm + const blocked = await wrapped.handler({ action: "revoke" }); + expect(blocked.isError).toBe(true); + expect(handler).not.toHaveBeenCalled(); + + const allowed = await wrapped.handler({ action: "revoke", confirm: true }); + expect(allowed.content[0].text).toBe("ran"); + expect(handler).toHaveBeenCalledOnce(); + }); + + it("preserves tool metadata (name/description/inputSchema/annotations)", () => { + const tool = { ...makeTool(), annotations: { destructive: true } }; + const wrapped = withConfirmation(tool); + expect(wrapped.name).toBe(tool.name); + expect(wrapped.description).toBe(tool.description); + expect(wrapped.inputSchema).toBe(tool.inputSchema); + expect(wrapped.annotations).toEqual({ destructive: true }); + }); +}); diff --git a/src/tools/confirmation.ts b/src/tools/confirmation.ts new file mode 100644 index 0000000..5c22f73 --- /dev/null +++ b/src/tools/confirmation.ts @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Reusable confirmation gate for destructive / irreversible tool operations + * (SAFE-002). + * + * Mirrors the `withContentAccess` wrapper pattern in `content-access.ts`: it + * spreads the wrapped tool (preserving name/description/inputSchema/annotations + * so ListTools + the registry are unaffected) and only wraps the handler. The + * enforcement therefore lives in exactly one place and cannot drift per-handler. + * + * When confirmation is required and the caller did not pass `confirm: true`, the + * wrapped handler returns a `CONFIRMATION_REQUIRED` failure WITHOUT invoking the + * inner handler — so no Graph / Azure mutation occurs. + * + * Tools that already implement their own rich preview/confirm UX + * (`container_type_delete`, `project_cleanup`, `billing_setup`) are intentionally + * NOT wrapped — see index.ts. + */ + +import { fail } from "../responses.js"; +import type { McpTool } from "../types.js"; + +export interface ConfirmationOptions { + /** Name of the argument holding the action/sub-command. Default: `"action"`. */ + actionArg?: string; + /** + * When provided, confirmation is only required if `args[actionArg]` is one of + * these values (e.g. only the `permanent-delete` action of a multi-mode tool). + * When omitted, EVERY call to the tool requires confirmation. + */ + actions?: string[]; + /** Name of the boolean confirmation argument. Default: `"confirm"`. */ + confirmArg?: string; +} + +/** + * Decide whether the current invocation requires confirmation, given the tool's + * arguments and the configured options. + */ +export function requiresConfirmation( + args: Record, + options: ConfirmationOptions = {}, +): boolean { + const { actionArg = "action", actions } = options; + if (!actions || actions.length === 0) return true; + const action = args[actionArg]; + return typeof action === "string" && actions.includes(action); +} + +/** + * Wrap a tool so destructive actions fail closed unless `confirm: true` is + * supplied. Applied at registration (see index.ts). + */ +export function withConfirmation(tool: McpTool, options: ConfirmationOptions = {}): McpTool { + const { confirmArg = "confirm" } = options; + return { + ...tool, + handler: async (args) => { + if (requiresConfirmation(args, options) && args[confirmArg] !== true) { + return fail( + "CONFIRMATION_REQUIRED", + `This is a destructive, irreversible operation. Re-run with ${confirmArg}=true to proceed.`, + `Pass ${confirmArg}: true once you have verified the target is correct.`, + ); + } + return tool.handler(args); + }, + }; +} diff --git a/src/tools/container-management.test.ts b/src/tools/container-management.test.ts new file mode 100644 index 0000000..b00aa99 --- /dev/null +++ b/src/tools/container-management.test.ts @@ -0,0 +1,306 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for container management tools. + * + * Tests tool handler logic with mocked Graph client. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock the graph-client module +vi.mock("../graph-client.js", () => ({ + listContainers: vi.fn(), + getContainer: vi.fn(), + getContainerDrive: vi.fn(), + listContainerPermissions: vi.fn(), + getCustomProperties: vi.fn(), + addContainerPermission: vi.fn(), + updateContainerPermission: vi.fn(), + removeContainerPermission: vi.fn(), + lockContainer: vi.fn(), + unlockContainer: vi.fn(), + deleteContainer: vi.fn(), + permanentDeleteContainer: vi.fn(), + restoreDeletedContainer: vi.fn(), +})); +// container_list defaults containerTypeId from provisioning state; mock it so the +// test never reads the developer's real ~/.spe-mcp/state.json. +vi.mock("../state.js", () => ({ + readState: vi.fn(() => ({})), + writeState: vi.fn(), +})); + +import * as graph from "../graph-client.js"; +import * as state from "../state.js"; +import { listContainersTool } from "../tools/list-containers.js"; +import { getContainerTool } from "../tools/get-container.js"; +import { managePermissionsTool } from "../tools/manage-permissions.js"; +import { archiveRestoreTool } from "../tools/archive-restore.js"; +import { deleteContainerTool } from "../tools/delete-container.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// ─── container_list ──────────────────────────────────────────────────── + +describe("container_list", () => { + it("returns containers as markdown table", async () => { + vi.mocked(graph.listContainers).mockResolvedValue([ + { id: "c1", displayName: "Test", containerTypeId: "ct1", status: "active", createdDateTime: "2026-01-01" }, + ]); + + const result = await listContainersTool.handler({ containerTypeId: "ct1" }); + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain("Containers (1)"); + expect(result.content[0].text).toContain("Test"); + expect(graph.listContainers).toHaveBeenCalledWith("ct1"); + }); + + it("handles empty container list", async () => { + vi.mocked(graph.listContainers).mockResolvedValue([]); + const result = await listContainersTool.handler({ containerTypeId: "ct1" }); + expect(result.content[0].text).toContain("No containers found"); + }); + + it("requires containerTypeId", async () => { + const result = await listContainersTool.handler({}); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("required"); + }); + + it("defaults containerTypeId from provisioning state when omitted", async () => { + vi.mocked(state.readState).mockReturnValueOnce({ containerTypeId: "ct-from-state" }); + vi.mocked(graph.listContainers).mockResolvedValue([ + { id: "c1", displayName: "Test", containerTypeId: "ct-from-state", status: "active", createdDateTime: "2026-01-01" }, + ]); + const result = await listContainersTool.handler({}); + expect(result.isError).toBeUndefined(); + expect(graph.listContainers).toHaveBeenCalledWith("ct-from-state"); + }); +}); + +// ─── container_get ────────────────────────────────────────────────────── + +describe("container_get", () => { + it("returns container details with permissions and drive", async () => { + vi.mocked(graph.getContainer).mockResolvedValue({ + id: "c1", displayName: "Test", containerTypeId: "ct1", + status: "active", lockState: "unlocked", createdDateTime: "2026-01-01", + }); + vi.mocked(graph.getContainerDrive).mockResolvedValue({ + id: "d1", webUrl: "https://example.com/drive", quota: { used: 1024, total: 1048576 }, + }); + vi.mocked(graph.listContainerPermissions).mockResolvedValue([ + { id: "p1", roles: ["owner"], grantedToV2: { user: { userPrincipalName: "user@test.com" } } }, + ]); + vi.mocked(graph.getCustomProperties).mockRejectedValue(new Error("not found")); + + const result = await getContainerTool.handler({ containerId: "c1" }); + expect(result.content[0].text).toContain("Test"); + expect(result.content[0].text).toContain("owner"); + expect(result.content[0].text).toContain("user@test.com"); + expect(result.content[0].text).toContain("d1"); + }); + + it("handles missing permissions gracefully", async () => { + vi.mocked(graph.getContainer).mockResolvedValue({ + id: "c1", displayName: "Test", containerTypeId: "ct1", status: "active", + }); + vi.mocked(graph.getContainerDrive).mockRejectedValue(new Error("403")); + vi.mocked(graph.listContainerPermissions).mockRejectedValue(new Error("403")); + vi.mocked(graph.getCustomProperties).mockRejectedValue(new Error("403")); + + const result = await getContainerTool.handler({ containerId: "c1" }); + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain("Test"); + expect(result.content[0].text).toContain("unavailable"); + }); + + it("requires containerId", async () => { + const result = await getContainerTool.handler({}); + expect(result.isError).toBe(true); + }); +}); + +// ─── container_permissions_manage ───────────────────────────────────────────────── + +describe("container_permissions_manage", () => { + it("adds permission successfully", async () => { + vi.mocked(graph.addContainerPermission).mockResolvedValue({ + id: "p1", roles: ["writer"], + }); + + const result = await managePermissionsTool.handler({ + containerId: "c1", action: "add", userPrincipalName: "user@test.com", role: "writer", + }); + expect(result.content[0].text).toContain("Permission added"); + expect(result.content[0].text).toContain("user@test.com"); + }); + + it("handles 409 conflict on add", async () => { + vi.mocked(graph.addContainerPermission).mockRejectedValue(new Error("Graph API error (409): conflict")); + + const result = await managePermissionsTool.handler({ + containerId: "c1", action: "add", userPrincipalName: "user@test.com", role: "writer", + }); + expect(result.content[0].text).toContain("already has permissions"); + }); + + it("updates permission", async () => { + vi.mocked(graph.updateContainerPermission).mockResolvedValue(); + const result = await managePermissionsTool.handler({ + containerId: "c1", action: "update", permissionId: "p1", role: "manager", + }); + expect(result.content[0].text).toContain("updated"); + }); + + // role validation / no silent default-to-writer on update. + it("refuses to silently default role on update (no privilege change)", async () => { + vi.mocked(graph.updateContainerPermission).mockResolvedValue(); + const result = await managePermissionsTool.handler({ + containerId: "c1", action: "update", permissionId: "p1", // no role + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("role is required for update"); + // Critical: must NOT have mutated the grant to a defaulted writer role. + expect(graph.updateContainerPermission).not.toHaveBeenCalled(); + }); + + it("rejects an invalid role with an actionable error (update)", async () => { + const result = await managePermissionsTool.handler({ + containerId: "c1", action: "update", permissionId: "p1", role: "admin", + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("invalid role 'admin'"); + expect(graph.updateContainerPermission).not.toHaveBeenCalled(); + }); + + it("rejects an invalid role with an actionable error (add)", async () => { + const result = await managePermissionsTool.handler({ + containerId: "c1", action: "add", userPrincipalName: "user@test.com", role: "superuser", + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("invalid role 'superuser'"); + expect(graph.addContainerPermission).not.toHaveBeenCalled(); + }); + + it("accepts a valid explicit role on update", async () => { + vi.mocked(graph.updateContainerPermission).mockResolvedValue(); + const result = await managePermissionsTool.handler({ + containerId: "c1", action: "update", permissionId: "p1", role: "reader", + }); + expect(result.isError).toBeFalsy(); + expect(graph.updateContainerPermission).toHaveBeenCalledWith("c1", "p1", "reader"); + expect(result.content[0].text).toContain("reader"); + }); + + it("removes permission", async () => { + vi.mocked(graph.removeContainerPermission).mockResolvedValue(); + const result = await managePermissionsTool.handler({ + containerId: "c1", action: "remove", permissionId: "p1", + }); + expect(result.content[0].text).toContain("removed"); + }); + + it("requires userPrincipalName for add", async () => { + const result = await managePermissionsTool.handler({ containerId: "c1", action: "add" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("userPrincipalName"); + }); + + it("requires permissionId for update/remove", async () => { + const r1 = await managePermissionsTool.handler({ containerId: "c1", action: "update" }); + expect(r1.isError).toBe(true); + const r2 = await managePermissionsTool.handler({ containerId: "c1", action: "remove" }); + expect(r2.isError).toBe(true); + }); +}); + +// ─── container_archive_restore ──────────────────────────────────────────────────── + +describe("container_archive_restore", () => { + it("archives an active container", async () => { + vi.mocked(graph.getContainer).mockResolvedValue({ + id: "c1", displayName: "Test", containerTypeId: "ct1", status: "active", lockState: "unlocked", + }); + vi.mocked(graph.lockContainer).mockResolvedValue(); + + const result = await archiveRestoreTool.handler({ containerId: "c1", action: "archive" }); + expect(result.content[0].text).toContain("archived"); + expect(graph.lockContainer).toHaveBeenCalledWith("c1"); + }); + + it("skips archive if already locked", async () => { + vi.mocked(graph.getContainer).mockResolvedValue({ + id: "c1", displayName: "Test", containerTypeId: "ct1", status: "active", lockState: "lockedReadOnly", + }); + + const result = await archiveRestoreTool.handler({ containerId: "c1", action: "archive" }); + expect(result.content[0].text).toContain("already archived"); + expect(graph.lockContainer).not.toHaveBeenCalled(); + }); + + it("restores a locked container", async () => { + vi.mocked(graph.getContainer).mockResolvedValue({ + id: "c1", displayName: "Test", containerTypeId: "ct1", status: "active", lockState: "lockedReadOnly", + }); + vi.mocked(graph.unlockContainer).mockResolvedValue(); + + const result = await archiveRestoreTool.handler({ containerId: "c1", action: "restore" }); + expect(result.content[0].text).toContain("restored"); + }); + + it("skips restore if already unlocked", async () => { + vi.mocked(graph.getContainer).mockResolvedValue({ + id: "c1", displayName: "Test", containerTypeId: "ct1", status: "active", lockState: "unlocked", + }); + + const result = await archiveRestoreTool.handler({ containerId: "c1", action: "restore" }); + expect(result.content[0].text).toContain("already unlocked"); + }); +}); + +// ─── container_delete ─────────────────────────────────────────────────── + +describe("container_delete", () => { + it("soft-deletes a container", async () => { + vi.mocked(graph.getContainer).mockResolvedValue({ + id: "c1", displayName: "Test", containerTypeId: "ct1", status: "active", + }); + vi.mocked(graph.deleteContainer).mockResolvedValue(); + + const result = await deleteContainerTool.handler({ containerId: "c1", action: "soft-delete" }); + expect(result.content[0].text).toContain("soft-deleted"); + expect(result.content[0].text).toContain("93-day"); + }); + + it("permanently deletes a container (with confirm=true)", async () => { + vi.mocked(graph.permanentDeleteContainer).mockResolvedValue(); + const result = await deleteContainerTool.handler({ containerId: "c1", action: "permanent-delete", confirm: true }); + expect(result.content[0].text).toContain("permanently deleted"); + expect(result.content[0].text).toContain("IRREVERSIBLE"); + expect(graph.permanentDeleteContainer).toHaveBeenCalledWith("c1"); + }); + + it("blocks permanent-delete without confirm and does NOT call Graph (SAFE-002)", async () => { + const result = await deleteContainerTool.handler({ containerId: "c1", action: "permanent-delete" }); + expect(result.isError).toBe(true); + expect((result.structuredContent as { error?: { code?: string } }).error?.code).toBe("CONFIRMATION_REQUIRED"); + expect(graph.permanentDeleteContainer).not.toHaveBeenCalled(); + }); + + it("blocks permanent-delete when confirm is not strictly true (SAFE-002)", async () => { + const result = await deleteContainerTool.handler({ containerId: "c1", action: "permanent-delete", confirm: "yes" }); + expect(result.isError).toBe(true); + expect(graph.permanentDeleteContainer).not.toHaveBeenCalled(); + }); + + it("restores a deleted container", async () => { + vi.mocked(graph.restoreDeletedContainer).mockResolvedValue(); + const result = await deleteContainerTool.handler({ containerId: "c1", action: "restore" }); + expect(result.content[0].text).toContain("restored"); + }); +}); diff --git a/src/tools/container-type-app-grants.test.ts b/src/tools/container-type-app-grants.test.ts new file mode 100644 index 0000000..baf7edc --- /dev/null +++ b/src/tools/container-type-app-grants.test.ts @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for the container-type application-permission-grant tools + * (v1.0 `applicationPermissionGrants` on a container type registration). + * graph-client / auth / state are mocked so these run offline. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../graph-client.js", () => ({ + grantContainerTypeAppPermission: vi.fn(async (_ct: string, appId: string, del: string[], app: string[]) => ({ + appId, + delegatedPermissions: del, + applicationPermissions: app, + })), + listContainerTypeAppPermissions: vi.fn(async () => [ + { appId: "app-1", delegatedPermissions: ["full"], applicationPermissions: ["full"] }, + { appId: "app-2", delegatedPermissions: ["read"], applicationPermissions: ["none"] }, + ]), + revokeContainerTypeAppPermission: vi.fn(async () => undefined), +})); +vi.mock("../auth.js", () => ({ setAuthConfig: vi.fn() })); + +const stateStore: Record = {}; +vi.mock("../state.js", () => ({ readState: vi.fn(() => ({ ...stateStore })) })); + +import * as graph from "../graph-client.js"; +import { setAuthConfig } from "../auth.js"; +import { getSessionId } from "../session.js"; +import { + addContainerTypeAppGrantTool, + listContainerTypeAppGrantsTool, + removeContainerTypeAppGrantTool, +} from "./container-type-app-grants.js"; + +beforeEach(() => { + vi.clearAllMocks(); + for (const k of Object.keys(stateStore)) delete stateStore[k]; + // r-appgate: these tools are control-plane mutations gated by the restart + // confirmation guard; seed a confirmed session so the gate no-ops and the + // tool's own logic is exercised. The gate itself is covered in + // context-gate.test.ts. + Object.assign(stateStore, { appId: "app-1", tenantId: "t-1", containerTypeId: "ct-1", confirmedSessionId: getSessionId() }); +}); + +describe("container_type_app_grant_add", () => { + it("defaults the container type + appId to state and grants `full` delegated / `none` app-only (opt-in)", async () => { + const r = await addContainerTypeAppGrantTool.handler({}); + expect(setAuthConfig).toHaveBeenCalledWith({ clientId: "app-1", tenantId: "t-1" }); + expect(graph.grantContainerTypeAppPermission).toHaveBeenCalledWith("ct-1", "app-1", ["full"], ["none"]); + expect(r.isError).toBeFalsy(); + expect(r.content[0].text).toContain("app-1"); + }); + + it("opt-in: app-only permissions require an explicit `full` (least-privilege default otherwise)", async () => { + await addContainerTypeAppGrantTool.handler({ appId: "app-2", applicationPermissions: ["full"] }); + expect(graph.grantContainerTypeAppPermission).toHaveBeenCalledWith("ct-1", "app-2", ["full"], ["full"]); + }); + + it("authorizes an explicit secondary app with custom permissions", async () => { + const r = await addContainerTypeAppGrantTool.handler({ + appId: "app-2", + delegatedPermissions: ["readContent", "writeContent"], + applicationPermissions: ["none"], + }); + expect(graph.grantContainerTypeAppPermission).toHaveBeenCalledWith( + "ct-1", + "app-2", + ["readContent", "writeContent"], + ["none"], + ); + expect(r.content[0].text).toContain("readContent, writeContent"); + }); + + it("accepts a comma-separated permissions string", async () => { + await addContainerTypeAppGrantTool.handler({ appId: "app-2", delegatedPermissions: "read, write" }); + expect(graph.grantContainerTypeAppPermission).toHaveBeenCalledWith("ct-1", "app-2", ["read", "write"], ["none"]); + }); + + it("errors when no container type is known", async () => { + delete stateStore.containerTypeId; + const r = await addContainerTypeAppGrantTool.handler({}); + expect(r.isError).toBe(true); + expect(graph.grantContainerTypeAppPermission).not.toHaveBeenCalled(); + }); + + it("errors when no appId is known", async () => { + delete stateStore.appId; + const r = await addContainerTypeAppGrantTool.handler({}); + expect(r.isError).toBe(true); + expect(graph.grantContainerTypeAppPermission).not.toHaveBeenCalled(); + }); +}); + +describe("container_type_app_grants_list", () => { + it("lists grants for the provisioned container type", async () => { + const r = await listContainerTypeAppGrantsTool.handler({}); + expect(graph.listContainerTypeAppPermissions).toHaveBeenCalledWith("ct-1"); + expect(r.content[0].text).toContain("app-1"); + expect(r.content[0].text).toContain("app-2"); + }); + + it("reports an empty collection clearly", async () => { + vi.mocked(graph.listContainerTypeAppPermissions).mockResolvedValueOnce([]); + const r = await listContainerTypeAppGrantsTool.handler({ containerTypeId: "ct-9" }); + expect(graph.listContainerTypeAppPermissions).toHaveBeenCalledWith("ct-9"); + expect(r.content[0].text).toContain("No application permission grants"); + }); +}); + +describe("container_type_app_grant_remove", () => { + it("removes a grant by appId", async () => { + const r = await removeContainerTypeAppGrantTool.handler({ appId: "app-2" }); + expect(graph.revokeContainerTypeAppPermission).toHaveBeenCalledWith("ct-1", "app-2"); + expect(r.isError).toBeFalsy(); + }); + + it("requires an appId", async () => { + const r = await removeContainerTypeAppGrantTool.handler({}); + expect(r.isError).toBe(true); + expect(graph.revokeContainerTypeAppPermission).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tools/container-type-app-grants.ts b/src/tools/container-type-app-grants.ts new file mode 100644 index 0000000..6a42fe5 --- /dev/null +++ b/src/tools/container-type-app-grants.ts @@ -0,0 +1,199 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tools: container_type_app_grant_add / container_type_app_grants_list / + * container_type_app_grant_remove + * + * Manage the `applicationPermissionGrants` collection on a SharePoint Embedded + * container type registration via Microsoft Graph **v1.0**. Each grant authorizes + * one consuming app (by appId) to act on the container type with a set of + * delegated/application permissions. + * + * Relationship to `container_type_register`: registration replaces the WHOLE + * grants collection for the owning app; these tools add / list / remove a SINGLE + * app's grant without disturbing the others — the supported way to authorize + * ADDITIONAL apps on an existing container type registration. + * + * (Distinct from `container_type_grant_owner`, which manages the beta-only + * `owner` role on the container type itself for public-client container creation.) + */ + +import { + grantContainerTypeAppPermission, + listContainerTypeAppPermissions, + revokeContainerTypeAppPermission, +} from "../graph-client.js"; +import type { McpTool } from "../types.js"; +import { authContainerTypeState, err, reason } from "./container-type-shared.js"; +import { resolveContextGate } from "./context-gate.js"; + +/** Optional restart-confirmation arg surfaced on the mutation tools (r-appgate). */ +const contextChoiceSchema = { + type: "string" as const, + enum: ["confirm", "switch"], + description: + "On a freshly restarted session, confirm the remembered owning app / container type ('confirm') " + + "or switch to a different one ('switch'). Supplied in response to the confirmation prompt; omit on the first call.", +}; + +/** Coerce a string | string[] arg into a clean string[]; undefined → fallback. */ +function toPermissions(value: unknown, fallback: string[]): string[] { + if (Array.isArray(value)) { + const cleaned = value.map((v) => String(v).trim()).filter((v) => v !== ""); + return cleaned.length > 0 ? cleaned : fallback; + } + if (typeof value === "string" && value.trim() !== "") { + return value + .split(",") + .map((v) => v.trim()) + .filter((v) => v !== ""); + } + return fallback; +} + +export const addContainerTypeAppGrantTool: McpTool = { + name: "container_type_app_grant_add", + annotations: { plane: "control" }, + description: + "Add or update an application permission grant on a SharePoint Embedded container type registration " + + "(Microsoft Graph v1.0). Authorizes one consuming app (by appId) to act on the container type with the " + + "given delegated/application permissions. Idempotent upsert — re-running for the same appId overwrites its " + + "permissions. Defaults the container type to the provisioned one and the appId to the provisioned owning app; " + + "delegated permissions default to `full` and application (app-only) permissions default to `none` (opt-in). " + + "Use this to authorize additional apps without overwriting existing grants " + + "(unlike container_type_register, which replaces the whole collection).", + inputSchema: { + type: "object" as const, + properties: { + containerTypeId: { type: "string", description: "Container type registration id. Default: the provisioned container type." }, + appId: { type: "string", description: "Client (app) id to grant. Default: the provisioned owning app." }, + delegatedPermissions: { + type: "array", + items: { type: "string" }, + description: + "Permissions for delegated (user) tokens, e.g. [\"readContent\",\"writeContent\"] or [\"full\"]. Default: [\"full\"].", + }, + applicationPermissions: { + type: "array", + items: { type: "string" }, + description: + "Permissions for application (app-only) tokens, e.g. [\"full\"] or [\"none\"]. Default: [\"none\"]. " + + "App-only permissions are only for a daemon / app-only consumer that accesses containers without a " + + "signed-in user; the delegated full-setup path does not use them, so leave this at [\"none\"] unless " + + "you are authorizing an app-only client.", + }, + contextChoice: contextChoiceSchema, + }, + }, + handler: async (args) => { + try { + // Restart confirmation gate (r-appgate) before mutating grants on a fresh + // session. Inside the try so a stamp-write failure on `contextChoice=confirm` + // (writeState / writeSecureFile) is classified by this tool's own error + // handling below, like its other errors. (PR #3 review.) + const gate = await resolveContextGate(args.contextChoice as string | undefined); + if (gate) return gate; + + const state = authContainerTypeState(); + const containerTypeId = (args.containerTypeId as string) || state.containerTypeId; + const appId = (args.appId as string) || state.appId; + if (!containerTypeId) return err("no containerTypeId provided and none in provisioning state."); + if (!appId) return err("no appId provided and no owning app in provisioning state. Run project_app_create first or pass appId."); + + const delegated = toPermissions(args.delegatedPermissions, ["full"]); + const application = toPermissions(args.applicationPermissions, ["none"]); + + const grant = await grantContainerTypeAppPermission(containerTypeId, appId, delegated, application); + const del = grant.delegatedPermissions ?? delegated; + const app = grant.applicationPermissions ?? application; + return { + content: [{ + type: "text" as const, + text: + `## App permission grant added\n\nApp \`${appId}\` is now authorized on container type registration ` + + `\`${containerTypeId}\`.\n\n| Scope | Permissions |\n|---|---|\n` + + `| delegated | ${del.join(", ")} |\n| application | ${app.join(", ")} |`, + }], + }; + } catch (e) { + return err(`granting app permission: ${reason(e)}`); + } + }, +}; + +export const listContainerTypeAppGrantsTool: McpTool = { + name: "container_type_app_grants_list", + annotations: { readOnly: true }, + description: + "List the application permission grants on a SharePoint Embedded container type registration " + + "(Microsoft Graph v1.0) — the apps authorized on the container type and their delegated/application permissions.", + inputSchema: { + type: "object" as const, + properties: { + containerTypeId: { type: "string", description: "Container type registration id. Default: the provisioned container type." }, + }, + }, + handler: async (args) => { + const state = authContainerTypeState(); + const containerTypeId = (args.containerTypeId as string) || state.containerTypeId; + if (!containerTypeId) return err("no containerTypeId provided and none in provisioning state."); + + try { + const grants = await listContainerTypeAppPermissions(containerTypeId); + if (grants.length === 0) { + return { content: [{ type: "text" as const, text: `No application permission grants on container type registration \`${containerTypeId}\`.` }] }; + } + const rows = grants + .map((g) => `| \`${g.appId ?? "?"}\` | ${g.delegatedPermissions?.join(", ") ?? ""} | ${g.applicationPermissions?.join(", ") ?? ""} |`) + .join("\n"); + return { + content: [{ + type: "text" as const, + text: `## App permission grants on \`${containerTypeId}\`\n\n| App | Delegated | Application |\n|---|---|---|\n${rows}`, + }], + }; + } catch (e) { + return err(`listing app permission grants: ${reason(e)}`); + } + }, +}; + +export const removeContainerTypeAppGrantTool: McpTool = { + name: "container_type_app_grant_remove", + annotations: { destructive: true, plane: "control" }, + description: + "Remove an application permission grant from a SharePoint Embedded container type registration " + + "(Microsoft Graph v1.0), revoking that app's access to the container type. Removing the owning app's grant " + + "breaks container operations — pass the appId explicitly.", + inputSchema: { + type: "object" as const, + properties: { + containerTypeId: { type: "string", description: "Container type registration id. Default: the provisioned container type." }, + appId: { type: "string", description: "Client (app) id whose grant to remove (see container_type_app_grants_list)." }, + contextChoice: contextChoiceSchema, + }, + required: ["appId"], + }, + handler: async (args) => { + try { + // Restart confirmation gate (r-appgate) before mutating grants on a fresh + // session. Inside the try so a stamp-write failure on `contextChoice=confirm` + // (writeState / writeSecureFile) is classified by this tool's own error + // handling below, like its other errors. (PR #3 review.) + const gate = await resolveContextGate(args.contextChoice as string | undefined); + if (gate) return gate; + + const state = authContainerTypeState(); + const containerTypeId = (args.containerTypeId as string) || state.containerTypeId; + const appId = args.appId as string | undefined; + if (!containerTypeId) return err("no containerTypeId provided and none in provisioning state."); + if (!appId) return err("appId is required (see container_type_app_grants_list)."); + + await revokeContainerTypeAppPermission(containerTypeId, appId); + return { content: [{ type: "text" as const, text: `Removed app \`${appId}\`'s permission grant from container type registration \`${containerTypeId}\`.` }] }; + } catch (e) { + return err(`removing app permission grant: ${reason(e)}`); + } + }, +}; diff --git a/src/tools/container-type-crud.ts b/src/tools/container-type-crud.ts new file mode 100644 index 0000000..cd36f2b --- /dev/null +++ b/src/tools/container-type-crud.ts @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tools: container_type_get / container_type_update / container_type_delete + * + * Read / Update / Delete operations on SharePoint Embedded container types via + * Microsoft Graph **beta** (Create + List already exist as container_type_create + * / container_type_list). Delete reuses the trial-only deletion policy: standard + * and direct-to-customer container types are PROTECTED unless deleteStandard=true. + */ + +import { setAuthConfig } from "../auth.js"; +import { AppError } from "../errors.js"; +import { + deleteContainerType, + getContainerType, + listContainerTypes, + updateContainerType, +} from "../graph-client.js"; +import { readState } from "../state.js"; +import type { McpTool } from "../types.js"; + +function authAndDefaultCt(): string | undefined { + const state = readState(); + if (state.appId && state.tenantId) { + setAuthConfig({ clientId: state.appId, tenantId: state.tenantId }); + } + return state.containerTypeId; +} + +function err(text: string) { + return { content: [{ type: "text" as const, text: `Error: ${text}` }], isError: true }; +} +function reason(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +export const getContainerTypeTool: McpTool = { + name: "container_type_get", + annotations: { readOnly: true }, + description: "Get a SharePoint Embedded container type by id (Microsoft Graph beta).", + inputSchema: { + type: "object" as const, + properties: { + containerTypeId: { type: "string", description: "Container type id. Default: the provisioned container type." }, + }, + }, + handler: async (args) => { + const defaultCt = authAndDefaultCt(); + const id = (args.containerTypeId as string) || defaultCt; + if (!id) return err("no containerTypeId provided and none in provisioning state."); + try { + const ct = await getContainerType(id); + return { + content: [{ + type: "text" as const, + text: + "## Container Type\n\n| Property | Value |\n|---|---|\n" + + `| Id | \`${ct.containerTypeId}\` |\n` + + `| Name | ${ct.displayName} |\n` + + `| Owning app | \`${ct.owningAppId}\` |\n` + + `| Billing | ${ct.billingClassification ?? "?"} |\n` + + `| Created | ${ct.createdDateTime ?? "?"} |\n` + + `| Expires | ${ct.expirationDateTime ?? "—"} |`, + }], + }; + } catch (e) { + return err(`getting container type: ${reason(e)}`); + } + }, +}; + +export const updateContainerTypeTool: McpTool = { + name: "container_type_update", + annotations: { plane: "control" }, + description: "Update a SharePoint Embedded container type's mutable properties, e.g. displayName (Microsoft Graph beta).", + inputSchema: { + type: "object" as const, + properties: { + containerTypeId: { type: "string", description: "Container type id. Default: the provisioned container type." }, + displayName: { type: "string", description: "New display name for the container type." }, + }, + }, + handler: async (args) => { + const defaultCt = authAndDefaultCt(); + const id = (args.containerTypeId as string) || defaultCt; + if (!id) return err("no containerTypeId provided and none in provisioning state."); + const update: Record = {}; + if (typeof args.displayName === "string" && args.displayName.trim() !== "") { + // The beta Update fileStorageContainerType API accepts the display name as + // `name` (NOT `displayName`, which it rejects with HTTP 400 — it accepts + // only name/settings/etag). + update.name = args.displayName; + } + if (Object.keys(update).length === 0) return err("nothing to update — provide displayName."); + try { + await updateContainerType(id, update); + return { content: [{ type: "text" as const, text: `Updated container type \`${id}\` (now "${args.displayName}").` }] }; + } catch (e) { + return err(`updating container type: ${reason(e)}`); + } + }, +}; + +export const deleteContainerTypeTool: McpTool = { + name: "container_type_delete", + annotations: { destructive: true, plane: "control" }, + description: + "Delete a SharePoint Embedded container type (Microsoft Graph beta). Trial-only by default: standard / " + + "direct-to-customer container types are billed production resources and are PROTECTED unless you pass " + + "deleteStandard=true. Requires confirm=true.", + inputSchema: { + type: "object" as const, + properties: { + containerTypeId: { type: "string", description: "Container type id. Default: the provisioned container type." }, + confirm: { type: "boolean", description: "Must be true to actually delete." }, + deleteStandard: { type: "boolean", description: "Override to delete a STANDARD / direct-to-customer container type. Strongly discouraged." }, + }, + }, + handler: async (args) => { + const defaultCt = authAndDefaultCt(); + const id = (args.containerTypeId as string) || defaultCt; + if (!id) return err("no containerTypeId provided and none in provisioning state."); + + // Resolve the container type's billing classification up front (used by both + // the preview and the protection gate). Only a TRIAL container type is safe + // to auto-delete; unknown is treated as protected (fail safe). + let classification: string | undefined; + try { + const cts = await listContainerTypes(); + classification = cts.find((c) => c.containerTypeId === id)?.billingClassification; + } catch { + /* leave undefined → protected */ + } + const isTrial = classification === "trial"; + + if (args.confirm !== true) { + const protectedNote = isTrial + ? "Re-run with `confirm=true`." + : `> ⚠️ **Protected:** this container type is **${classification ?? "unknown"}** (not trial) — a billed ` + + "production resource. Re-run with `confirm=true` **and** `deleteStandard=true` (strongly discouraged)."; + return { + content: [{ + type: "text" as const, + text: `### Confirm delete\n\nThis will delete container type \`${id}\`.\n\n${protectedNote}`, + }], + }; + } + + if (!isTrial && args.deleteStandard !== true) { + return err( + `container type \`${id}\` is **${classification ?? "unknown"}** (not trial). Standard / ` + + "direct-to-customer container types are billed, production resources and are protected. Pass " + + "deleteStandard=true to override (strongly discouraged).", + ); + } + + try { + await deleteContainerType(id); + return { + content: [{ + type: "text" as const, + text: `Deleted container type \`${id}\`${isTrial ? " (trial)" : ` (**${classification}** — override)`}.`, + }], + }; + } catch (e) { + // The most common failure is a 409 because the container type still has a + // registration. Removing app *grants* does NOT clear this — the + // registration RECORD must be deleted. Point the caller at the right tool. + if (e instanceof AppError && e.code === "CONFLICT") { + return err( + `cannot delete container type \`${id}\`: it still has an active registration. ` + + "Delete the registration first with `container_type_registration_delete` (a registration can only " + + "be deleted once its live and recycle-bin containers are permanently removed — use " + + "`container_deleted_list` to find recycle-bin containers). Removing an app permission *grant* is not " + + "sufficient. Then retry container_type_delete.", + ); + } + return err(`deleting container type: ${reason(e)}`); + } + }, +}; diff --git a/src/tools/container-type-permissions.ts b/src/tools/container-type-permissions.ts new file mode 100644 index 0000000..95304a6 --- /dev/null +++ b/src/tools/container-type-permissions.ts @@ -0,0 +1,212 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tools: container_type_grant_owner / container_type_owners_list / + * container_type_revoke_owner + * + * Manage the `permissions` (owner) collection on a fileStorageContainerType via + * Microsoft Graph **beta**. Granting a USER the `owner` role lets that user + * create containers using a public client (PCA) — the v1.0 container endpoint + * rejects container creation by public clients ("Container creation by a public + * client is not allowed"). Only the `owner` role and a user identity are + * supported; max 3 owners per container type. + */ + +import { bootstrapTokenProvider } from "../bootstrap.js"; +import { + getSignedInUser, + grantContainerTypeOwner, + listContainerTypePermissions, + revokeContainerTypePermission, +} from "../graph-client.js"; +import type { McpTool } from "../types.js"; +import { authContainerTypeState, err, reason } from "./container-type-shared.js"; +import { resolveContextGate } from "./context-gate.js"; + +/** Optional restart-confirmation arg surfaced on the mutation tools (r-appgate). */ +const contextChoiceSchema = { + type: "string" as const, + enum: ["confirm", "switch"], + description: + "On a freshly restarted session, confirm the remembered owning app / container type ('confirm') " + + "or switch to a different one ('switch'). Supplied in response to the confirmation prompt; omit on the first call.", +}; + +/** Point MSAL at the owning app and return the provisioned container-type id as + * the default. Thin wrapper over the shared helper (owner tools only need the + * container-type id). */ +function authAndDefaultCt(): string | undefined { + return authContainerTypeState().containerTypeId; +} + +/** + * Does a Graph error look like the "a guest (B2B) user cannot be a container-type + * owner" rejection? Heuristic on the error text — used to turn the raw API + * failure into clear, actionable guidance when an explicit guest `userId` was + * supplied (its `userType` is unknown without an extra lookup). Conservative: + * only matches when the message mentions a guest / external (`#EXT#`) user, which + * in the grant-owner context is the owner restriction. (PR #3 review.) + */ +function isGuestOwnerRejection(e: unknown): boolean { + const m = reason(e).toLowerCase(); + return m.includes("guest") || m.includes("#ext#"); +} + +export const grantContainerTypeOwnerTool: McpTool = { + name: "container_type_grant_owner", + annotations: { plane: "control" }, + description: + "Grant the `owner` role on a SharePoint Embedded container type to a user (Microsoft Graph beta). " + + "An owner can create containers using a public client (PCA) — the v1.0 container API rejects container " + + "creation by public clients. Defaults the container type to the provisioned one and the user to the " + + "signed-in user. Max 3 owners per container type.", + inputSchema: { + type: "object" as const, + properties: { + containerTypeId: { type: "string", description: "Container type id. Default: the provisioned container type." }, + userId: { type: "string", description: "Object id of the user to grant `owner`. Default: the signed-in user." }, + contextChoice: contextChoiceSchema, + }, + }, + handler: async (args) => { + // Whether the grant targets the SIGNED-IN user (no explicit userId). Declared + // before the try so the catch can scope the guest-owner remap to this + // self-target path only. (PR #3 review.) + const targetIsSignedInUser = !(args.userId as string | undefined); + try { + // Restart confirmation gate (r-appgate) before mutating owners on a fresh + // session. Inside the try so a stamp-write failure on `contextChoice=confirm` + // (writeState / writeSecureFile) is classified by this tool's own error + // handling below, like its other errors. (PR #3 review.) + const gate = await resolveContextGate(args.contextChoice as string | undefined); + if (gate) return gate; + + const defaultCt = authAndDefaultCt(); + const containerTypeId = (args.containerTypeId as string) || defaultCt; + if (!containerTypeId) return err("no containerTypeId provided and none in provisioning state."); + + let userId = args.userId as string | undefined; + let who = ""; + if (!userId) { + let me: Awaited>; + try { + me = await getSignedInUser(bootstrapTokenProvider); + } catch (e) { + return err(`could not resolve the signed-in user — pass userId explicitly. ${reason(e)}`); + } + // Guest (B2B) users cannot be container-type owners — the Graph API rejects + // them. Detect it up front (from the /me `userType`) and return a clear, + // actionable message instead of defaulting the grant to a guest and + // surfacing a raw API error. NON-BLOCKING guidance: pass a member user's + // `userId` to proceed. Guest sign-in itself remains fully supported. + // (PR #3 review.) + if (me.userType === "Guest") { + return err( + `the signed-in user ${me.userPrincipalName ?? me.id} is a guest (B2B) account, and guest ` + + "users cannot be granted the container-type `owner` role. Re-run with `userId` set to a " + + "**member** user of the target tenant.", + ); + } + userId = me.id; + who = ` (signed-in user ${me.userPrincipalName ?? me.id})`; + } + + const perm = await grantContainerTypeOwner(containerTypeId, userId); + return { + content: [{ + type: "text" as const, + text: + `## Owner granted\n\nUser \`${userId}\`${who} now has the **owner** role on container type ` + + `\`${containerTypeId}\` (permission \`${perm.id ?? "?"}\`).\n\n` + + "> This user can now create containers on this container type using a public client (PCA).", + }], + }; + } catch (e) { + // The Graph API also rejects a guest owner when we defaulted to the signed-in + // user and that account turns out to be a guest (its userType can be absent + // from /me). Map that specific rejection to the same clear guidance — but ONLY + // on the self-target path. For an EXPLICIT `userId`, an unrelated failure that + // merely mentions "guest" must surface its raw reason, not misdirected guest + // guidance. (PR #3 review.) + if (targetIsSignedInUser && isGuestOwnerRejection(e)) { + return err( + "guest (B2B) users cannot be granted the container-type `owner` role. Grant it to a " + + "**member** user of the target tenant (pass their `userId`).", + ); + } + return err(`granting owner: ${reason(e)}`); + } + }, +}; + +export const listContainerTypeOwnersTool: McpTool = { + name: "container_type_owners_list", + annotations: { readOnly: true }, + description: "List the owner permissions on a SharePoint Embedded container type (Microsoft Graph beta).", + inputSchema: { + type: "object" as const, + properties: { + containerTypeId: { type: "string", description: "Container type id. Default: the provisioned container type." }, + }, + }, + handler: async (args) => { + const defaultCt = authAndDefaultCt(); + const containerTypeId = (args.containerTypeId as string) || defaultCt; + if (!containerTypeId) return err("no containerTypeId provided and none in provisioning state."); + + try { + const perms = await listContainerTypePermissions(containerTypeId); + if (perms.length === 0) { + return { content: [{ type: "text" as const, text: `No owner permissions on container type \`${containerTypeId}\`.` }] }; + } + const rows = perms + .map((p) => `| \`${p.id ?? "?"}\` | ${p.roles?.join(", ") ?? ""} | ${p.grantedToV2?.user?.id ?? p.grantedToV2?.user?.userPrincipalName ?? "?"} |`) + .join("\n"); + return { + content: [{ + type: "text" as const, + text: `## Owners of \`${containerTypeId}\`\n\n| Permission | Roles | User |\n|---|---|---|\n${rows}`, + }], + }; + } catch (e) { + return err(`listing owners: ${reason(e)}`); + } + }, +}; + +export const revokeContainerTypeOwnerTool: McpTool = { + name: "container_type_revoke_owner", + annotations: { destructive: true, plane: "control" }, + description: "Remove an owner permission from a SharePoint Embedded container type (Microsoft Graph beta).", + inputSchema: { + type: "object" as const, + properties: { + containerTypeId: { type: "string", description: "Container type id. Default: the provisioned container type." }, + permissionId: { type: "string", description: "The permission id to remove (from container_type_owners_list)." }, + contextChoice: contextChoiceSchema, + }, + required: ["permissionId"], + }, + handler: async (args) => { + try { + // Restart confirmation gate (r-appgate) before mutating owners on a fresh + // session. Inside the try so a stamp-write failure on `contextChoice=confirm` + // (writeState / writeSecureFile) is classified by this tool's own error + // handling below, like its other errors. (PR #3 review.) + const gate = await resolveContextGate(args.contextChoice as string | undefined); + if (gate) return gate; + + const defaultCt = authAndDefaultCt(); + const containerTypeId = (args.containerTypeId as string) || defaultCt; + const permissionId = args.permissionId as string | undefined; + if (!containerTypeId) return err("no containerTypeId provided and none in provisioning state."); + if (!permissionId) return err("permissionId is required (see container_type_owners_list)."); + + await revokeContainerTypePermission(containerTypeId, permissionId); + return { content: [{ type: "text" as const, text: `Removed owner permission \`${permissionId}\` from container type \`${containerTypeId}\`.` }] }; + } catch (e) { + return err(`revoking owner: ${reason(e)}`); + } + }, +}; diff --git a/src/tools/container-type-registration.test.ts b/src/tools/container-type-registration.test.ts new file mode 100644 index 0000000..f926a6f --- /dev/null +++ b/src/tools/container-type-registration.test.ts @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for the container-type registration CRUDL tools, the recycle-bin + * list tool, and container_update (rename) — the operations added to close the + * container-type teardown gap. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../graph-client.js", () => ({ + getContainerTypeRegistration: vi.fn(), + listContainerTypeRegistrations: vi.fn(), + deleteContainerTypeRegistration: vi.fn(), + listContainers: vi.fn(), + listDeletedContainers: vi.fn(), + updateContainer: vi.fn(), +})); + +// Deterministic state regardless of the dev machine's ~/.spe-mcp/state.json. +vi.mock("../state.js", () => ({ + readState: vi.fn(() => ({ appId: "app-1", tenantId: "tenant-1", containerTypeId: "ct-1", containerId: "cid-1" })), + writeState: vi.fn(), +})); +vi.mock("../auth.js", () => ({ setAuthConfig: vi.fn() })); + +import * as graph from "../graph-client.js"; +import { AppError } from "../errors.js"; +import { + getContainerTypeRegistrationTool, + listContainerTypeRegistrationsTool, + deleteContainerTypeRegistrationTool, +} from "../tools/container-type-registration.js"; +import { listDeletedContainersTool } from "../tools/list-deleted-containers.js"; +import { updateContainerTool } from "../tools/update-container.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// ─── container_type_registration_get / _list ───────────────────────────────── + +describe("container_type_registration_get", () => { + it("reads a registration and defaults the id from state", async () => { + vi.mocked(graph.getContainerTypeRegistration).mockResolvedValue({ + id: "ct-1", + owningAppId: "app-1", + billingClassification: "trial", + applicationPermissionGrants: [{ appId: "app-1", delegatedPermissions: ["full"], applicationPermissions: ["none"] }], + }); + const r = await getContainerTypeRegistrationTool.handler({}); + expect(graph.getContainerTypeRegistration).toHaveBeenCalledWith("ct-1"); + expect(r.content[0].text).toContain("Container Type Registration"); + expect(r.content[0].text).toContain("app-1"); + }); +}); + +describe("container_type_registration_list", () => { + it("lists registrations", async () => { + vi.mocked(graph.listContainerTypeRegistrations).mockResolvedValue([ + { id: "ct-1", owningAppId: "app-1" }, + { id: "ct-2", owningAppId: "app-2" }, + ]); + const r = await listContainerTypeRegistrationsTool.handler({}); + expect(r.content[0].text).toContain("Container Type Registrations (2)"); + expect(r.content[0].text).toContain("ct-2"); + }); + + it("handles an empty tenant", async () => { + vi.mocked(graph.listContainerTypeRegistrations).mockResolvedValue([]); + const r = await listContainerTypeRegistrationsTool.handler({}); + expect(r.content[0].text).toContain("No container type registrations"); + }); +}); + +// ─── container_type_registration_delete ────────────────────────────────────── + +describe("container_type_registration_delete", () => { + it("requires confirm and surfaces blockers in the preview", async () => { + vi.mocked(graph.listContainers).mockResolvedValue([{ id: "c1" } as never]); + vi.mocked(graph.listDeletedContainers).mockResolvedValue([{ id: "d1" } as never, { id: "d2" } as never]); + + const r = await deleteContainerTypeRegistrationTool.handler({ containerTypeId: "ct-1" }); + expect(graph.deleteContainerTypeRegistration).not.toHaveBeenCalled(); + expect(r.content[0].text).toContain("Confirm delete registration"); + expect(r.content[0].text).toContain("1 live container(s)"); + expect(r.content[0].text).toContain("2 recycle-bin container(s)"); + }); + + it("fails fast (no DELETE) when confirmed but containers still exist", async () => { + vi.mocked(graph.listContainers).mockResolvedValue([{ id: "c1" } as never]); + vi.mocked(graph.listDeletedContainers).mockResolvedValue([]); + + const r = await deleteContainerTypeRegistrationTool.handler({ containerTypeId: "ct-1", confirm: true }); + expect(graph.deleteContainerTypeRegistration).not.toHaveBeenCalled(); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("still has 1 live container(s)"); + expect(r.content[0].text).toContain("container_deleted_list"); + }); + + it("deletes the registration when confirmed and empty", async () => { + vi.mocked(graph.listContainers).mockResolvedValue([]); + vi.mocked(graph.listDeletedContainers).mockResolvedValue([]); + vi.mocked(graph.deleteContainerTypeRegistration).mockResolvedValue(undefined); + + const r = await deleteContainerTypeRegistrationTool.handler({ containerTypeId: "ct-1", confirm: true }); + expect(graph.deleteContainerTypeRegistration).toHaveBeenCalledWith("ct-1"); + expect(r.isError).toBeFalsy(); + expect(r.content[0].text).toContain("Deleted container type registration"); + }); + + it("treats NOT_FOUND as already-deleted (idempotent)", async () => { + vi.mocked(graph.listContainers).mockResolvedValue([]); + vi.mocked(graph.listDeletedContainers).mockResolvedValue([]); + vi.mocked(graph.deleteContainerTypeRegistration).mockRejectedValue( + new AppError("NOT_FOUND", "Resource not found"), + ); + const r = await deleteContainerTypeRegistrationTool.handler({ containerTypeId: "ct-1", confirm: true }); + expect(r.isError).toBeFalsy(); + expect(r.content[0].text).toContain("already deleted"); + }); + + it("maps a server-side CONFLICT to actionable guidance", async () => { + vi.mocked(graph.listContainers).mockResolvedValue([]); + vi.mocked(graph.listDeletedContainers).mockResolvedValue([]); + vi.mocked(graph.deleteContainerTypeRegistration).mockRejectedValue( + new AppError("CONFLICT", "Graph API conflict (409)"), + ); + const r = await deleteContainerTypeRegistrationTool.handler({ containerTypeId: "ct-1", confirm: true }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("container_deleted_list"); + }); +}); + +// ─── container_deleted_list (recycle bin) ───────────────────────────────────── + +describe("container_deleted_list", () => { + it("lists recycle-bin containers filtered by the provisioned container type by default", async () => { + vi.mocked(graph.listDeletedContainers).mockResolvedValue([ + { id: "d1", displayName: "Gone", containerTypeId: "ct-1" } as never, + ]); + const r = await listDeletedContainersTool.handler({}); + expect(graph.listDeletedContainers).toHaveBeenCalledWith("ct-1"); + expect(r.content[0].text).toContain("Deleted Containers (recycle bin)"); + expect(r.content[0].text).toContain("Gone"); + }); + + it("can list across all container types by fanning out over registrations", async () => { + vi.mocked(graph.listContainerTypeRegistrations).mockResolvedValue([ + { id: "ct-1" }, + { id: "ct-2" }, + ]); + vi.mocked(graph.listDeletedContainers).mockResolvedValue([]); + const r = await listDeletedContainersTool.handler({ allContainerTypes: true }); + expect(graph.listContainerTypeRegistrations).toHaveBeenCalled(); + expect(graph.listDeletedContainers).toHaveBeenCalledWith("ct-1"); + expect(graph.listDeletedContainers).toHaveBeenCalledWith("ct-2"); + expect(r.content[0].text).toContain("No soft-deleted containers"); + }); +}); + +// ─── container_update (rename) ──────────────────────────────────────────────── + +describe("container_update", () => { + it("renames a container and syncs persisted state", async () => { + const state = await import("../state.js"); + vi.mocked(graph.updateContainer).mockResolvedValue({ id: "cid-1", displayName: "New Name" } as never); + const r = await updateContainerTool.handler({ displayName: "New Name" }); + expect(graph.updateContainer).toHaveBeenCalledWith("cid-1", { displayName: "New Name", description: undefined }); + expect(vi.mocked(state.writeState)).toHaveBeenCalledWith({ containerName: "New Name" }); + expect(r.content[0].text).toContain("Container Updated"); + expect(r.content[0].text).toContain("New Name"); + }); + + it("rejects when nothing to update", async () => { + const r = await updateContainerTool.handler({ containerId: "cid-9" }); + expect(graph.updateContainer).not.toHaveBeenCalled(); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("nothing to update"); + }); +}); diff --git a/src/tools/container-type-registration.ts b/src/tools/container-type-registration.ts new file mode 100644 index 0000000..13aa0bc --- /dev/null +++ b/src/tools/container-type-registration.ts @@ -0,0 +1,230 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tools: container_type_registration_get / container_type_registration_list / + * container_type_registration_delete + * + * CRUDL on the container type **registration RECORD** itself (the tenant↔ + * containerType binding) via Microsoft Graph v1.0 — distinct from the per-app + * `applicationPermissionGrants` managed by the `container_type_app_grant_*` + * tools, and from `container_type_register` (Create). + * + * Why delete matters: a container type can only be deleted once it has NO + * registrations, and a registration can only be deleted once it has NO + * containers AND NO deleted (recycle-bin) containers. Removing an app *grant* is + * NOT the same as deleting the registration — this is the operation that + * actually unblocks `container_type_delete`. + */ + +import { setAuthConfig } from "../auth.js"; +import { AppError } from "../errors.js"; +import { + deleteContainerTypeRegistration, + getContainerTypeRegistration, + listContainers, + listContainerTypeRegistrations, + listDeletedContainers, +} from "../graph-client.js"; +import { readState } from "../state.js"; +import type { McpTool } from "../types.js"; + +/** Point MSAL at the owning app and return the provisioned container-type id + * (== registration id) as the default. */ +function authState(): { containerTypeId?: string; appId?: string } { + const state = readState(); + if (state.appId && state.tenantId) { + setAuthConfig({ clientId: state.appId, tenantId: state.tenantId }); + } + return { containerTypeId: state.containerTypeId, appId: state.appId }; +} + +function err(text: string) { + return { content: [{ type: "text" as const, text: `Error: ${text}` }], isError: true }; +} +function reason(e: unknown): string { + if (e instanceof AppError) return e.safeMessage ?? e.message; + return e instanceof Error ? e.message : String(e); +} + +export const getContainerTypeRegistrationTool: McpTool = { + name: "container_type_registration_get", + annotations: { readOnly: true }, + description: + "Read a SharePoint Embedded container type registration record (the tenant↔container-type binding) " + + "via Microsoft Graph v1.0. Use this to inspect a registration's status and owning app when diagnosing " + + "why a container type can't be deleted. Defaults the registration id to the provisioned container type.", + inputSchema: { + type: "object" as const, + properties: { + containerTypeId: { + type: "string", + description: "Container type registration id. Default: the provisioned container type.", + }, + }, + }, + handler: async (args) => { + const state = authState(); + const containerTypeId = (args.containerTypeId as string) || state.containerTypeId; + if (!containerTypeId) return err("no containerTypeId provided and none in provisioning state."); + + try { + const reg = await getContainerTypeRegistration(containerTypeId); + const grants = reg.applicationPermissionGrants ?? []; + return { + content: [{ + type: "text" as const, + text: + `## Container Type Registration\n\n` + + "| Property | Value |\n|----------|-------|\n" + + `| **Registration / container type id** | \`${reg.id ?? containerTypeId}\` |\n` + + `| **Owning app** | ${reg.owningAppId ? `\`${reg.owningAppId}\`` : "—"} |\n` + + `| **Billing** | ${reg.billingClassification ?? "—"} |\n` + + `| **Registered** | ${reg.registeredDateTime ?? "—"} |\n` + + `| **App permission grants** | ${grants.length} |\n`, + }], + }; + } catch (e) { + return err(`reading container type registration: ${reason(e)}`); + } + }, +}; + +export const listContainerTypeRegistrationsTool: McpTool = { + name: "container_type_registration_list", + annotations: { readOnly: true }, + description: + "List the SharePoint Embedded container type registrations on the tenant (Microsoft Graph v1.0) — the " + + "container types registered for use in this tenant and the apps that registered them. Use this to find " + + "registrations that must be deleted before their container types can be removed.", + inputSchema: { + type: "object" as const, + properties: {}, + }, + handler: async () => { + authState(); + try { + const regs = await listContainerTypeRegistrations(); + if (regs.length === 0) { + return { content: [{ type: "text" as const, text: "No container type registrations found on this tenant." }] }; + } + const rows = regs + .map( + (r) => + `| \`${r.id ?? "?"}\` | ${r.owningAppId ? `\`${r.owningAppId}\`` : "—"} | ${r.billingClassification ?? "—"} | ${r.registeredDateTime ?? "—"} |`, + ) + .join("\n"); + return { + content: [{ + type: "text" as const, + text: `## Container Type Registrations (${regs.length})\n\n| Registration id | Owning app | Billing | Registered |\n|---|---|---|---|\n${rows}`, + }], + }; + } catch (e) { + return err(`listing container type registrations: ${reason(e)}`); + } + }, +}; + +export const deleteContainerTypeRegistrationTool: McpTool = { + name: "container_type_registration_delete", + annotations: { destructive: true, plane: "control" }, + description: + "Delete a SharePoint Embedded container type REGISTRATION record (Microsoft Graph v1.0), unregistering the " + + "container type from the tenant. This is REQUIRED before a container type can be deleted, and is NOT the same " + + "as removing an app's permission grant. A registration can only be deleted once it has NO live containers AND " + + "NO deleted (recycle-bin) containers — this tool checks both first and tells you exactly what is blocking. " + + "Destructive: requires confirm=true.", + inputSchema: { + type: "object" as const, + properties: { + containerTypeId: { + type: "string", + description: "Container type registration id to delete. Default: the provisioned container type.", + }, + confirm: { + type: "boolean", + description: "Must be true to actually delete the registration.", + }, + }, + }, + handler: async (args) => { + const state = authState(); + const containerTypeId = (args.containerTypeId as string) || state.containerTypeId; + if (!containerTypeId) return err("no containerTypeId provided and none in provisioning state."); + + // Surface blockers up front (live + recycle-bin containers) so the agent gets + // an actionable list instead of a raw 409 from the DELETE. + let liveCount: number | undefined; + let deletedCount: number | undefined; + try { + liveCount = (await listContainers(containerTypeId)).length; + } catch { /* non-fatal: fall through to the DELETE, which will 409 if blocked */ } + try { + deletedCount = (await listDeletedContainers(containerTypeId)).length; + } catch { /* deletedContainers may be unavailable in some clouds; non-fatal */ } + + if (args.confirm !== true) { + const blockers: string[] = []; + if (liveCount) blockers.push(`${liveCount} live container(s)`); + if (deletedCount) blockers.push(`${deletedCount} recycle-bin container(s)`); + const blockNote = + blockers.length > 0 + ? `\n\n> ⚠️ **Blocked:** this registration still has ${blockers.join(" and ")}. Permanently ` + + "delete them first (container_delete → soft-delete, then permanent-delete; list the recycle bin " + + "with container_deleted_list) — the registration delete will fail until both are empty." + : ""; + return { + content: [{ + type: "text" as const, + text: + `### Confirm delete registration\n\nThis will unregister container type \`${containerTypeId}\` ` + + `from the tenant. Re-run with \`confirm=true\` to proceed.${blockNote}`, + }], + }; + } + + // Pre-flight block: if we KNOW there are containers, fail fast with guidance + // rather than emitting a raw 409. + if (liveCount || deletedCount) { + const blockers: string[] = []; + if (liveCount) blockers.push(`${liveCount} live container(s)`); + if (deletedCount) blockers.push(`${deletedCount} recycle-bin container(s)`); + return err( + `cannot delete registration \`${containerTypeId}\`: it still has ${blockers.join(" and ")}. ` + + "Permanently delete all containers (soft-delete then permanent-delete; use container_deleted_list " + + "to find recycle-bin containers) before deleting the registration.", + ); + } + + try { + await deleteContainerTypeRegistration(containerTypeId); + return { + content: [{ + type: "text" as const, + text: + `Deleted container type registration \`${containerTypeId}\`. The container type can now be deleted ` + + "with container_type_delete (trial container types only).", + }], + }; + } catch (e) { + // NOT_FOUND → treat as already-unregistered (idempotent teardown). + if (e instanceof AppError && e.code === "NOT_FOUND") { + return { + content: [{ + type: "text" as const, + text: `Container type registration \`${containerTypeId}\` was already deleted (not found).`, + }], + }; + } + if (e instanceof AppError && e.code === "CONFLICT") { + return err( + `cannot delete registration \`${containerTypeId}\` yet: it still has containers or recycle-bin ` + + "containers. Permanently delete them first (use container_deleted_list to find recycle-bin " + + "containers), then retry.", + ); + } + return err(`deleting container type registration: ${reason(e)}`); + } + }, +}; diff --git a/src/tools/container-type-shared.test.ts b/src/tools/container-type-shared.test.ts new file mode 100644 index 0000000..0e5b955 --- /dev/null +++ b/src/tools/container-type-shared.test.ts @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for the shared container-type control-plane helpers extracted in + * WI-32 (DRY of container-type-permissions.ts + container-type-app-grants.ts). + * These lock the behavior that both tool modules now depend on. auth / state are + * mocked so the tests run offline. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../auth.js", () => ({ setAuthConfig: vi.fn() })); + +const stateStore: Record = {}; +vi.mock("../state.js", () => ({ readState: vi.fn(() => ({ ...stateStore })) })); + +import { setAuthConfig } from "../auth.js"; +import { authContainerTypeState, err, reason } from "./container-type-shared.js"; + +beforeEach(() => { + vi.clearAllMocks(); + for (const k of Object.keys(stateStore)) delete stateStore[k]; +}); + +describe("err", () => { + it("wraps text in a standard MCP error result", () => { + expect(err("boom")).toEqual({ + content: [{ type: "text", text: "Error: boom" }], + isError: true, + }); + }); +}); + +describe("reason", () => { + it("returns the message for an Error", () => { + expect(reason(new Error("nope"))).toBe("nope"); + }); + it("stringifies a non-Error value", () => { + expect(reason("plain")).toBe("plain"); + expect(reason(42)).toBe("42"); + }); +}); + +describe("authContainerTypeState", () => { + it("points MSAL at the owning app and returns the state defaults", () => { + Object.assign(stateStore, { appId: "app-1", tenantId: "t-1", containerTypeId: "ct-1" }); + const result = authContainerTypeState(); + expect(setAuthConfig).toHaveBeenCalledWith({ clientId: "app-1", tenantId: "t-1" }); + expect(result).toEqual({ containerTypeId: "ct-1", appId: "app-1" }); + }); + + it("does NOT configure auth when appId/tenantId are missing", () => { + Object.assign(stateStore, { containerTypeId: "ct-1" }); + const result = authContainerTypeState(); + expect(setAuthConfig).not.toHaveBeenCalled(); + expect(result).toEqual({ containerTypeId: "ct-1", appId: undefined }); + }); +}); diff --git a/src/tools/container-type-shared.ts b/src/tools/container-type-shared.ts new file mode 100644 index 0000000..161c184 --- /dev/null +++ b/src/tools/container-type-shared.ts @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Shared helpers for the container-type control-plane tools. + * + * Both `container-type-permissions.ts` (beta `permissions`/owner role) and + * `container-type-app-grants.ts` (v1.0 `applicationPermissionGrants`) point MSAL + * at the owning app and default the container type to the provisioned one, and + * both format errors identically. That logic is centralized here so the two tool + * modules stay DRY (see PR #3 review comments r3531896108, r3531803187). + */ + +import { setAuthConfig } from "../auth.js"; +import { readState } from "../state.js"; + +/** A standard MCP error result: `Error: ` with `isError: true`. */ +export function err(text: string) { + return { content: [{ type: "text" as const, text: `Error: ${text}` }], isError: true }; +} + +/** Narrow an unknown thrown value to a human-readable reason string. */ +export function reason(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +/** + * Point MSAL at the owning app (so SPE control-plane calls use its token) and + * return the provisioned owning-app id and container-type id as defaults. + * + * For app-grants the container-type id doubles as the registration id. + */ +export function authContainerTypeState(): { containerTypeId?: string; appId?: string } { + const state = readState(); + if (state.appId && state.tenantId) { + setAuthConfig({ clientId: state.appId, tenantId: state.tenantId }); + } + return { containerTypeId: state.containerTypeId, appId: state.appId }; +} diff --git a/src/tools/container-type-tools.test.ts b/src/tools/container-type-tools.test.ts new file mode 100644 index 0000000..71558d0 --- /dev/null +++ b/src/tools/container-type-tools.test.ts @@ -0,0 +1,217 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for the container-type CRUD + owner-permission tools. + * graph-client / auth / bootstrap / state are mocked so these run offline. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../graph-client.js", () => ({ + getSignedInUser: vi.fn(async () => ({ id: "user-1", userPrincipalName: "admin@x.com" })), + grantContainerTypeOwner: vi.fn(async () => ({ id: "perm-1", roles: ["owner"], grantedToV2: { user: { id: "user-1" } } })), + listContainerTypePermissions: vi.fn(async () => [{ id: "perm-1", roles: ["owner"], grantedToV2: { user: { id: "user-1" } } }]), + revokeContainerTypePermission: vi.fn(async () => undefined), + getContainerType: vi.fn(async () => ({ containerTypeId: "ct-1", displayName: "CT", owningAppId: "app-1", billingClassification: "trial" })), + updateContainerType: vi.fn(async () => ({ containerTypeId: "ct-1", displayName: "Renamed", owningAppId: "app-1" })), + deleteContainerType: vi.fn(async () => undefined), + listContainerTypes: vi.fn(async () => [{ containerTypeId: "ct-1", owningAppId: "app-1", displayName: "CT", billingClassification: "trial" }]), +})); +vi.mock("../auth.js", () => ({ setAuthConfig: vi.fn() })); +vi.mock("../bootstrap.js", () => ({ bootstrapTokenProvider: vi.fn(async () => "boot") })); + +const stateStore: Record = {}; +vi.mock("../state.js", () => ({ readState: vi.fn(() => ({ ...stateStore })) })); + +import * as graph from "../graph-client.js"; +import { grantContainerTypeOwnerTool, listContainerTypeOwnersTool, revokeContainerTypeOwnerTool } from "../tools/container-type-permissions.js"; +import { getContainerTypeTool, updateContainerTypeTool, deleteContainerTypeTool } from "../tools/container-type-crud.js"; +import { getSessionId } from "../session.js"; + +beforeEach(() => { + vi.clearAllMocks(); + for (const k of Object.keys(stateStore)) delete stateStore[k]; + // r-appgate: the owner grant/revoke tools are control-plane mutations gated by + // the restart confirmation guard; seed a confirmed session so the gate no-ops + // and each tool's own logic is exercised (gate coverage: context-gate.test.ts). + Object.assign(stateStore, { appId: "app-1", tenantId: "t-1", containerTypeId: "ct-1", confirmedSessionId: getSessionId() }); +}); + +describe("container_type_grant_owner", () => { + it("grants owner to the signed-in user by default and reports PCA creation", async () => { + const r = await grantContainerTypeOwnerTool.handler({}); + expect(graph.getSignedInUser).toHaveBeenCalled(); + expect(graph.grantContainerTypeOwner).toHaveBeenCalledWith("ct-1", "user-1"); + expect(r.isError).toBeFalsy(); + expect(r.content[0].text).toContain("owner"); + expect(r.content[0].text).toContain("public client"); + }); + + it("uses an explicit userId without resolving the signed-in user", async () => { + await grantContainerTypeOwnerTool.handler({ userId: "user-2" }); + expect(graph.getSignedInUser).not.toHaveBeenCalled(); + expect(graph.grantContainerTypeOwner).toHaveBeenCalledWith("ct-1", "user-2"); + }); + + it("proceeds normally when the signed-in user is a Member (userType present)", async () => { + vi.mocked(graph.getSignedInUser).mockResolvedValueOnce({ + id: "user-1", + userPrincipalName: "admin@x.com", + userType: "Member", + }); + const r = await grantContainerTypeOwnerTool.handler({}); + expect(graph.grantContainerTypeOwner).toHaveBeenCalledWith("ct-1", "user-1"); + expect(r.isError).toBeFalsy(); + }); + + it("returns a clear NON-BLOCKING message and does NOT grant when the signed-in user is a Guest", async () => { + vi.mocked(graph.getSignedInUser).mockResolvedValueOnce({ + id: "guest-1", + userPrincipalName: "alice_corp.com#EXT#@x.onmicrosoft.com", + userType: "Guest", + }); + + const r = await grantContainerTypeOwnerTool.handler({}); + + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("guest"); + expect(r.content[0].text).toContain("member"); + // Proactive: we never attempt the doomed grant for a guest default. + expect(graph.grantContainerTypeOwner).not.toHaveBeenCalled(); + }); + + it("surfaces the RAW reason for an explicit userId grant failure that mentions guest (not misdirected guidance)", async () => { + // PR #3 review: the guest-owner remap must apply ONLY to the self-target + // (signed-in user) path. For an EXPLICIT `userId`, a failure whose text merely + // mentions "guest" must surface its raw reason — the guest guidance would be + // misdirected here (the caller chose that user deliberately). + vi.mocked(graph.grantContainerTypeOwner).mockRejectedValueOnce( + new Error("Guest users cannot be added as owners of a container type."), + ); + + const r = await grantContainerTypeOwnerTool.handler({ userId: "guest-2" }); + + expect(r.isError).toBe(true); + // Raw reason, prefixed by the tool's own classifier. + expect(r.content[0].text).toContain("granting owner"); + expect(r.content[0].text).toContain("added as owners"); + // NOT the remapped self-target guidance. + expect(r.content[0].text).not.toContain("guest (B2B) users cannot be granted"); + }); + + it("maps a guest-owner rejection to guidance on the SELF-TARGET path (no explicit userId)", async () => { + // Self-target: /me resolves to a Member (so the proactive guest check passes), + // but the grant itself is rejected by Graph for a guest reason (userType can be + // absent from /me). The remap applies because we defaulted to the signed-in + // user. (PR #3 review.) + vi.mocked(graph.getSignedInUser).mockResolvedValueOnce({ + id: "user-1", + userPrincipalName: "admin@x.com", + userType: "Member", + }); + vi.mocked(graph.grantContainerTypeOwner).mockRejectedValueOnce( + new Error("Guest users cannot be added as owners of a container type."), + ); + + const r = await grantContainerTypeOwnerTool.handler({}); + + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("guest (B2B) users cannot be granted"); + expect(r.content[0].text).toContain("member"); + // The raw API sentence is replaced by actionable guidance. + expect(r.content[0].text).not.toContain("added as owners"); + }); + + it("still surfaces the raw reason for a non-guest grant failure", async () => { + vi.mocked(graph.grantContainerTypeOwner).mockRejectedValueOnce( + new Error("Too many owners (max 3)."), + ); + + const r = await grantContainerTypeOwnerTool.handler({ userId: "user-3" }); + + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("granting owner"); + expect(r.content[0].text).toContain("Too many owners"); + }); + + it("errors when no container type is known", async () => { + delete stateStore.containerTypeId; + const r = await grantContainerTypeOwnerTool.handler({}); + expect(r.isError).toBe(true); + expect(graph.grantContainerTypeOwner).not.toHaveBeenCalled(); + }); +}); + +describe("container_type_owners_list / revoke", () => { + it("lists owners", async () => { + const r = await listContainerTypeOwnersTool.handler({}); + expect(graph.listContainerTypePermissions).toHaveBeenCalledWith("ct-1"); + expect(r.content[0].text).toContain("perm-1"); + }); + + it("revokes by permission id", async () => { + const r = await revokeContainerTypeOwnerTool.handler({ permissionId: "perm-1" }); + expect(graph.revokeContainerTypePermission).toHaveBeenCalledWith("ct-1", "perm-1"); + expect(r.isError).toBeFalsy(); + }); + + it("requires a permission id to revoke", async () => { + const r = await revokeContainerTypeOwnerTool.handler({}); + expect(r.isError).toBe(true); + expect(graph.revokeContainerTypePermission).not.toHaveBeenCalled(); + }); +}); + +describe("container_type_get / update", () => { + it("gets the container type", async () => { + const r = await getContainerTypeTool.handler({}); + expect(graph.getContainerType).toHaveBeenCalledWith("ct-1"); + expect(r.content[0].text).toContain("ct-1"); + }); + + it("updates the display name", async () => { + const r = await updateContainerTypeTool.handler({ displayName: "Renamed" }); + // The beta Update fileStorageContainerType API uses `name` (not displayName). + expect(graph.updateContainerType).toHaveBeenCalledWith("ct-1", { name: "Renamed" }); + expect(r.isError).toBeFalsy(); + }); + + it("errors when there is nothing to update", async () => { + const r = await updateContainerTypeTool.handler({}); + expect(r.isError).toBe(true); + expect(graph.updateContainerType).not.toHaveBeenCalled(); + }); +}); + +describe("container_type_delete — trial-only policy", () => { + it("requires confirmation", async () => { + const r = await deleteContainerTypeTool.handler({}); + expect(r.content[0].text).toContain("Confirm delete"); + expect(graph.deleteContainerType).not.toHaveBeenCalled(); + }); + + it("deletes a trial container type with confirm=true", async () => { + const r = await deleteContainerTypeTool.handler({ confirm: true }); + expect(graph.deleteContainerType).toHaveBeenCalledWith("ct-1"); + expect(r.isError).toBeFalsy(); + }); + + it("PROTECTS a standard container type without the override", async () => { + vi.mocked(graph.listContainerTypes).mockResolvedValueOnce([ + { containerTypeId: "ct-1", owningAppId: "app-1", displayName: "CT", billingClassification: "standard" }, + ]); + const r = await deleteContainerTypeTool.handler({ confirm: true }); + expect(r.isError).toBe(true); + expect(graph.deleteContainerType).not.toHaveBeenCalled(); + }); + + it("deletes a standard container type with deleteStandard=true", async () => { + vi.mocked(graph.listContainerTypes).mockResolvedValueOnce([ + { containerTypeId: "ct-1", owningAppId: "app-1", displayName: "CT", billingClassification: "standard" }, + ]); + const r = await deleteContainerTypeTool.handler({ confirm: true, deleteStandard: true }); + expect(graph.deleteContainerType).toHaveBeenCalledWith("ct-1"); + expect(r.isError).toBeFalsy(); + }); +}); diff --git a/src/tools/content-access.test.ts b/src/tools/content-access.test.ts new file mode 100644 index 0000000..d9ef8cc --- /dev/null +++ b/src/tools/content-access.test.ts @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for content-plane access enforcement. + * + * The bug: `isContentAccessGranted()` existed but no content handler called it, + * so content-plane tools (upload/search/preview/...) ran even when the user + * never opted in. These assert the gate now fails closed and that wrapping a + * real content tool blocks it until access is granted. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mutable mocked state store (mirrors the provisioning test pattern). +const stateStore: Record = {}; +vi.mock("../state.js", () => ({ + readState: vi.fn(() => ({ ...stateStore })), + writeState: vi.fn((patch: Record) => { + Object.assign(stateStore, patch); + return { ...stateStore }; + }), +})); + +// Mock the graph client so wrapping a real content tool doesn't hit the network. +vi.mock("../graph-client.js", () => ({ + getContainerDrive: vi.fn(), + uploadSmallFile: vi.fn(), +})); + +import * as graph from "../graph-client.js"; +import { + isContentAccessGranted, + requireContentAccess, + withContentAccess, +} from "../tools/content-access.js"; +import { uploadFileTool } from "../tools/upload-file.js"; +import type { McpTool } from "../types.js"; + +beforeEach(() => { + vi.clearAllMocks(); + for (const k of Object.keys(stateStore)) delete stateStore[k]; +}); + +describe("requireContentAccess gate", () => { + it("denies (fails closed) when content access is NOT granted", () => { + const denied = requireContentAccess(); + expect(denied).not.toBeNull(); + expect(denied?.isError).toBe(true); + expect(denied?.content[0].text).toContain("Content access not enabled"); + expect(denied?.content[0].text).toContain("content_access_grant"); + }); + + it("allows (returns null) once content access is granted", () => { + stateStore.contentAccessGranted = true; + expect(isContentAccessGranted()).toBe(true); + expect(requireContentAccess()).toBeNull(); + }); +}); + +describe("withContentAccess wrapper", () => { + const stub: McpTool = { + name: "content_stub", + description: "stub", + inputSchema: { type: "object", properties: {} }, + handler: vi.fn(async () => ({ content: [{ type: "text" as const, text: "ran" }] })), + }; + + it("blocks the inner handler when ungated", async () => { + const wrapped = withContentAccess(stub); + const result = await wrapped.handler({}); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Content access not enabled"); + expect(stub.handler).not.toHaveBeenCalled(); + }); + + it("runs the inner handler when access is granted", async () => { + stateStore.contentAccessGranted = true; + const wrapped = withContentAccess(stub); + const result = await wrapped.handler({ a: 1 }); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toBe("ran"); + expect(stub.handler).toHaveBeenCalledWith({ a: 1 }); + }); + + it("preserves tool metadata (name/description/schema)", () => { + const wrapped = withContentAccess(stub); + expect(wrapped.name).toBe(stub.name); + expect(wrapped.description).toBe(stub.description); + expect(wrapped.inputSchema).toBe(stub.inputSchema); + }); +}); + +describe("real content tool enforcement (upload)", () => { + it("ungated: fails closed and never touches Graph", async () => { + const wrapped = withContentAccess(uploadFileTool); + const result = await wrapped.handler({ containerId: "c1", fileName: "a.txt", content: "hi" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("content_access_grant"); + expect(graph.getContainerDrive).not.toHaveBeenCalled(); + }); + + it("gated: proceeds to the real handler once granted", async () => { + stateStore.contentAccessGranted = true; + vi.mocked(graph.getContainerDrive).mockResolvedValue({ id: "d1" }); + vi.mocked(graph.uploadSmallFile).mockResolvedValue({ id: "i1", name: "a.txt", size: 2 }); + + const wrapped = withContentAccess(uploadFileTool); + const result = await wrapped.handler({ containerId: "c1", fileName: "a.txt", content: "hi" }); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain("File Uploaded"); + expect(graph.getContainerDrive).toHaveBeenCalledWith("c1"); + }); +}); diff --git a/src/tools/content-access.ts b/src/tools/content-access.ts new file mode 100644 index 0000000..d9240b8 --- /dev/null +++ b/src/tools/content-access.ts @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tools: content_access_grant, content_access_revoke + * + * Content-plane access (reading/managing files inside the dev's containers) is + * OFF by default. These tools implement the PRD's opt-in, separately-consented, + * revocable content access: + * - grant: confirm intent (agent-guided elicitation), then mark content + * access enabled in state. The next content operation triggers a one-time + * sign-in for the content scopes. + * - revoke: clear the content-access flag; control-plane work is unaffected. + * + * Content tools (upload/search/preview/sharing/seed) check this flag. + */ + +import { readState, writeState } from "../state.js"; +import type { McpTool, McpToolResult } from "../types.js"; + +export const grantContentAccessTool: McpTool = { + name: "content_access_grant", + annotations: { requiresConsent: true }, + description: + "Grant the SPE Builder access to read and manage files inside your containers (content plane). " + + "This is off by default and separate from provisioning. Pass confirm=true to enable; the next " + + "content operation will prompt a one-time sign-in for content scopes. Revoke any time with " + + "content_access_revoke.", + inputSchema: { + type: "object" as const, + properties: { + confirm: { + type: "boolean", + description: "Set true to confirm you want to enable content (file) access.", + }, + }, + }, + handler: async (args) => { + const confirm = args.confirm === true; + if (!confirm) { + return { + content: [{ + type: "text" as const, + text: + "### Enable content access?\n\n" + + "This lets the SPE Builder **read and manage files** inside your containers — separate " + + "from creating/provisioning resources.\n\n" + + "> To proceed, re-run `content_access_grant` with `confirm=true`. You can revoke it " + + "any time with `content_access_revoke`.", + }], + }; + } + + writeState({ contentAccessGranted: true }); + return { + content: [{ + type: "text" as const, + text: + "## Content Access Granted\n\n" + + "File read/manage operations are now enabled. The next content operation will prompt a " + + "one-time sign-in for the content scopes.\n\n" + + "> Revoke any time with `content_access_revoke`.", + }], + }; + }, +}; + +export const revokeContentAccessTool: McpTool = { + name: "content_access_revoke", + annotations: { destructive: true }, + description: + "Revoke the SPE Builder's content-plane (file read/manage) access. Control-plane provisioning " + + "is unaffected.", + inputSchema: { type: "object" as const, properties: {} }, + handler: async () => { + writeState({ contentAccessGranted: false }); + return { + content: [{ + type: "text" as const, + text: "## Content Access Revoked\n\nFile read/manage access is disabled. Provisioning still works normally.", + }], + }; + }, +}; + +/** Whether content-plane access has been granted (checked by content tools). */ +export function isContentAccessGranted(): boolean { + return readState().contentAccessGranted === true; +} + +/** + * Gate for content-plane tools (upload/create-folder/search/preview/sharing/seed). + * + * Content access is OFF by default (PRD opt-in). Call this at the top of every + * content-plane tool handler and return its result if non-null, so the tool + * FAILS CLOSED with actionable guidance when the developer has not opted in. + * + * @returns an error `McpToolResult` when access is NOT granted, or `null` when + * access has been granted and the operation may proceed. + */ +export function requireContentAccess(): McpToolResult | null { + if (isContentAccessGranted()) { + return null; + } + return { + content: [{ + type: "text" as const, + text: + "### Content access not enabled\n\n" + + "This tool reads or manages **files inside your containers** (content plane), which is " + + "**off by default** and separate from provisioning.\n\n" + + "> To enable it, run `content_access_grant` with `confirm=true`. The next content " + + "operation will prompt a one-time sign-in for the content scopes. You can revoke access " + + "any time with `content_access_revoke`.", + }], + isError: true, + }; +} + +/** + * Wrap a content-plane tool so its handler fails closed unless content access + * has been granted. Applied at registration (see index.ts) so the enforcement + * lives in exactly one place and cannot drift per-handler. Control-plane tools + * are never wrapped and are therefore unaffected. + */ +export function withContentAccess(tool: McpTool): McpTool { + return { + ...tool, + handler: async (args) => { + const denied = requireContentAccess(); + if (denied) return denied; + return tool.handler(args); + }, + }; +} diff --git a/src/tools/content-operations.test.ts b/src/tools/content-operations.test.ts new file mode 100644 index 0000000..af3571c --- /dev/null +++ b/src/tools/content-operations.test.ts @@ -0,0 +1,287 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for content operations tools. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../graph-client.js", () => ({ + getContainerDrive: vi.fn(), + getDriveItem: vi.fn(), + uploadSmallFile: vi.fn(), + createFolder: vi.fn(), + listDriveChildren: vi.fn(), + searchContent: vi.fn(), + previewDriveItem: vi.fn(), + createSharingLink: vi.fn(), + listDriveItemPermissions: vi.fn(), + revokeSharingLink: vi.fn(), +})); + +// Content-plane tools are gated by the content-access opt-in, +// which reads provisioning state. Mock state with a mutable store so tests can +// toggle the opt-in; default to granted so the happy-path tests below pass. +const stateStore: Record = { contentAccessGranted: true }; +vi.mock("../state.js", () => ({ + readState: vi.fn(() => ({ ...stateStore })), + writeState: vi.fn((p: Record) => { Object.assign(stateStore, p); return { ...stateStore }; }), + clearState: vi.fn(() => { for (const k of Object.keys(stateStore)) delete stateStore[k]; }), +})); + +import * as graph from "../graph-client.js"; +import { uploadFileTool } from "../tools/upload-file.js"; +import { createFolderTool } from "../tools/create-folder.js"; +import { searchContentTool } from "../tools/search-content.js"; +import { previewFileTool } from "../tools/preview-file.js"; +import { manageSharingTool } from "../tools/manage-sharing.js"; +import { seedSampleDataTool } from "../tools/seed-sample-data.js"; + +beforeEach(() => { + vi.clearAllMocks(); + for (const k of Object.keys(stateStore)) delete stateStore[k]; + stateStore.contentAccessGranted = true; +}); + +// ─── content_file_upload ──────────────────────────────────────────────────────── + +describe("content_file_upload", () => { + it("uploads text content", async () => { + vi.mocked(graph.getContainerDrive).mockResolvedValue({ id: "d1" }); + vi.mocked(graph.uploadSmallFile).mockResolvedValue({ + id: "item1", name: "test.txt", size: 100, webUrl: "https://example.com/test.txt", + }); + + const result = await uploadFileTool.handler({ + containerId: "c1", fileName: "test.txt", content: "hello world", + }); + expect(result.content[0].text).toContain("File Uploaded"); + expect(result.content[0].text).toContain("test.txt"); + expect(graph.uploadSmallFile).toHaveBeenCalledWith("d1", "/test.txt", "hello world"); + }); + + it("uploads to a folder path", async () => { + vi.mocked(graph.getContainerDrive).mockResolvedValue({ id: "d1" }); + vi.mocked(graph.uploadSmallFile).mockResolvedValue({ + id: "item1", name: "test.txt", size: 100, + }); + + await uploadFileTool.handler({ + containerId: "c1", fileName: "test.txt", content: "data", folderPath: "Documents/Reports", + }); + expect(graph.uploadSmallFile).toHaveBeenCalledWith("d1", "/Documents/Reports/test.txt", "data"); + }); + + it("requires all parameters", async () => { + const r = await uploadFileTool.handler({ containerId: "c1" }); + expect(r.isError).toBe(true); + }); +}); + +// ─── content_folder_create ────────────────────────────────────────────────────── + +describe("content_folder_create", () => { + it("creates nested folders", async () => { + vi.mocked(graph.getContainerDrive).mockResolvedValue({ id: "d1" }); + vi.mocked(graph.createFolder) + .mockResolvedValueOnce({ id: "f1", name: "Documents" }) + .mockResolvedValueOnce({ id: "f2", name: "Reports" }); + + const result = await createFolderTool.handler({ + containerId: "c1", folderPath: "Documents/Reports", + }); + expect(result.content[0].text).toContain("Documents"); + expect(result.content[0].text).toContain("Reports"); + expect(result.content[0].text).toContain("f2"); + }); + + it("handles already-existing folders", async () => { + vi.mocked(graph.getContainerDrive).mockResolvedValue({ id: "d1" }); + vi.mocked(graph.createFolder).mockRejectedValue(new Error("nameAlreadyExists")); + vi.mocked(graph.listDriveChildren).mockResolvedValue([ + { id: "f1", name: "Docs", folder: {} }, + ]); + + const result = await createFolderTool.handler({ + containerId: "c1", folderPath: "Docs", + }); + expect(result.content[0].text).toContain("exists"); + }); +}); + +// ─── content_search ───────────────────────────────────────────────────── + +describe("content_search", () => { + it("returns search results", async () => { + vi.mocked(graph.searchContent).mockResolvedValue({ + value: [{ + hitsContainers: [{ + total: 1, + hits: [{ + resource: { name: "report.pdf", webUrl: "https://example.com/report.pdf", size: 5120 }, + summary: "Quarterly report", + }], + }], + }], + }); + + const result = await searchContentTool.handler({ query: "quarterly report" }); + expect(result.content[0].text).toContain("report.pdf"); + expect(result.content[0].text).toContain("Search Results"); + }); + + it("handles no results", async () => { + vi.mocked(graph.searchContent).mockResolvedValue({ + value: [{ hitsContainers: [{ total: 0, hits: [] }] }], + }); + + const result = await searchContentTool.handler({ query: "nonexistent" }); + expect(result.content[0].text).toContain("No results found"); + }); + + it("requires query parameter", async () => { + const r = await searchContentTool.handler({}); + expect(r.isError).toBe(true); + }); +}); + +// ─── content_file_preview ─────────────────────────────────────────────────────── + +describe("content_file_preview", () => { + it("generates preview URL", async () => { + vi.mocked(graph.getContainerDrive).mockResolvedValue({ id: "d1" }); + vi.mocked(graph.getDriveItem).mockResolvedValue({ + id: "item1", name: "report.pdf", size: 2048, + }); + vi.mocked(graph.previewDriveItem).mockResolvedValue({ + getUrl: "https://example.com/preview/report", + }); + + const result = await previewFileTool.handler({ + containerId: "c1", filePath: "report.pdf", + }); + expect(result.content[0].text).toContain("Preview URL"); + expect(result.content[0].text).toContain("https://example.com/preview/report"); + }); +}); + +// ─── content_sharing_manage ───────────────────────────────────────────────────── + +describe("content_sharing_manage", () => { + beforeEach(() => { + vi.mocked(graph.getContainerDrive).mockResolvedValue({ id: "d1" }); + vi.mocked(graph.getDriveItem).mockResolvedValue({ + id: "item1", name: "report.pdf", + }); + }); + + it("creates a sharing link", async () => { + vi.mocked(graph.createSharingLink).mockResolvedValue({ + id: "sl1", + link: { type: "view", scope: "organization", webUrl: "https://share.example.com/link1" }, + }); + + const result = await manageSharingTool.handler({ + containerId: "c1", filePath: "report.pdf", action: "create", linkType: "view", + }); + expect(result.content[0].text).toContain("Sharing link created"); + expect(result.content[0].text).toContain("https://share.example.com/link1"); + }); + + it("lists sharing links", async () => { + vi.mocked(graph.listDriveItemPermissions).mockResolvedValue([ + { id: "sl1", link: { type: "view", scope: "organization", webUrl: "https://link1" } }, + { id: "sl2", link: { type: "edit", scope: "anonymous", webUrl: "https://link2" } }, + ]); + + const result = await manageSharingTool.handler({ + containerId: "c1", filePath: "report.pdf", action: "list", + }); + expect(result.content[0].text).toContain("Sharing Links"); + expect(result.content[0].text).toContain("sl1"); + expect(result.content[0].text).toContain("sl2"); + }); + + it("revokes a sharing link", async () => { + vi.mocked(graph.revokeSharingLink).mockResolvedValue(); + const result = await manageSharingTool.handler({ + containerId: "c1", filePath: "report.pdf", action: "revoke", permissionId: "sl1", + }); + expect(result.content[0].text).toContain("revoked"); + }); + + it("requires permissionId for revoke", async () => { + const r = await manageSharingTool.handler({ + containerId: "c1", filePath: "report.pdf", action: "revoke", + }); + expect(r.isError).toBe(true); + }); +}); + +// ─── content-access opt-in gate ──────────────────────────────── +// +// Regression: content-plane tools previously bypassed the content-access opt-in +// and called Graph even when access had NOT been granted. They must now fail +// CLOSED with actionable guidance when not opted-in, and only proceed once the +// developer has granted access. + +describe("content-access opt-in gate", () => { + const gatedTools: Array<{ name: string; call: () => Promise<{ isError?: boolean; content: Array<{ text: string }> }> }> = [ + { name: "content_file_upload", call: () => uploadFileTool.handler({ containerId: "c1", fileName: "f.txt", content: "x" }) }, + { name: "content_folder_create", call: () => createFolderTool.handler({ containerId: "c1", folderPath: "Docs" }) }, + { name: "content_search", call: () => searchContentTool.handler({ query: "report" }) }, + { name: "content_file_preview", call: () => previewFileTool.handler({ containerId: "c1", filePath: "f.txt" }) }, + { name: "content_sharing_manage", call: () => manageSharingTool.handler({ containerId: "c1", filePath: "f.txt", action: "list" }) }, + { name: "content_sample_seed", call: () => seedSampleDataTool.handler({ containerTypeId: "ct1" }) }, + ]; + + describe("blocks when content access has NOT been granted", () => { + beforeEach(() => { + stateStore.contentAccessGranted = false; + }); + + for (const { name, call } of gatedTools) { + it(`${name} fails closed with actionable guidance`, async () => { + const r = await call(); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("Content access not enabled"); + expect(r.content[0].text).toContain("content_access_grant"); + // Must short-circuit before touching Graph. + expect(graph.getContainerDrive).not.toHaveBeenCalled(); + expect(graph.searchContent).not.toHaveBeenCalled(); + }); + } + + it("does not treat a missing flag as granted (fail closed by default)", async () => { + delete stateStore.contentAccessGranted; + const r = await uploadFileTool.handler({ containerId: "c1", fileName: "f.txt", content: "x" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("Content access not enabled"); + }); + }); + + describe("allows when content access HAS been granted", () => { + beforeEach(() => { + stateStore.contentAccessGranted = true; + }); + + it("content_file_upload proceeds to Graph once opted-in", async () => { + vi.mocked(graph.getContainerDrive).mockResolvedValue({ id: "d1" }); + vi.mocked(graph.uploadSmallFile).mockResolvedValue({ id: "i1", name: "f.txt", size: 1 }); + const r = await uploadFileTool.handler({ containerId: "c1", fileName: "f.txt", content: "x" }); + expect(r.isError).toBeFalsy(); + expect(r.content[0].text).toContain("File Uploaded"); + expect(graph.getContainerDrive).toHaveBeenCalled(); + }); + + it("content_search proceeds to Graph once opted-in", async () => { + vi.mocked(graph.searchContent).mockResolvedValue({ + value: [{ hitsContainers: [{ total: 0, hits: [] }] }], + }); + const r = await searchContentTool.handler({ query: "anything" }); + expect(r.isError).toBeFalsy(); + expect(graph.searchContent).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/tools/context-gate.test.ts b/src/tools/context-gate.test.ts new file mode 100644 index 0000000..daadc93 --- /dev/null +++ b/src/tools/context-gate.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for the restart confirmation gate (context-gate.ts) — PR #3 + * review: r-appgate. Verifies that control-plane mutation handlers are asked to confirm + * the remembered owning app / container type on a fresh (unconfirmed) session, + * that confirmation clears the gate for the rest of the process, and that the + * staleness warning fires when the owning app cannot enumerate all CTs. + * + * state.js is mocked with an in-memory store shared by session.js (imported + * transitively) so stampContextConfirmed round-trips without disk I/O. + * elicitation.js is REAL so we assert on the actual agent-guided choice text. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { ProvisioningState } from "../state.js"; + +let stateStore: ProvisioningState; + +vi.mock("../state.js", () => ({ + readState: () => ({ ...stateStore }), + writeState: (patch: Partial) => { + stateStore = { ...stateStore, ...patch }; + return { ...stateStore }; + }, +})); + +import { getSessionId } from "../session.js"; +import { requireConfirmedContext, resolveContextGate } from "./context-gate.js"; + +beforeEach(() => { + stateStore = {}; +}); + +describe("requireConfirmedContext", () => { + it("returns null when there is nothing remembered to confirm", async () => { + expect(await requireConfirmedContext()).toBeNull(); + }); + + it("returns a confirm/switch choice when an owning app is remembered but unconfirmed", async () => { + stateStore = { appId: "app-1", appDisplayName: "Contoso Docs App", containerTypeName: "Docs CT" }; + + const r = await requireConfirmedContext(); + + expect(r).not.toBeNull(); + expect(r?.isError).toBe(false); + const text = r?.content[0].text ?? ""; + expect(text).toContain("Confirm the active"); + expect(text).toContain("Contoso Docs App"); + expect(text).toContain("Docs CT"); + expect(text).toContain("contextChoice=confirm"); + expect(text).toContain("contextChoice=switch"); + }); + + it("also fires when only a container type is remembered (no owning app)", async () => { + stateStore = { containerTypeId: "ct-1" }; + expect(await requireConfirmedContext()).not.toBeNull(); + }); + + it("returns null once the context is confirmed under the current session", async () => { + stateStore = { appId: "app-1", confirmedSessionId: getSessionId() }; + expect(await requireConfirmedContext()).toBeNull(); + }); + + it("notes a prior session when contextConfirmedAt was set by an earlier process", async () => { + stateStore = { + appId: "app-1", + contextConfirmedAt: "2024-01-01T00:00:00.000Z", + confirmedSessionId: "some-old-session", + }; + + const text = (await requireConfirmedContext())?.content[0].text ?? ""; + expect(text).toContain("prior session"); + }); + + it("appends a staleness warning only when owningAppManagesAllContainerTypes === false", async () => { + stateStore = { appId: "app-1", owningAppManagesAllContainerTypes: false }; + expect((await requireConfirmedContext())?.content[0].text).toContain("stale"); + + stateStore = { appId: "app-1", owningAppManagesAllContainerTypes: true }; + expect((await requireConfirmedContext())?.content[0].text).not.toContain("stale"); + + stateStore = { appId: "app-1" }; // undefined => unknown => no warning + expect((await requireConfirmedContext())?.content[0].text).not.toContain("stale"); + }); +}); + +describe("resolveContextGate", () => { + it("stamps the session confirmed and returns null on 'confirm'", async () => { + stateStore = { appId: "app-1" }; + + const r = await resolveContextGate("confirm"); + + expect(r).toBeNull(); + expect(stateStore.confirmedSessionId).toBe(getSessionId()); + expect(stateStore.contextConfirmedAt).toBeTruthy(); + }); + + it("directs the user to re-provision on 'switch' (without stamping)", async () => { + stateStore = { appId: "app-1" }; + + const r = await resolveContextGate("switch"); + + expect(r).not.toBeNull(); + expect(r?.isError).toBe(false); + expect(r?.content[0].text).toContain("project_provision"); + expect(stateStore.confirmedSessionId).toBeUndefined(); + }); + + it("returns the confirmation choice when no contextChoice is supplied (unconfirmed)", async () => { + stateStore = { appId: "app-1" }; + expect(await resolveContextGate(undefined)).not.toBeNull(); + }); + + it("returns null when no contextChoice is supplied but the session is already confirmed", async () => { + stateStore = { appId: "app-1", confirmedSessionId: getSessionId() }; + expect(await resolveContextGate(undefined)).toBeNull(); + }); +}); diff --git a/src/tools/context-gate.ts b/src/tools/context-gate.ts new file mode 100644 index 0000000..5509973 --- /dev/null +++ b/src/tools/context-gate.ts @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Restart confirmation gate for control-plane MUTATION tools (PR #3 review: r-appgate). + * + * A freshly restarted process must not act on the remembered owning app / + * container type until the user has confirmed that context under the current + * session (see session.ts). This gate is wired at the TOP of the control-plane + * MUTATION handlers (create/register a container type, grant/revoke owners, + * add/remove app grants). Read-only/status tools are intentionally NOT gated — + * reads should never nag. + * + * Elicitation prefers the NATIVE MCP capability (PR #3 review): on a client + * that supports `elicitation/create`, the gate prompts the user directly and, + * on "confirm", continues in-band (stamps the session and returns `null`). When + * the client does not support elicitation, it falls back to a structured + * `needChoice` McpToolResult (agent-guided) — identical to prior behavior. + */ + +import { elicitChoice } from "../elicitation.js"; +import { isContextConfirmedThisSession, stampContextConfirmed } from "../session.js"; +import { readState } from "../state.js"; +import type { McpToolResult } from "../types.js"; + +/** + * Short message directing the user to re-provision when they choose to switch + * away from the remembered owning app / container type. + */ +function switchContextMessage(): McpToolResult { + return { + content: [ + { + type: "text", + text: + "Switching owning app / container type. Run **project_provision** " + + "(or **project_app_create**) and choose the app you want, then retry this operation.", + }, + ], + isError: false, + }; +} + +/** + * If there is a remembered owning app / container type that has NOT been + * confirmed under the current session, ask the user to confirm (continue) or + * switch (pick a different app/container type). + * + * Prefers native MCP elicitation: on a capable client the user is prompted + * directly and, on "confirm", we stamp the session and return `null` so the + * caller PROCEEDS in-band; on "switch" we return the re-provision message. When + * elicitation is unavailable the fallback `needChoice` text is returned (the + * agent re-invokes with `contextChoice`). Returns `null` when already confirmed + * this session (no friction mid-session) or when there is nothing remembered. + */ +export async function requireConfirmedContext(): Promise { + const s = readState(); + if (!(s.appId || s.containerTypeId)) return null; // nothing to confirm + if (isContextConfirmedThisSession(s)) return null; // already confirmed this session + + const app = s.appDisplayName ?? s.appId ?? "(unknown app)"; + const ct = s.containerTypeName ?? s.containerTypeId ?? "(none)"; + const priorNote = s.contextConfirmedAt + ? " This context is remembered from a **prior session** (the server has since restarted)." + : ""; + const staleNote = + s.owningAppManagesAllContainerTypes === false + ? "\n\n> ⚠️ The remembered container-type list may be **stale**: the owning app lacks " + + "`FileStorageContainerType.Manage.All`, so it cannot enumerate all container types." + : ""; + + const choice = await elicitChoice( + `Confirm the active SharePoint Embedded context before continuing.\n\n` + + `- **Owning app:** ${app}\n` + + `- **Container type:** ${ct}${priorNote}${staleNote}`, + [ + { + label: "Yes, continue with this app/container type", + value: "confirm", + description: "keep using the remembered owning app + container type", + }, + { + label: "No, choose a different app/container type", + value: "switch", + description: "run project_provision / project_app_create to pick another app", + }, + ], + "contextChoice", + ); + if (!choice.resolved) return choice.result; // fallback text ask, or user declined + // Native path resolved the ask in-band. + if (choice.value === "confirm") { + stampContextConfirmed(); + return null; // proceed + } + return switchContextMessage(); // "switch" +} + +/** + * Resolve the gate for a mutation handler given the caller-supplied + * `contextChoice` argument: + * - "confirm" → stamp the session as confirmed and return `null` (proceed). + * - "switch" → return a short message directing the user to re-provision. + * - absent → prompt (native elicitation, else the fallback choice), or + * `null` if already confirmed / nothing remembered. + * + * A handler should call this first and, when it returns non-null, return that + * result immediately (before touching the owning app / container type). + */ +export async function resolveContextGate(contextChoice?: string): Promise { + if (contextChoice === "confirm") { + stampContextConfirmed(); + return null; + } + if (contextChoice === "switch") { + return switchContextMessage(); + } + return await requireConfirmedContext(); +} diff --git a/src/tools/create-app.test.ts b/src/tools/create-app.test.ts new file mode 100644 index 0000000..40c85ac --- /dev/null +++ b/src/tools/create-app.test.ts @@ -0,0 +1,369 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for project_app_create (create-app.ts), focused on the reuse/attach + * path SPA redirect-URI self-repair. + * + * Apps created before have no `spa` platform, so the generated browser + * app's MSAL.js auth-code + PKCE sign-in fails with AADSTS9002326. The fresh-create + * path sets `spa` via createApplication; the reuse path must self-repair an + * existing app by calling addSpaRedirectUris(objectId, [LOCAL_SPA_REDIRECT_URI], + * …, { bestEffort: true }) — idempotently and without failing the tool when the + * PATCH lacks permission. + * + * Graph / bootstrap / auth / state are mocked so nothing hits the network. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { readFileSync } from "node:fs"; +import { LOCAL_SPA_REDIRECT_URI } from "../constants.js"; + +const getSignedInIdentityMock = vi.fn(); +const findApplicationByAppIdMock = vi.fn(); +const findApplicationByNameMock = vi.fn(); +const createApplicationMock = vi.fn(); +const addSpePermissionsMock = vi.fn(); +const addSpaRedirectUrisMock = vi.fn(); +const setAuthConfigMock = vi.fn(); +const readStateMock = vi.fn(); +const writeStateMock = vi.fn(); + +vi.mock("../bootstrap.js", () => ({ + getSignedInIdentity: () => getSignedInIdentityMock(), + bootstrapTokenProvider: vi.fn(async () => "boot-token"), +})); + +vi.mock("../graph-client.js", () => ({ + addSpaRedirectUris: (...args: unknown[]) => addSpaRedirectUrisMock(...args), + addSpePermissions: (...args: unknown[]) => addSpePermissionsMock(...args), + createApplication: (...args: unknown[]) => createApplicationMock(...args), + findApplicationByAppId: (...args: unknown[]) => findApplicationByAppIdMock(...args), + findApplicationByName: (...args: unknown[]) => findApplicationByNameMock(...args), +})); + +vi.mock("../auth.js", () => ({ + setAuthConfig: (...args: unknown[]) => setAuthConfigMock(...args), +})); + +vi.mock("../state.js", () => ({ + readState: () => readStateMock(), + writeState: (...args: unknown[]) => writeStateMock(...args), +})); + +import { createAppTool } from "./create-app.js"; +import { getSessionId } from "../session.js"; +import { + wireElicitation, + resetElicitationForTests, + type ElicitInputResult, +} from "../elicitation.js"; + +const EXISTING_APP = { + appId: "cd7243b7-f00c-4aec-8a96-67e0a15ea5e6", + objectId: "obj-existing-123", + displayName: "SPE Builder App", +}; + +beforeEach(() => { + vi.clearAllMocks(); + getSignedInIdentityMock.mockResolvedValue({ tenantId: "tenant-1" }); + readStateMock.mockReturnValue({}); + findApplicationByAppIdMock.mockResolvedValue(null); + findApplicationByNameMock.mockResolvedValue(null); + addSpePermissionsMock.mockResolvedValue(undefined); + addSpaRedirectUrisMock.mockResolvedValue({ added: [], redirectUris: [LOCAL_SPA_REDIRECT_URI] }); +}); + +describe("project_app_create — reuse/attach SPA self-repair", () => { + it("calls addSpaRedirectUris with the existing app's objectId and {bestEffort:true} on reuse", async () => { + findApplicationByNameMock.mockResolvedValue(EXISTING_APP); + + const r = await createAppTool.handler({}); + + expect(r.isError).toBeUndefined(); + expect(addSpaRedirectUrisMock).toHaveBeenCalledTimes(1); + const [objectId, origins, , options] = addSpaRedirectUrisMock.mock.calls[0]; + expect(objectId).toBe(EXISTING_APP.objectId); + expect(origins).toEqual([LOCAL_SPA_REDIRECT_URI]); + expect(options).toEqual({ bestEffort: true }); + // Must not double-apply via the fresh-create path. + expect(createApplicationMock).not.toHaveBeenCalled(); + }); + + it("resolves by persisted appId and self-repairs that app", async () => { + readStateMock.mockReturnValue({ appId: EXISTING_APP.appId }); + findApplicationByAppIdMock.mockResolvedValue(EXISTING_APP); + + const r = await createAppTool.handler({ appSelection: "reuse" }); + + expect(r.content[0].text).toContain("Owning App Found"); + expect(findApplicationByAppIdMock).toHaveBeenCalledWith(EXISTING_APP.appId, expect.anything()); + expect(addSpaRedirectUrisMock).toHaveBeenCalledTimes(1); + expect(addSpaRedirectUrisMock.mock.calls[0][0]).toBe(EXISTING_APP.objectId); + }); + + it("is an idempotent no-op when the local origin is already registered (tool still succeeds)", async () => { + findApplicationByNameMock.mockResolvedValue(EXISTING_APP); + // Helper reports nothing added because the origin is already present. + addSpaRedirectUrisMock.mockResolvedValue({ added: [], redirectUris: [LOCAL_SPA_REDIRECT_URI] }); + + const r = await createAppTool.handler({}); + + expect(r.isError).toBeUndefined(); + expect(addSpaRedirectUrisMock).toHaveBeenCalledTimes(1); + }); + + it("swallows a best-effort PATCH failure — the tool still succeeds", async () => { + findApplicationByNameMock.mockResolvedValue(EXISTING_APP); + // best-effort failure surfaces as undefined from the helper (it logs + swallows). + addSpaRedirectUrisMock.mockResolvedValue(undefined); + + const r = await createAppTool.handler({}); + + expect(r.isError).toBeUndefined(); + expect(r.content[0].text).toContain("Owning App Found"); + }); + + it("does NOT self-repair on the fresh-create path (createApplication already sets spa)", async () => { + findApplicationByNameMock.mockResolvedValue(null); + createApplicationMock.mockResolvedValue({ + appId: "new-app", + objectId: "obj-new", + displayName: "SPE Builder App", + }); + + const r = await createAppTool.handler({}); + + expect(r.content[0].text).toContain("Owning App Created"); + expect(createApplicationMock).toHaveBeenCalledTimes(1); + expect(addSpaRedirectUrisMock).not.toHaveBeenCalled(); + }); +}); + +describe("project_app_create — ask before reusing a remembered app (PM feedback)", () => { + const REMEMBERED = { appId: "remembered-app-id", appDisplayName: "Contoso Docs App" }; + + it("asks (does not silently reuse) when an app is remembered and no displayName/appSelection is given", async () => { + readStateMock.mockReturnValue(REMEMBERED); + + const r = await createAppTool.handler({}); + + // Returns an elicitation, not a resolved app. + expect(r.isError).toBeFalsy(); + expect(r.content[0].text).toContain("Reuse"); + expect(r.content[0].text).toContain("different app"); + expect(r.content[0].text).toContain("appSelection=reuse"); + expect(r.content[0].text).toContain(REMEMBERED.appDisplayName); + // Nothing resolved/created; auth not repointed; state untouched. + expect(findApplicationByAppIdMock).not.toHaveBeenCalled(); + expect(findApplicationByNameMock).not.toHaveBeenCalled(); + expect(createApplicationMock).not.toHaveBeenCalled(); + expect(setAuthConfigMock).not.toHaveBeenCalled(); + expect(writeStateMock).not.toHaveBeenCalled(); + }); + + it("reuses the remembered app when appSelection='reuse'", async () => { + readStateMock.mockReturnValue(REMEMBERED); + findApplicationByAppIdMock.mockResolvedValue({ ...EXISTING_APP, appId: REMEMBERED.appId }); + + const r = await createAppTool.handler({ appSelection: "reuse" }); + + expect(r.content[0].text).toContain("Owning App Found"); + expect(findApplicationByAppIdMock).toHaveBeenCalledWith(REMEMBERED.appId, expect.anything()); + expect(findApplicationByNameMock).not.toHaveBeenCalled(); + }); + + it("does NOT reuse the remembered appId when appSelection='new' (resolves by name instead)", async () => { + readStateMock.mockReturnValue(REMEMBERED); + findApplicationByNameMock.mockResolvedValue(null); + createApplicationMock.mockResolvedValue({ appId: "fresh-app", objectId: "obj-fresh", displayName: "SPE Builder App" }); + + const r = await createAppTool.handler({ appSelection: "new" }); + + expect(r.isError).toBeUndefined(); + // Must not resume the remembered id; name-based resolution + create instead. + expect(findApplicationByAppIdMock).not.toHaveBeenCalled(); + expect(findApplicationByNameMock).toHaveBeenCalledWith("SPE Builder App", expect.anything()); + expect(createApplicationMock).toHaveBeenCalledTimes(1); + }); + + it("an explicit displayName still wins without prompting once the session is CONFIRMED", async () => { + // r-appgate: an explicit displayName no longer bypasses the always-ask on an + // UNconfirmed (freshly restarted) session — see the companion test below. + // Once the context is confirmed under the current session, the explicit-name + // fast path is restored (no friction mid-session). + readStateMock.mockReturnValue({ ...REMEMBERED, confirmedSessionId: getSessionId() }); + findApplicationByNameMock.mockResolvedValue({ appId: "named-app", objectId: "obj-named", displayName: "Other App" }); + + const r = await createAppTool.handler({ displayName: "Other App" }); + + expect(r.content[0].text).toContain("Owning App Found"); + expect(findApplicationByNameMock).toHaveBeenCalledWith("Other App", expect.anything()); + expect(findApplicationByAppIdMock).not.toHaveBeenCalled(); + }); + + it("PROMPTS even with an explicit displayName on a freshly restarted (unconfirmed) session", async () => { + // r-appgate (critical always-ask): a restart is a new process with a new + // session id, so a remembered app is unconfirmed and the new-vs-existing + // choice must fire — appSelection (not a name) is the answer, so displayName + // alone must NOT silently target an app. + readStateMock.mockReturnValue(REMEMBERED); + + const r = await createAppTool.handler({ displayName: "Other App" }); + + expect(r.isError).toBeFalsy(); + expect(r.content[0].text).toContain("appSelection=reuse"); + expect(findApplicationByNameMock).not.toHaveBeenCalled(); + expect(createApplicationMock).not.toHaveBeenCalled(); + }); + + it("does NOT prompt on a first run when nothing is remembered", async () => { + readStateMock.mockReturnValue({}); + findApplicationByNameMock.mockResolvedValue(null); + createApplicationMock.mockResolvedValue({ appId: "first-app", objectId: "obj-first", displayName: "SPE Builder App" }); + + const r = await createAppTool.handler({}); + + expect(r.content[0].text).toContain("Owning App Created"); + expect(createApplicationMock).toHaveBeenCalledTimes(1); + }); +}); + +describe("project_app_create — native elicitation continues in-band (PR #3 review)", () => { + const REMEMBERED = { appId: "remembered-app-id", appDisplayName: "Contoso Docs App" }; + + // These tests wire a fake capability-advertising server so elicitChoice takes + // the NATIVE path; reset after each so other suites keep the (unwired) fallback. + afterEach(() => { + resetElicitationForTests(); + }); + + it("CONTINUES with the user's native pick (reuse) instead of returning the choice", async () => { + readStateMock.mockReturnValue(REMEMBERED); + findApplicationByAppIdMock.mockResolvedValue({ ...EXISTING_APP, appId: REMEMBERED.appId }); + + const elicitInput = vi.fn( + async (): Promise => ({ action: "accept", content: { appSelection: "reuse" } }), + ); + wireElicitation({ elicitInput, getClientCapabilities: () => ({ elicitation: {} }) }); + + // No appSelection arg: on a capable client the user is asked natively and we + // proceed in-band with their answer — no re-invoke, no returned choice text. + const r = await createAppTool.handler({}); + + expect(elicitInput).toHaveBeenCalledTimes(1); + expect(r.content[0].text).toContain("Owning App Found"); + expect(findApplicationByAppIdMock).toHaveBeenCalledWith(REMEMBERED.appId, expect.anything()); + }); + + it("on native 'new' with no explicit name, elicits the new app name and uses it", async () => { + readStateMock.mockReturnValue(REMEMBERED); + findApplicationByNameMock.mockResolvedValue(null); + createApplicationMock.mockResolvedValue({ appId: "fresh-app", objectId: "obj-fresh", displayName: "My New App" }); + + // First elicit (appSelection) → "new"; second elicit (displayName) → a name. + const elicitInput = vi + .fn<(params: { requestedSchema: { properties: Record } }) => Promise>() + .mockResolvedValueOnce({ action: "accept", content: { appSelection: "new" } }) + .mockResolvedValueOnce({ action: "accept", content: { displayName: "My New App" } }); + wireElicitation({ elicitInput, getClientCapabilities: () => ({ elicitation: {} }) }); + + const r = await createAppTool.handler({}); + + expect(elicitInput).toHaveBeenCalledTimes(2); + expect(r.isError).toBeUndefined(); + // The elicited name (not the "SPE Builder App" default) drives resolution. + expect(findApplicationByNameMock).toHaveBeenCalledWith("My New App", expect.anything()); + expect(createApplicationMock).toHaveBeenCalledWith("My New App", expect.anything()); + }); +}); + +describe("project_app_create — NON-BLOCKING guest sign-in note (PR #3 review)", () => { + it("appends a guest heads-up (does NOT block) when signed in as a B2B guest", async () => { + getSignedInIdentityMock.mockResolvedValue({ + tenantId: "tenant-1", + username: "alice_corp.com#EXT#@resourcetenant.onmicrosoft.com", + }); + createApplicationMock.mockResolvedValue({ appId: "new-app", objectId: "obj-new", displayName: "SPE Builder App" }); + + const r = await createAppTool.handler({}); + + // Non-blocking: the app is still created and the tool succeeds. + expect(r.isError).toBeUndefined(); + expect(r.content[0].text).toContain("Owning App Created"); + expect(createApplicationMock).toHaveBeenCalledTimes(1); + // The informational note is present. + expect(r.content[0].text).toContain("guest (B2B)"); + expect(r.content[0].text).toContain("Heads-up"); + }); + + it("does NOT append the note for a member identity", async () => { + getSignedInIdentityMock.mockResolvedValue({ tenantId: "tenant-1", username: "dev@contoso.com" }); + createApplicationMock.mockResolvedValue({ appId: "new-app", objectId: "obj-new", displayName: "SPE Builder App" }); + + const r = await createAppTool.handler({}); + + expect(r.isError).toBeUndefined(); + expect(r.content[0].text).toContain("Owning App Created"); + expect(r.content[0].text).not.toContain("guest (B2B)"); + expect(r.content[0].text).not.toContain("Heads-up"); + }); +}); + +describe("project_app_create — admin-consent link (PR #3 review)", () => { + it("appends the tenant-wide admin-consent URL with the signed-in tenant id and NEW app's client id (create path)", async () => { + getSignedInIdentityMock.mockResolvedValue({ tenantId: "tenant-1", username: "dev@contoso.com" }); + createApplicationMock.mockResolvedValue({ appId: "new-app", objectId: "obj-new", displayName: "SPE Builder App" }); + + const r = await createAppTool.handler({}); + + expect(r.isError).toBeUndefined(); + const text = r.content[0].text; + // The exact copy-paste tenant-wide admin-consent URL: real tenant id + new app's client id. + expect(text).toContain( + "https://login.microsoftonline.com/tenant-1/adminconsent?client_id=new-app", + ); + expect(text).toContain("Grant admin consent"); + // Admin-vs-nonadmin explanation is present. + expect(text).toContain("Global Administrator"); + expect(text).toMatch(/NOT an admin/); + // Non-blocking / informational. + expect(text).toMatch(/not blocked on consent/i); + }); + + it("appends the admin-consent URL on the REUSE path too (consent may still be pending)", async () => { + getSignedInIdentityMock.mockResolvedValue({ tenantId: "tenant-1", username: "dev@contoso.com" }); + findApplicationByNameMock.mockResolvedValue(EXISTING_APP); + + const r = await createAppTool.handler({}); + + expect(r.content[0].text).toContain("Owning App Found"); + expect(r.content[0].text).toContain( + `https://login.microsoftonline.com/tenant-1/adminconsent?client_id=${EXISTING_APP.appId}`, + ); + }); + + it("never embeds a secret/token in the admin-consent URL (only tenant id + public client id)", async () => { + getSignedInIdentityMock.mockResolvedValue({ tenantId: "tenant-1", username: "dev@contoso.com" }); + createApplicationMock.mockResolvedValue({ appId: "new-app", objectId: "obj-new", displayName: "SPE Builder App" }); + + const r = await createAppTool.handler({}); + const text = r.content[0].text; + + // The URL's only query parameter is client_id — no secret/token smuggled in. + const match = text.match(/https:\/\/login\.microsoftonline\.com\/[^\s`]+/); + expect(match).not.toBeNull(); + const url = match![0]; + expect(url.split("?")[1]).toBe("client_id=new-app"); + expect(text).not.toContain("client_secret"); + }); +}); + +describe("create-app.ts source hygiene (PR #3 review)", () => { + it("no longer references the vague full-setup skill script (removed, no internal URL invented)", () => { + const src = readFileSync(new URL("./create-app.ts", import.meta.url), "utf8"); + expect(src).not.toContain("Ports the full-setup skill"); + expect(src).not.toContain("02-app.ps1"); + }); +}); diff --git a/src/tools/create-app.ts b/src/tools/create-app.ts new file mode 100644 index 0000000..8d6585d --- /dev/null +++ b/src/tools/create-app.ts @@ -0,0 +1,273 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tool: project_app_create + * + * Creates the owning Entra application for a SharePoint Embedded setup, using + * the Azure CLI **bootstrap token** (no Microsoft first-party app required). + * This is the first step of the two-token model: + * 1. az bootstrap token → create owning app + add SPE permissions (here) + * 2. owning-app token → all SPE container-type/container operations + * + * After creating the app, we point MSAL auth at the new app's client ID so the + * subsequent SPE tools acquire a delegated owning-app token (device code / + * browser) automatically. + * + * Idempotency model: the stable key is the **appId** (client ID), not the + * display name. Entra display names are NOT unique — multiple apps can share + * one — so once an owning app has been provisioned we remember its appId and + * resume by appId (findApplicationByAppId) on every subsequent run. A + * display-name lookup is only a best-effort convenience for the very first run + * (nothing remembered yet) or when the caller explicitly targets a named app; + * if several apps share that name it resolves the first match. Net effect: + * re-running is idempotent (no duplicate app is created) and, after the first + * run, precise because it keys on the unique appId. + */ + +import { bootstrapTokenProvider, getSignedInIdentity } from "../bootstrap.js"; +import { + addSpaRedirectUris, + addSpePermissions, + createApplication, + findApplicationByAppId, + findApplicationByName, +} from "../graph-client.js"; +import { LOCAL_SPA_REDIRECT_URI } from "../constants.js"; +import { setAuthConfig } from "../auth.js"; +import { guestSignInAdvisory } from "../guest-advisory.js"; +import { adminConsentSection } from "../onboarding-messages.js"; +import { clientSafeMessage } from "../errors.js"; +import { elicitChoice, elicitText } from "../elicitation.js"; +import { isContextConfirmedThisSession, stampContextConfirmed } from "../session.js"; +import { readState } from "../state.js"; +import type { McpTool, OwnerScope } from "../types.js"; + +export const createAppTool: McpTool = { + name: "project_app_create", + annotations: { plane: "control" }, + description: + "Create the owning Entra application for a SharePoint Embedded setup (a public-client app " + + "with the required SPE delegated permissions). Uses your signed-in Azure CLI session — no " + + "first-party app or pre-authorization needed. This is the first provisioning step; the " + + "container type and containers are created afterward as this app. Idempotent: re-running " + + "does not create a duplicate — once provisioned it resumes by the app's unique client ID " + + "(appId), not by display name (Entra display names are not unique).", + inputSchema: { + type: "object" as const, + properties: { + displayName: { + type: "string", + description: "Display name for the owning app (e.g., 'Contoso Docs App'). Default: 'SPE Builder App'.", + }, + appSelection: { + type: "string", + enum: ["reuse", "new"], + description: + "When a previously-used owning app is remembered, set 'reuse' to use it again or 'new' to " + + "create/target a different one. If omitted and an app is remembered, the tool asks first " + + "instead of silently reusing the last one.", + }, + ownerScope: { + type: "string", + enum: ["manage-all", "selected"], + description: + "Least-privilege intent for the owning app's SPE permissions (PR #3 review). " + + "'selected' (default) requests only the scopes needed to manage this app's own container " + + "type — the standard ISV/LOB scenario. 'manage-all' additionally requests the broad " + + "*.Manage.All scopes to administer ALL container types in the tenant (an admin/console app). " + + "Persisted and reused on later runs.", + }, + }, + }, + handler: async (args) => { + // An explicitly-provided displayName targets that specific named app; absent + // one we use the default (and prefer resuming the persisted appId below). + // `let` because a native "different app" prompt can supply a name in-band. + const explicitName = + typeof args.displayName === "string" && args.displayName.trim() !== "" + ? args.displayName + : undefined; + let displayName = explicitName ?? "SPE Builder App"; + // The user's explicit decision (relayed by the agent) about a remembered + // app: "reuse" the last one or use a "new"/different one. Undefined until asked. + let appSelection = + args.appSelection === "reuse" || args.appSelection === "new" ? args.appSelection : undefined; + + try { + const identity = await getSignedInIdentity(); + if (!identity) { + return { + content: [ + { + type: "text" as const, + text: "⛔ Not signed in to Azure CLI. Run `az login --allow-no-subscriptions`, then retry.", + }, + ], + isError: true, + }; + } + + const getToken = bootstrapTokenProvider; + + // Ask before silently reusing the last app (PM feedback: "it favors using + // the last one — it should ask"). Critical always-ask (r-appgate): the + // choice must fire not only when no intent was expressed, but ALSO on the + // first touch of a freshly restarted process — i.e., whenever an app is + // remembered, this call carries no appSelection, and the context has NOT + // been confirmed under the current session. + // + // Prefer NATIVE MCP elicitation (PR #3 review): on a capable client the + // user is prompted directly and we CONTINUE in-band with their pick — no + // re-invoke needed and no loop. On a client without elicitation, elicitChoice + // falls back to the agent-guided text ask (needChoice), which the agent + // re-invokes with `appSelection` (reuse/new) — identical to prior behavior. + // NOTE: an explicit `displayName` alone no longer bypasses the ask on an + // unconfirmed session — appSelection (not a name) is the answer to + // new-vs-existing; "different app" tells the caller to also pass displayName. + const persisted = readState(); + if (persisted.appId && !appSelection && !isContextConfirmedThisSession(persisted)) { + const choice = await elicitChoice( + `You previously used the owning app "${persisted.appDisplayName ?? persisted.appId}". Reuse it, or use a different app?`, + [ + { + label: `Reuse "${persisted.appDisplayName ?? persisted.appId}"`, + value: "reuse", + description: `the remembered app (client ID ${persisted.appId})`, + }, + { + label: "Use a different app", + value: "new", + description: "create or target another owning app — also pass displayName with its name", + }, + ], + "appSelection", + ); + if (!choice.resolved) return choice.result; + appSelection = choice.value as "reuse" | "new"; + // Native path resolved the ask in-band. For a "different app" with no + // explicit name, prompt for one too so the flow is complete instead of + // silently defaulting; on decline/no-capability we keep the default. + if (appSelection === "new" && !explicitName) { + const name = await elicitText("Name for the new owning app?", "displayName", { + title: "New app name", + }); + if (name.resolved) displayName = name.value; + } + } + + // Least-privilege intent (PR #3 review): resolve the owning app's SPE scope + // set from the explicit arg, else the persisted choice, else the + // least-privilege default "selected". Unlike appSelection, create-app does + // NOT block on an elicitation here — a silent least-privilege default never + // over-privileges, and callers who want tenant-wide admin pass + // ownerScope="manage-all" (project_provision does prompt for this). + const ownerScope: OwnerScope = + args.ownerScope === "manage-all" || args.ownerScope === "selected" + ? args.ownerScope + : persisted.ownerScope ?? "selected"; + + // Resolution order: + // - An EXPLICIT displayName targets that named app (created if missing), + // so a caller can address a specific app even when state holds another. + // NOTE: display names are NOT unique in Entra, so a name lookup resolves + // the first match; the unique key is the appId, which is why the reuse + // path below (and every run after the first) keys on the persisted appId. + // - "reuse" (or a first run with nothing remembered) resumes by the + // persisted appId (stable, unique identity); "new" forces name/default + // resolution instead of the remembered id. + // - Otherwise fall back to a default-name lookup. + const resumeByAppId = !explicitName && appSelection !== "new" && !!persisted.appId; + let app = explicitName + ? await findApplicationByName(explicitName, getToken) + : resumeByAppId + ? await findApplicationByAppId(persisted.appId as string, getToken) + : await findApplicationByName(displayName, getToken); + let reused = false; + if (app) { + reused = true; + // Attach/reuse path: adding permissions is best-effort and non-blocking. + await addSpePermissions(app.objectId, getToken, { bestEffort: true, ownerScope }); + // self-repair the SPA redirect URI on a pre-existing owning + // app. Apps created before have no `spa` platform, so the + // generated browser app's MSAL.js auth-code + PKCE sign-in fails with + // AADSTS9002326 — and the fresh-create path (createApplication) never + // runs for a reused app, so without this the app stays broken on re-run. + // Idempotent (addSpaRedirectUris no-ops when the origin is already + // present) and best-effort (a missing Application.ReadWrite grant must + // not fail app reuse — mirrors addSpePermissions above). Only on the + // reuse path: createApplication already sets `spa` at create time. + await addSpaRedirectUris(app.objectId, [LOCAL_SPA_REDIRECT_URI], getToken, { + bestEffort: true, + }); + } else { + app = await createApplication(displayName, getToken); + // Create path: permissions are required, so errors propagate. + await addSpePermissions(app.objectId, getToken, { ownerScope }); + } + + // Persist and point MSAL at the new owning app for subsequent SPE calls. + // stampContextConfirmed marks this session as confirmed (r-appgate) so the + // always-ask above does NOT re-fire on later calls in the same process; + // the next restart starts unconfirmed and asks again. + stampContextConfirmed({ + tenantId: identity.tenantId, + appId: app.appId, + appObjectId: app.objectId, + appDisplayName: app.displayName, + ownerScope, + // Both scope sets (manage-all AND selected) grant + // FileStorageContainerType.Manage.All, so a freshly CREATED owning app can + // always enumerate all container types → flag true. For a REUSED app we + // cannot be sure (the best-effort grant may not have taken, or it is an + // external app lacking the scope), so we leave the flag to the runtime + // listContainerTypes self-correction (403 → false) rather than assert it + // here from intent (PR #3 review). + ...(reused ? {} : { owningAppManagesAllContainerTypes: true }), + }); + setAuthConfig({ clientId: app.appId, tenantId: identity.tenantId }); + + // Reflect the granted least-privilege set (PR #3 review): "selected" apps + // request only their own container type's scopes; "manage-all" also gets the + // broad *.Manage.All administration scopes. + const permsSummary = + ownerScope === "manage-all" + ? "FileStorageContainer.Manage.All, FileStorageContainer.Selected, ContainerType.Manage.All, ContainerTypeReg.Manage.All, ContainerTypeReg.Selected" + : "FileStorageContainer.Selected, ContainerType.Manage.All, ContainerTypeReg.Selected"; + + const output = + `## Owning App ${reused ? "Found" : "Created"}\n\n` + + "| Property | Value |\n|----------|-------|\n" + + `| **Display name** | ${app.displayName} |\n` + + `| **Application (client) ID** | \`${app.appId}\` |\n` + + `| **Object ID** | \`${app.objectId}\` |\n` + + `| **Tenant** | \`${identity.tenantId}\` |\n` + + `| **Client type** | Public client (no secret) |\n` + + `| **Owner scope** | ${ownerScope} |\n` + + `| **SPE permissions** | ${permsSummary} |\n\n` + + "> The server is now **configured to sign in as this app** for SharePoint Embedded " + + "operations — **no restart needed**. The first SPE call opens a browser for a one-time " + + "consent (or unset `SPE_NON_INTERACTIVE`); after that, container types and containers can be created." + + // Copy-paste tenant-wide admin-consent link (PR #3 review). The app's SPE + // permissions still need an admin to consent: a Global Admin can grant + // tenant-wide with this link, and a non-admin can forward it to their + // admin. Appended on BOTH create and reuse (a reused app may have just had + // permissions (re)requested best-effort, so consent may still be pending). + // NON-BLOCKING/informational — no browser is opened and provisioning is + // never gated on consent; the URL carries only the tenant id + public + // client id (never a secret). + adminConsentSection(app.appId, identity.tenantId) + + // NON-BLOCKING heads-up appended for a B2B guest identity (guests often + // lack permission to create Entra apps / own container types). Empty for + // a member; never blocks the operation. (PR #3 review.) + guestSignInAdvisory(identity.username); + + return { content: [{ type: "text" as const, text: output }] }; + } catch (error) { + return { + content: [{ type: "text" as const, text: `Error creating owning app: ${clientSafeMessage(error)}` }], + isError: true, + }; + } + }, +}; diff --git a/src/tools/create-container-type.test.ts b/src/tools/create-container-type.test.ts new file mode 100644 index 0000000..732c238 --- /dev/null +++ b/src/tools/create-container-type.test.ts @@ -0,0 +1,355 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for container_type_create. + * + * Focus: standard-billing prerequisite validation — parity with + * project_provision. Graph client and state are mocked so these run offline. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../graph-client.js", () => ({ + createContainerType: vi.fn(), + listContainerTypes: vi.fn(), + registerContainerType: vi.fn(), + deleteContainerType: vi.fn(), +})); + +vi.mock("../azure-cli.js", async (importActual) => ({ + ...(await importActual()), + ensureSyntexProviderRegistered: vi.fn(), + // Guided standard-billing sub/RG selection (PR #3 review) lists these inline; + // default to empty and let each test set the shape it needs. + listSubscriptions: vi.fn(async () => []), + listResourceGroups: vi.fn(async () => []), +})); + +const stateStore: Record = {}; +vi.mock("../state.js", () => ({ + readState: vi.fn(() => ({ ...stateStore })), + writeState: vi.fn((patch: Record) => { + Object.assign(stateStore, patch); + return { ...stateStore }; + }), +})); + +import * as graph from "../graph-client.js"; +import * as azureCli from "../azure-cli.js"; +import { createContainerTypeTool } from "../tools/create-container-type.js"; +import { getSessionId } from "../session.js"; + +beforeEach(() => { + vi.clearAllMocks(); + for (const k of Object.keys(stateStore)) delete stateStore[k]; + // An owning app is present for all cases (validation is about billing args). + stateStore.appId = "app-1"; + // r-appgate: container_type_create is gated by the restart confirmation guard; + // seed a confirmed session so the gate no-ops and the billing-validation logic + // under test runs (gate behavior is covered in context-gate.test.ts). + stateStore.confirmedSessionId = getSessionId(); + vi.mocked(graph.listContainerTypes).mockResolvedValue([]); + // Reset the guided sub/RG listings to their empty defaults each test — + // vi.clearAllMocks() clears call history but NOT mockResolvedValue + // implementations, so without this a prior test's shape would leak forward. + vi.mocked(azureCli.listSubscriptions).mockResolvedValue([]); + vi.mocked(azureCli.listResourceGroups).mockResolvedValue([]); + // Standard-billing Azure prerequisite succeeds by default; rollback tests + // override this to reject. + vi.mocked(azureCli.ensureSyntexProviderRegistered).mockResolvedValue({ + namespace: "Microsoft.Syntex", + registrationState: "Registered", + } as never); +}); + +describe("container_type_create — standard billing validation", () => { + it("guides subscription selection inline (fallback) when standard billing lacks a subscription", async () => { + // PR #3 review: instead of punting to azure_subscriptions_list + + // azure_resource_groups_list and a manual re-invoke mid-creation, the tool + // lists the subscriptions itself and (with >1) asks the user to pick. No + // native elicitation is wired here, so elicitChoice degrades to the + // agent-guided ask keyed on `azureSubscriptionId`. + vi.mocked(azureCli.listSubscriptions).mockResolvedValue([ + { id: "sub-a", name: "Sub A", state: "Enabled" }, + { id: "sub-b", name: "Sub B", state: "Enabled" }, + ]); + + const result = await createContainerTypeTool.handler({ + displayName: "X", + billingClassification: "standard", + }); + + expect(azureCli.listSubscriptions).toHaveBeenCalled(); + expect(result.content[0].text).toContain("azureSubscriptionId=sub-a"); + expect(result.content[0].text).toContain("azureSubscriptionId=sub-b"); + // No misconfigured Graph request, no false "created" success, and NOT the old + // "run azure_subscriptions_list yourself" punt. + expect(result.content[0].text).not.toContain("azure_subscriptions_list"); + expect(graph.createContainerType).not.toHaveBeenCalled(); + expect(graph.registerContainerType).not.toHaveBeenCalled(); + expect(result.content[0].text).not.toContain("Container Type Created"); + }); + + it("guides resource-group selection inline (fallback) once a subscription is supplied", async () => { + // Subscription supplied → the tool lists resource groups WITHIN it and asks + // the user to pick (agent-guided fallback keyed on `resourceGroup`). + vi.mocked(azureCli.listResourceGroups).mockResolvedValue([ + { name: "rg-x", location: "eastus", id: "/subscriptions/sub-1/resourceGroups/rg-x" }, + { name: "rg-y", location: "westus", id: "/subscriptions/sub-1/resourceGroups/rg-y" }, + ]); + + const result = await createContainerTypeTool.handler({ + displayName: "X", + billingClassification: "standard", + azureSubscriptionId: "sub-1", + }); + + expect(azureCli.listResourceGroups).toHaveBeenCalledWith("sub-1"); + expect(result.content[0].text).toContain("resourceGroup=rg-x"); + expect(result.content[0].text).toContain("resourceGroup=rg-y"); + expect(graph.createContainerType).not.toHaveBeenCalled(); + }); + + it("errors clearly when standard billing has no Azure subscriptions (no crash)", async () => { + // Default mock returns zero subscriptions → a clear, non-crashing error that + // points at `az login`, and nothing is created. + const result = await createContainerTypeTool.handler({ + displayName: "X", + billingClassification: "standard", + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("az login"); + expect(graph.createContainerType).not.toHaveBeenCalled(); + expect(graph.registerContainerType).not.toHaveBeenCalled(); + }); + + it("auto-selects a lone subscription + resource group and proceeds (no prompt)", async () => { + // Exactly one of each → auto-selected without any elicitation, threaded into + // the Graph create, and surfaced as a note in the result body. + vi.mocked(azureCli.listSubscriptions).mockResolvedValue([ + { id: "only-sub", name: "Only Sub", state: "Enabled" }, + ]); + vi.mocked(azureCli.listResourceGroups).mockResolvedValue([ + { name: "only-rg", location: "eastus", id: "/subscriptions/only-sub/resourceGroups/only-rg" }, + ]); + vi.mocked(graph.createContainerType).mockResolvedValue({ + containerTypeId: "ct-1", owningAppId: "app-1", displayName: "X", + billingClassification: "standard", + }); + vi.mocked(graph.registerContainerType).mockResolvedValue(undefined as never); + + const result = await createContainerTypeTool.handler({ + displayName: "X", + billingClassification: "standard", + }); + + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain("Using the only Azure subscription"); + expect(result.content[0].text).toContain("Using the only resource group"); + expect(graph.createContainerType).toHaveBeenCalledWith( + expect.objectContaining({ + displayName: "X", + owningAppId: "app-1", + billingClassification: "standard", + azureSubscriptionId: "only-sub", + resourceGroup: "only-rg", + }), + ); + }); + + it("proceeds with standard billing when subscription + resource group are supplied", async () => { + vi.mocked(graph.createContainerType).mockResolvedValue({ + containerTypeId: "ct-1", owningAppId: "app-1", displayName: "X", + billingClassification: "standard", + }); + vi.mocked(graph.registerContainerType).mockResolvedValue(undefined as never); + + const result = await createContainerTypeTool.handler({ + displayName: "X", + billingClassification: "standard", + azureSubscriptionId: "sub-1", + resourceGroup: "rg-1", + }); + + expect(result.isError).toBeFalsy(); + expect(graph.createContainerType).toHaveBeenCalledWith( + expect.objectContaining({ + displayName: "X", + owningAppId: "app-1", + billingClassification: "standard", + azureSubscriptionId: "sub-1", + resourceGroup: "rg-1", + }), + ); + }); + + it("rejects an unsupported region before creating the (non-deletable) standard CT", async () => { + const result = await createContainerTypeTool.handler({ + displayName: "X", + billingClassification: "standard", + azureSubscriptionId: "sub-1", + resourceGroup: "rg-1", + region: "westus2", + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/not available for Microsoft\.Syntex/i); + // Guard fires BEFORE creation so no non-deletable standard CT is stranded. + expect(graph.createContainerType).not.toHaveBeenCalled(); + }); + + it("allows standard billing with region omitted (billing_setup can default it later)", async () => { + vi.mocked(graph.createContainerType).mockResolvedValue({ + containerTypeId: "ct-1", owningAppId: "app-1", displayName: "X", + billingClassification: "standard", + }); + vi.mocked(graph.registerContainerType).mockResolvedValue(undefined as never); + + const result = await createContainerTypeTool.handler({ + displayName: "X", + billingClassification: "standard", + azureSubscriptionId: "sub-1", + resourceGroup: "rg-1", + }); + + expect(result.isError).toBeFalsy(); + expect(graph.createContainerType).toHaveBeenCalled(); + }); + + it("leaves the trial path unchanged (no billing args required)", async () => { + vi.mocked(graph.createContainerType).mockResolvedValue({ + containerTypeId: "ct-1", owningAppId: "app-1", displayName: "X", + billingClassification: "trial", + }); + vi.mocked(graph.registerContainerType).mockResolvedValue(undefined as never); + + const result = await createContainerTypeTool.handler({ + displayName: "X", + billingClassification: "trial", + }); + + expect(result.isError).toBeFalsy(); + expect(graph.createContainerType).toHaveBeenCalledWith( + expect.objectContaining({ billingClassification: "trial" }), + ); + // Trial billing never runs the Azure Syntex prerequisite, so no rollback path. + expect(azureCli.ensureSyntexProviderRegistered).not.toHaveBeenCalled(); + expect(graph.deleteContainerType).not.toHaveBeenCalled(); + }); +}); + +describe("container_type_create — standard billing rollback", () => { + beforeEach(() => { + vi.mocked(graph.createContainerType).mockResolvedValue({ + containerTypeId: "ct-rollback", owningAppId: "app-1", displayName: "X", + billingClassification: "standard", + }); + vi.mocked(graph.registerContainerType).mockResolvedValue(undefined as never); + }); + + it("rolls back (deletes) the just-created container type when standard-billing setup fails", async () => { + vi.mocked(azureCli.ensureSyntexProviderRegistered).mockRejectedValue( + new Error("Microsoft.Syntex registration timed out"), + ); + + const result = await createContainerTypeTool.handler({ + displayName: "X", + billingClassification: "standard", + azureSubscriptionId: "sub-1", + resourceGroup: "rg-1", + }); + + // Transactional rollback: the orphan CT is deleted and a clear error is returned. + expect(graph.deleteContainerType).toHaveBeenCalledWith("ct-rollback"); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("rolled back"); + expect(result.content[0].text).toContain("Standard billing setup failed"); + // Registration must NOT proceed for a rolled-back container type. + expect(graph.registerContainerType).not.toHaveBeenCalled(); + }); + + it("warns when rollback ALSO fails so the orphan CT can be cleaned up manually", async () => { + vi.mocked(azureCli.ensureSyntexProviderRegistered).mockRejectedValue( + new Error("Microsoft.Syntex registration timed out"), + ); + vi.mocked(graph.deleteContainerType).mockRejectedValue(new Error("DELETE 500")); + + const result = await createContainerTypeTool.handler({ + displayName: "X", + billingClassification: "standard", + azureSubscriptionId: "sub-1", + resourceGroup: "rg-1", + }); + + expect(graph.deleteContainerType).toHaveBeenCalledWith("ct-rollback"); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("rollback ALSO failed"); + expect(result.content[0].text).toContain("ct-rollback"); + }); + + it("does NOT roll back when standard-billing setup succeeds", async () => { + const result = await createContainerTypeTool.handler({ + displayName: "X", + billingClassification: "standard", + azureSubscriptionId: "sub-1", + resourceGroup: "rg-1", + }); + + expect(result.isError).toBeFalsy(); + expect(azureCli.ensureSyntexProviderRegistered).toHaveBeenCalledWith("sub-1"); + expect(graph.deleteContainerType).not.toHaveBeenCalled(); + expect(graph.registerContainerType).toHaveBeenCalled(); + }); +}); + +describe("container_type_create — enum/displayName validation", () => { + it("rejects an out-of-enum billingClassification with NO Graph call", async () => { + const result = await createContainerTypeTool.handler({ + displayName: "X", + billingClassification: "free", + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Invalid billingClassification 'free'"); + expect(result.content[0].text).toContain("trial, standard, directToCustomer"); + // Validation happens before any Graph call (including the existence probe). + expect(graph.listContainerTypes).not.toHaveBeenCalled(); + expect(graph.createContainerType).not.toHaveBeenCalled(); + expect(result.content[0].text).not.toContain("Container Type Created"); + }); + + it.each([123, "", " ", {}, null, true])( + "rejects a non-string / empty displayName (%p) with NO Graph call", + async (displayName) => { + const result = await createContainerTypeTool.handler({ + displayName, + } as Record); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain( + "displayName is required and must be a non-empty string", + ); + expect(graph.createContainerType).not.toHaveBeenCalled(); + }, + ); + + it("accepts the directToCustomer billing model", async () => { + vi.mocked(graph.createContainerType).mockResolvedValue({ + containerTypeId: "ct-1", owningAppId: "app-1", displayName: "X", + billingClassification: "directToCustomer", + }); + vi.mocked(graph.registerContainerType).mockResolvedValue(undefined as never); + + const result = await createContainerTypeTool.handler({ + displayName: "X", + billingClassification: "directToCustomer", + }); + + expect(result.isError).toBeFalsy(); + expect(graph.createContainerType).toHaveBeenCalledWith( + expect.objectContaining({ billingClassification: "directToCustomer" }), + ); + }); +}); diff --git a/src/tools/create-container-type.ts b/src/tools/create-container-type.ts new file mode 100644 index 0000000..d0ebf69 --- /dev/null +++ b/src/tools/create-container-type.ts @@ -0,0 +1,342 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tool: container_type_create + * + * Creates a new SharePoint Embedded container type via Microsoft Graph. + * + * Key gotchas (from live testing — see Skills/full-setup/gotchas.md): + * - Use `displayName` field (Graph API), not `name` + * - Response field is `containerTypeId`, not `id` + * - One container type per owning app (1:1 relationship) + * - Trial billing: max 3 per tenant, expires after 30 days + * - After creation, must register the CT before creating containers + */ + +import { ensureSyntexProviderRegistered, isSyntexRegionSupported } from "../azure-cli.js"; +// These are Microsoft Graph API wrappers from graph-client (not other tools' +// handlers). All four are used by this one tool: listContainerTypes enforces the +// 1:1 owning-app→CT check, createContainerType creates it, deleteContainerType +// rolls back on a failed standard-billing setup, and registerContainerType +// performs the default auto-registration. +import { + createContainerType, + deleteContainerType, + listContainerTypes, + registerContainerType, +} from "../graph-client.js"; +import { readState, writeState } from "../state.js"; +import { defineTool, z } from "../tooling/define-tool.js"; +import { resolveContextGate } from "./context-gate.js"; +import { resolveStandardBillingTarget } from "./standard-billing-target.js"; +import type { BillingClassification } from "../types.js"; +import { fail, ok } from "../responses.js"; +import { clientSafeMessage } from "../errors.js"; + +// Local-only argument shape for this tool's handler. Per repo convention, +// shared Graph/MCP domain types live centrally in `src/types.ts`, while per-tool +// argument interfaces are kept local to their tool file (see deploy-azure, +// provision, register-container-type, etc.). This one is not reused elsewhere, +// so it stays local rather than being centralized. +interface CreateContainerTypeArgs { + displayName: string; + owningAppId?: string; + billingClassification?: BillingClassification; + azureSubscriptionId?: string; + resourceGroup?: string; + region?: string; + autoRegister?: boolean; +} + +/** + * Allowed billing models — must mirror the tool inputSchema enum. Zod needs the + * runtime array, so it stays; `satisfies readonly BillingClassification[]` pins + * it to the single {@link BillingClassification} union so the array and the type + * can't drift apart. + */ +const BILLING_CLASSIFICATIONS = [ + "trial", + "standard", + "directToCustomer", +] as const satisfies readonly BillingClassification[]; + +async function executeCreateContainerType(args: CreateContainerTypeArgs) { + const { + displayName, + owningAppId = readState().appId, + billingClassification = "trial", + azureSubscriptionId, + resourceGroup, + region, + autoRegister = true, + } = args; + + // Validate inputs BEFORE any Graph call so wrong-typed / out-of-enum values + // never reach the Graph API. MCP clients can send arbitrary JSON, + // so the declared inputSchema is not enforced at the transport boundary. + if (typeof displayName !== "string" || displayName.trim() === "") { + return { success: false, error: "displayName is required and must be a non-empty string" }; + } + if (!(BILLING_CLASSIFICATIONS as readonly string[]).includes(billingClassification)) { + return { + success: false, + error: + `Invalid billingClassification '${String(billingClassification)}'. ` + + `Must be one of: ${BILLING_CLASSIFICATIONS.join(", ")}.`, + }; + } + if (!owningAppId) { + return { success: false, error: "owningAppId (Application/Client ID) is required — run project_app_create first or pass it explicitly" }; + } + + // Standard billing requires an Azure subscription + resource group. The tool's + // handler runs GUIDED inline selection first (see resolveStandardBillingTarget), + // so by the time we get here both are normally present. This stays as a + // last-line defensive check (parity with project_provision) so a direct call — + // or a resolution that somehow yields blanks — never emits a misconfigured Graph + // request that omits the subscription and then falsely reports success. Region + // may default, so it is not required here. + if (billingClassification === "standard" && (!azureSubscriptionId || !resourceGroup)) { + return { + success: false, + error: + "Standard billing needs an Azure subscription and resource group.\n\n" + + "1. Run **azure_subscriptions_list** and pick one → pass `azureSubscriptionId`.\n" + + "2. Run **azure_resource_groups_list** for that subscription and pick one → pass `resourceGroup`.\n" + + "3. Re-run **container_type_create** with `billingClassification=standard`, `azureSubscriptionId`, and `resourceGroup`.\n\n" + + "(For a free container type, use `billingClassification=trial` instead.)", + }; + } + + // Pre-flight: if a region was specified for standard billing, validate it + // BEFORE creating the container type. A standard CT cannot be deleted (Graph + // 422 "Cannot delete container type for non trial"), so catching a bad region + // only later (at billing-account creation) would strand an orphan CT. Region + // is optional here (billing_setup can default it), so only check when given. + // (per PR #3 review — provisioning safety) + if (billingClassification === "standard" && region && !isSyntexRegionSupported(region)) { + return { + success: false, + error: + `Azure region '${region}' is not available for Microsoft.Syntex/accounts (SharePoint Embedded ` + + "standard billing). Choose a supported region (e.g. eastus, westus, westeurope, uksouth) and " + + "re-run **container_type_create**, or omit `region` to use the default.", + }; + } + + // Check for existing container type with the same owning app + // (SPE enforces 1:1 relationship between owning app and container type) + const existing = await listContainerTypes(); + const existingCt = existing.find( + (ct) => ct.owningAppId?.toLowerCase() === owningAppId.toLowerCase(), + ); + + if (existingCt) { + writeState({ containerTypeId: existingCt.containerTypeId, containerTypeName: existingCt.displayName }); + return { + success: true, + alreadyExisted: true, + containerType: existingCt, + message: `Container type already exists for app ${owningAppId}. SPE enforces a 1:1 relationship between owning app and container type.`, + }; + } + + // Create the container type + const ct = await createContainerType({ + displayName, + owningAppId, + billingClassification, + azureSubscriptionId, + resourceGroup, + region, + }); + + // Standard billing requires an Azure-side prerequisite AFTER the CT exists: + // the Microsoft.Syntex resource provider must be registered on the chosen + // subscription. If that setup fails, the just-created container type would be + // orphaned. Roll it back (transactional delete) so a failed standard-billing + // setup never leaks an unusable CT. The CT is only persisted to + // state after this step succeeds. + if (billingClassification === "standard" && ct.containerTypeId) { + try { + await ensureSyntexProviderRegistered(azureSubscriptionId as string); + } catch (billingError) { + const billingMsg = billingError instanceof Error ? billingError.message : String(billingError); + let rolledBack = true; + let rollbackNote = ""; + try { + await deleteContainerType(ct.containerTypeId); + } catch (rollbackError) { + rolledBack = false; + rollbackNote = rollbackError instanceof Error ? rollbackError.message : String(rollbackError); + } + return { + success: false, + error: + `Standard billing setup failed after the container type was created: ${billingMsg}. ` + + (rolledBack + ? "The container type was rolled back (deleted) so no orphan remains — fix the billing prerequisite and re-run container_type_create." + : `WARNING: rollback ALSO failed — container type \`${ct.containerTypeId}\` may still exist and should be deleted manually (${rollbackNote}).`), + }; + } + } + + writeState({ + containerTypeId: ct.containerTypeId, + containerTypeName: displayName, + billingClassification, + }); + + let registrationDone = false; + + // Auto-register the container type with full permissions for the owning app + if (autoRegister && ct.containerTypeId) { + try { + await registerContainerType(ct.containerTypeId, owningAppId); + registrationDone = true; + } catch (error) { + // Registration may fail if propagation hasn't completed yet. + // This is expected — the user can retry later. + const msg = error instanceof Error ? error.message : String(error); + return { + success: true, + containerType: ct, + registrationDone: false, + registrationError: msg, + message: `Container type created but registration failed (propagation delay). Retry with container_type_register after ~15 seconds.`, + }; + } + } + + return { + success: true, + alreadyExisted: false, + containerType: ct, + registrationDone, + message: registrationDone + ? "Container type created and registered successfully." + : "Container type created. Registration was skipped (autoRegister=false).", + }; +} + +function formatResult(result: Awaited>): string { + if (!result.success) { + return `Error: ${result.error}`; + } + + const ct = result.containerType; + let output = result.alreadyExisted + ? "## Existing Container Type Found\n\n" + : "## Container Type Created\n\n"; + + output += `| Property | Value |\n`; + output += `|----------|-------|\n`; + output += `| **Container Type ID** | \`${ct?.containerTypeId ?? "N/A"}\` |\n`; + output += `| **Display Name** | ${ct?.displayName ?? "N/A"} |\n`; + output += `| **Owning App ID** | \`${ct?.owningAppId ?? "N/A"}\` |\n`; + output += `| **Billing** | ${ct?.billingClassification ?? "N/A"} |\n`; + output += `| **Registration** | ${result.registrationDone ? "✅ Done" : result.alreadyExisted ? "—" : "⏳ Pending"} |\n`; + + if (result.message) { + output += `\n> ${result.message}\n`; + } + if (result.registrationError) { + output += `\n> ⚠️ Registration error: ${result.registrationError}\n`; + } + + return output; +} + +const createContainerTypeSchema = z.object({ + displayName: z.string().trim().min(1).describe("Display name for the container type (e.g., 'Contoso Legal Documents')"), + owningAppId: z.string().optional().describe( + "Application (Client) ID of the Entra ID app that will own this container type. " + + "Defaults to the app created by project_app_create when omitted.", + ), + billingClassification: z.enum(BILLING_CLASSIFICATIONS).optional().describe( + "Billing model: 'trial' (free, 30 days, max 3), 'standard' (billed to owning tenant), 'directToCustomer' (billed to consuming tenant). Default: trial", + ), + azureSubscriptionId: z.string().optional().describe("Azure subscription ID for standard billing. Required when billingClassification is 'standard'."), + resourceGroup: z.string().optional().describe("Azure resource group for standard billing."), + region: z.string().optional().describe("Azure region for standard billing."), + autoRegister: z.boolean().optional().describe( + "Automatically register the container type with full permissions for the owning app. Default: true. " + + "Registration is REQUIRED before any containers can be created.", + ), + contextChoice: z.enum(["confirm", "switch"]).optional().describe( + "On a freshly restarted session, confirm the remembered owning app / container type ('confirm') " + + "or switch to a different one ('switch'). Supplied in response to the confirmation prompt; omit on the first call.", + ), +}); + +function createContainerTypeValidationMessage(error: z.ZodError): string { + const issue = error.issues[0]; + if (issue?.path[0] === "displayName") { + return "displayName is required and must be a non-empty string"; + } + if (issue?.path[0] === "billingClassification") { + const received = "received" in issue ? String(issue.received) : "unknown"; + return `Invalid billingClassification '${received}'. Must be one of: ${BILLING_CLASSIFICATIONS.join(", ")}.`; + } + return issue?.message ?? "Invalid container type arguments"; +} + +export const createContainerTypeTool = defineTool({ + name: "container_type_create", + description: + "Create a new SharePoint Embedded container type. A container type defines the relationship between your application and a set of containers. " + + "Each owning application can have exactly one container type (1:1 relationship). " + + "By default, the container type is automatically registered with full permissions for the owning app. " + + "Trial container types are limited to 3 per tenant and expire after 30 days.", + annotations: { + destructive: true, + idempotent: true, + plane: "control", + }, + schema: createContainerTypeSchema, + validationErrorMessage: createContainerTypeValidationMessage, + handler: async (args) => { + try { + // Restart confirmation gate (r-appgate): confirm the remembered owning app / + // container type before creating a new one on a fresh session. Inside the + // try so a stamp-write failure on `contextChoice=confirm` (writeState / + // writeSecureFile) is classified by this tool's own error handling below, + // like its other errors, rather than the generic dispatch catch. (PR #3 review.) + const gate = await resolveContextGate(args.contextChoice); + if (gate) return gate; + + // Guided standard-billing sub/RG selection (PR #3 review): when the caller + // chose standard billing but is missing a subscription and/or resource group, + // run the Azure listings INLINE and prompt for a pick (native elicitation, or + // the agent-guided fallback) so the user doesn't have to break out to + // azure_subscriptions_list / azure_resource_groups_list mid-creation and + // re-invoke. Singletons auto-select. The resolved values thread through the + // existing region check + rollback in executeCreateContainerType unchanged. + let effectiveArgs = args; + let guidedNotes: string[] = []; + if (args.billingClassification === "standard" && (!args.azureSubscriptionId || !args.resourceGroup)) { + const target = await resolveStandardBillingTarget({ + azureSubscriptionId: args.azureSubscriptionId, + resourceGroup: args.resourceGroup, + }); + if (!target.resolved) return target.result; + effectiveArgs = { ...args, azureSubscriptionId: target.azureSubscriptionId, resourceGroup: target.resourceGroup }; + guidedNotes = target.notes; + } + + const result = await executeCreateContainerType(effectiveArgs); + if (!result.success) { + return fail("INVALID_ARGS", result.error ?? "Container type creation failed"); + } + // Surface any guided-selection notes (e.g. an auto-selected singleton) above + // the standard result so the user sees which subscription/RG was used. + const body = guidedNotes.length + ? `${guidedNotes.map((n) => `> ${n}`).join("\n")}\n\n${formatResult(result)}` + : formatResult(result); + return ok(result, body); + } catch (error) { + return fail("UPSTREAM", `creating container type: ${clientSafeMessage(error)}`); + } + }, +}); diff --git a/src/tools/create-container.test.ts b/src/tools/create-container.test.ts new file mode 100644 index 0000000..54538d2 --- /dev/null +++ b/src/tools/create-container.test.ts @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for the container_create tool's registration precheck (WI-08 part + * b, reviewer r3531125394). + * + * Standalone container_create must GET the container-type registration BEFORE + * entering the ~150s propagation backoff loop: + * • registration ABSENT (404) → fail fast, never call createContainer; + * • registration PRESENT → proceed into the create loop; + * • precheck INCONCLUSIVE (5xx/429) → do not block a valid create; proceed. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../graph-client.js", () => ({ + getContainerTypeRegistration: vi.fn(), + createContainer: vi.fn(), + activateContainer: vi.fn(), +})); +// Default the containerTypeId from mocked state so the test never reads the +// developer's real ~/.spe-mcp/state.json. +vi.mock("../state.js", () => ({ + readState: vi.fn(() => ({ containerTypeId: "ct-registered" })), + writeState: vi.fn(), +})); + +import * as graph from "../graph-client.js"; +import { AppError } from "../errors.js"; +import { createContainerTool } from "./create-container.js"; + +const activeContainer = { + id: "c-1", + displayName: "My First Container", + containerTypeId: "ct-registered", + status: "active", +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("container_create — registration precheck (WI-08)", () => { + it("fails fast without any backoff when the container type is NOT registered (404)", async () => { + vi.mocked(graph.getContainerTypeRegistration).mockRejectedValue( + new AppError("NOT_FOUND", "Resource not found", { status: 404 }), + ); + + const result = await createContainerTool.handler({ containerTypeId: "ct-missing" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/not registered/i); + // Never entered the create/backoff loop → createContainer must not run. + expect(graph.createContainer).not.toHaveBeenCalled(); + }); + + it("proceeds into the create loop when the registration is present", async () => { + vi.mocked(graph.getContainerTypeRegistration).mockResolvedValue( + { containerTypeId: "ct-registered" } as never, + ); + vi.mocked(graph.createContainer).mockResolvedValue(activeContainer); + + const result = await createContainerTool.handler({ containerTypeId: "ct-registered" }); + + expect(result.isError).toBeUndefined(); + expect(graph.createContainer).toHaveBeenCalledWith("ct-registered", "My First Container"); + }); + + it("treats an inconclusive precheck (transient 5xx) as unknown and still creates", async () => { + vi.mocked(graph.getContainerTypeRegistration).mockRejectedValue( + new AppError("UPSTREAM", "Service unavailable", { status: 503 }), + ); + vi.mocked(graph.createContainer).mockResolvedValue(activeContainer); + + const result = await createContainerTool.handler({ containerTypeId: "ct-registered" }); + + // A flaky read must not block a valid create. + expect(result.isError).toBeUndefined(); + expect(graph.createContainer).toHaveBeenCalledWith("ct-registered", "My First Container"); + }); +}); diff --git a/src/tools/create-container.ts b/src/tools/create-container.ts new file mode 100644 index 0000000..971d760 --- /dev/null +++ b/src/tools/create-container.ts @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tool: container_create + * + * Creates a container in a registered container type and activates it. + * Handles the registration-propagation delay (10–30s) with retry/backoff, and + * activates the container since new containers start inactive (full-setup skill + * 05.1, gotchas #5/#9). + */ + +import { activateContainer, createContainer, getContainerTypeRegistration } from "../graph-client.js"; +import { + CONTAINER_CREATE_MAX_ATTEMPTS, + containerCreateBackoffMs, + isContainerPropagationError, + toClassifiableError, +} from "../container-retry.js"; +import { readState, writeState } from "../state.js"; +import type { Container } from "../types.js"; +import { defineTool, z } from "../tooling/define-tool.js"; +import { fail, ok } from "../responses.js"; +import { clientSafeMessage } from "../errors.js"; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +const createContainerSchema = z.object({ + displayName: z.string().optional().describe("Display name for the container (e.g., 'Project Files'). Default: 'My First Container'."), + containerTypeId: z.string().optional().describe("Container type ID. Defaults to the most recently created/registered one."), +}); + +export const createContainerTool = defineTool({ + name: "container_create", + description: + "Create a container in a registered SharePoint Embedded container type and activate it. " + + "Retries through the registration propagation delay automatically. Defaults the container " + + "type ID from the current provisioning state when omitted.", + annotations: { + idempotent: false, + plane: "control", + }, + schema: createContainerSchema, + handler: async (args) => { + const state = readState(); + const { displayName = "My First Container", containerTypeId = state.containerTypeId } = + args; + + if (!containerTypeId) { + return fail("INVALID_ARGS", "containerTypeId is required (none in state). Create and register a container type first."); + } + + // Positive registration precheck (standalone container_create only; the + // provisioning flow registers immediately before creating, so it does not + // need this). A GET on the registration RECORD is created synchronously by + // container_type_register, so a 404 here means the container type is + // genuinely NOT registered on this tenant — fail fast with an actionable + // message instead of burning ~150s of propagation backoff. A transient / + // 5xx / 429 / network error on the pre-check itself is INCONCLUSIVE: do not + // block a valid create on a flaky read — fall through to the retry loop. + // (The slow ~10–30s grant propagation surfaces LATER as a phrase-bearing + // 403 on createContainer, which the loop retries — not as a 404 here.) + try { + await getContainerTypeRegistration(containerTypeId); + } catch (error) { + if (toClassifiableError(error).status === 404) { + return fail( + "FAILED_PRECONDITION", + `container type \`${containerTypeId}\` is not registered on this tenant. ` + + "Register it first with container_type_register (or run project_provision), then retry.", + ); + } + // Non-404 (transient/permission/network) → inconclusive; proceed to loop. + } + + let container: Container | undefined; + let lastSafeError = ""; + for (let attempt = 1; attempt <= CONTAINER_CREATE_MAX_ATTEMPTS; attempt++) { + try { + container = await createContainer(containerTypeId, displayName); + break; + } catch (error) { + // Surface only the sanitized message to the client (SEC-002); classify + // on the error object so retry decisions use the HTTP status, not a + // substring of the (localizable) message. + lastSafeError = clientSafeMessage(error); + // Only retry genuine registration-propagation delays. Permanent errors + // (invalid/unregistered container type → 404, unauthorized → 403) fail + // fast instead of hanging through ~150s of backoff. + if ( + attempt < CONTAINER_CREATE_MAX_ATTEMPTS && + isContainerPropagationError(toClassifiableError(error)) + ) { + await sleep(containerCreateBackoffMs(attempt)); // 15s, 30s, 45s, 60s + continue; + } + return fail("UPSTREAM", `creating container after ${attempt} attempt(s): ${lastSafeError}`); + } + } + + if (!container) { + return fail("UPSTREAM", `container creation failed. ${lastSafeError}`); + } + + // Activate if needed (new containers start inactive). + let activated = container.status === "active"; + if (!activated) { + try { + await activateContainer(container.id); + activated = true; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (/already active|activated/i.test(msg)) { + activated = true; + } + } + } + + writeState({ containerId: container.id, containerName: displayName }); + + const output = + "## Container Created\n\n" + + "| Property | Value |\n|----------|-------|\n" + + `| **Container ID** | \`${container.id}\` |\n` + + `| **Name** | ${displayName} |\n` + + `| **Container Type** | \`${containerTypeId}\` |\n` + + `| **Status** | ${activated ? "✅ active" : "⏳ activating"} |\n`; + + return ok({ container, containerTypeId, displayName, activated }, output); + }, +}); diff --git a/src/tools/create-folder.ts b/src/tools/create-folder.ts new file mode 100644 index 0000000..101e657 --- /dev/null +++ b/src/tools/create-folder.ts @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tool: content_folder_create + * + * Create a folder (or nested folder path) inside a container. + * + * Argument validation is declared once as a Zod schema (see `defineTool` and the + * shared field builders in `../tooling/fields.ts`). The `folderPath` builder + * normalizes the path — trimming, splitting on `/`, and dropping empty segments — + * so pathological inputs like `"/"`, `"///"`, or `"a//b"` can never reach Graph + * as a blank folder name; a path that normalizes to zero segments is rejected as + * a clean validation error instead of a Graph 400. + */ + +import { createFolder, getContainerDrive, listDriveChildren } from "../graph-client.js"; +import { defineTool } from "../tooling/define-tool.js"; +import { nonEmptyString, folderPath, folderSegments, z } from "../tooling/fields.js"; +import { requireContentAccess } from "./content-access.js"; +import { ok } from "../responses.js"; + +const schema = z.object({ + containerId: nonEmptyString("containerId", "The container ID."), + folderPath: folderPath("folderPath", { + required: true, + description: "Folder path to create (e.g., 'Documents/Reports/Q1').", + }), +}); + +export const createFolderTool = defineTool({ + name: "content_folder_create", + annotations: { plane: "content", requiresConsent: true }, + description: + "Create a folder or nested folder path inside a SharePoint Embedded container. " + + "Intermediate folders are created automatically. Already-existing folders are skipped.", + schema, + handler: async (args) => { + const gate = requireContentAccess(); + if (gate) return gate; + + const { containerId } = args; + // `folderPath` arrives normalized (non-empty segments joined by `/`), and the + // required refinement guarantees at least one segment — so `segments` is never + // empty and `createFolder` is never called with a blank name. + const segments = folderSegments(args.folderPath); + + const drive = await getContainerDrive(containerId); + let currentParent = "root"; + const results: Array<{ name: string; id: string; status: string }> = []; + + for (const segment of segments) { + try { + const folder = await createFolder(drive.id, currentParent, segment); + currentParent = folder.id; + results.push({ name: segment, id: folder.id, status: "created" }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (msg.includes("nameAlreadyExists") || msg.includes("already exists")) { + // Folder exists — find it and navigate into it + const children = await listDriveChildren(drive.id, currentParent === "root" ? undefined : currentParent); + const existing = children.find(c => c.name === segment && c.folder); + if (existing) { + currentParent = existing.id; + results.push({ name: segment, id: existing.id, status: "exists" }); + } else { + throw new Error(`Folder '${segment}' conflict but could not find it`); + } + } else { + throw error; + } + } + } + + let output = `## Folders Created\n\n`; + output += `| Folder | ID | Status |\n|--------|----|---------|\n`; + for (const r of results) { + output += `| ${r.name} | \`${r.id}\` | ${r.status} |\n`; + } + output += `\n**Leaf folder ID:** \`${currentParent}\``; + + return ok({ folders: results, leafFolderId: currentParent }, output); + }, +}); + diff --git a/src/tools/delete-container.ts b/src/tools/delete-container.ts new file mode 100644 index 0000000..b4990f0 --- /dev/null +++ b/src/tools/delete-container.ts @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tool: container_delete + * + * Soft-delete, permanently delete, or restore a container. + */ + +import { + deleteContainer, + getContainer, + permanentDeleteContainer, + restoreDeletedContainer, +} from "../graph-client.js"; +import { fail } from "../responses.js"; +import type { McpTool } from "../types.js"; + +export const deleteContainerTool: McpTool = { + name: "container_delete", + description: + "Delete or restore a SharePoint Embedded container. " + + "Use this when you need to remove a container or recover one from the recycle bin. " + + "soft-delete: moves to 93-day recycle bin. " + + "permanent-delete: irreversible removal — requires confirm=true. " + + "restore: recovers a soft-deleted container from the recycle bin. " + + "DESTRUCTIVE: permanent-delete cannot be undone.", + annotations: { destructive: true, plane: "control" }, + inputSchema: { + type: "object" as const, + properties: { + containerId: { + type: "string", + description: "The container ID.", + }, + action: { + type: "string", + enum: ["soft-delete", "permanent-delete", "restore"], + description: "The delete action. 'permanent-delete' is IRREVERSIBLE.", + }, + confirm: { + type: "boolean", + description: + "Required for 'permanent-delete'. Set true to confirm irreversible permanent deletion.", + }, + }, + required: ["containerId", "action"], + }, + handler: async (args) => { + const containerId = args.containerId as string; + const action = args.action as string; + + if (!containerId || !action) { + return { + content: [{ type: "text", text: "Error: containerId and action are required" }], + isError: true, + }; + } + + switch (action) { + case "soft-delete": { + let name = containerId; + try { + const c = await getContainer(containerId); + name = c.displayName; + } catch { /* container may already be deleted */ } + + await deleteContainer(containerId); + return { + content: [{ + type: "text", + text: `Container "${name}" soft-deleted (93-day recycle bin). Use action 'restore' to recover.`, + }], + }; + } + + case "permanent-delete": { + // SAFE-002: never permanently delete without explicit confirmation. + if (args.confirm !== true) { + return fail( + "CONFIRMATION_REQUIRED", + `Permanent deletion of container ${containerId} is IRREVERSIBLE and was not confirmed.`, + "Re-run container_delete with action='permanent-delete' and confirm=true to proceed.", + ); + } + await permanentDeleteContainer(containerId); + return { + content: [{ + type: "text", + text: `Container ${containerId} permanently deleted. This action is IRREVERSIBLE.`, + }], + }; + } + + case "restore": { + await restoreDeletedContainer(containerId); + return { + content: [{ + type: "text", + text: `Container ${containerId} restored from recycle bin.`, + }], + }; + } + + default: + return { + content: [{ type: "text", text: `Unknown action: ${action}` }], + isError: true, + }; + } + }, +}; diff --git a/src/tools/deploy-azure.test.ts b/src/tools/deploy-azure.test.ts new file mode 100644 index 0000000..9dd4d45 --- /dev/null +++ b/src/tools/deploy-azure.test.ts @@ -0,0 +1,199 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for project_deploy (deploy-azure.ts). + * + * The C# arch deploys the ODSP security-approved azd template, which is + * subscription-scoped and reads env name / location / subscription / SPE + * container type from azd environment variables. These assert the tool: + * - errors clearly when there is no azure.yaml or no region, + * - drives `azd up --no-prompt` with those values wired from state, + * - extracts the live endpoint and reports the managed-identity infra, + * - surfaces a friendly message when `azd` is not installed. + * + * node:child_process, node:fs and state are mocked so nothing actually runs. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +let azureYamlExists = true; +type ExecCb = (err: (Error & { code?: string }) | null, stdout: string, stderr: string) => void; +let execImpl: (cmd: string, args: string[], opts: { env?: NodeJS.ProcessEnv }, cb: ExecCb) => void; + +vi.mock("node:fs", () => ({ + existsSync: vi.fn((p: string) => (String(p).endsWith("azure.yaml") ? azureYamlExists : true)), +})); + +vi.mock("node:child_process", () => ({ + execFile: vi.fn((cmd: string, args: string[], opts: { env?: NodeJS.ProcessEnv }, cb: ExecCb) => + execImpl(cmd, args, opts, cb), + ), +})); + +const stateStore: Record = {}; +vi.mock("../state.js", () => ({ + readState: vi.fn(() => ({ ...stateStore })), +})); + +const addSpaRedirectUrisMock = vi.fn(); +vi.mock("../graph-client.js", () => ({ + addSpaRedirectUris: (...args: unknown[]) => addSpaRedirectUrisMock(...args), +})); +vi.mock("../bootstrap.js", () => ({ + bootstrapTokenProvider: vi.fn(async () => "boot-token"), +})); + +import { execFile } from "node:child_process"; +import { deployAzureTool } from "../tools/deploy-azure.js"; + +beforeEach(() => { + vi.clearAllMocks(); + azureYamlExists = true; + for (const k of Object.keys(stateStore)) delete stateStore[k]; + // Default: a successful azd up that prints a live endpoint. + execImpl = (_cmd, _args, _opts, cb) => + cb(null, "Deploying service web\n - Endpoint: https://demo.happyrock-1.eastus.azurecontainerapps.io/\n", ""); + // Default SPA patch: origin newly added. + addSpaRedirectUrisMock.mockResolvedValue({ added: ["x"], redirectUris: ["x"] }); +}); + +describe("project_deploy", () => { + it("errors when there is no azure.yaml to deploy", async () => { + azureYamlExists = false; + const r = await deployAzureTool.handler({ projectDir: "/proj", location: "eastus" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("no `azure.yaml`"); + expect(execFile).not.toHaveBeenCalled(); + }); + + it("errors when no region is supplied (subscription-scoped template needs one)", async () => { + delete process.env.AZURE_LOCATION; + const r = await deployAzureTool.handler({ projectDir: "/proj" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("no Azure region"); + expect(execFile).not.toHaveBeenCalled(); + }); + + it("runs azd up --no-prompt with env wired from state and returns the endpoint", async () => { + stateStore.azureSubscriptionId = "sub-123"; + stateStore.containerTypeId = "ct-456"; + + const r = await deployAzureTool.handler({ projectDir: "/proj", environmentName: "spe-demo", location: "eastus" }); + + expect(r.isError).toBeFalsy(); + expect(execFile).toHaveBeenCalledTimes(1); + const [cmd, args, opts] = vi.mocked(execFile).mock.calls[0] as unknown as [ + string, + string[], + { env?: NodeJS.ProcessEnv }, + ]; + expect(cmd).toBe("azd"); + expect(args).toEqual(["up", "--no-prompt", "--environment", "spe-demo"]); + expect(opts.env?.AZURE_ENV_NAME).toBe("spe-demo"); + expect(opts.env?.AZURE_LOCATION).toBe("eastus"); + expect(opts.env?.AZURE_SUBSCRIPTION_ID).toBe("sub-123"); + expect(opts.env?.SPE_CONTAINER_TYPE_ID).toBe("ct-456"); + + expect(r.content[0].text).toContain("https://demo.happyrock-1.eastus.azurecontainerapps.io/"); + expect(r.content[0].text).toContain("subscription-scoped"); + }); + + it("retries the deploy alone when azd up loses the Resource Graph indexing race", async () => { + vi.useFakeTimers(); + execImpl = (_cmd, args, _opts, cb) => { + if (args[0] === "up") { + // Provisioned, but the publish step could not find the freshly-created + // resource by its azd-service-name tag yet (ARG indexing lag). + cb( + new Error("exit status 1"), + "(done) Static Web App\nERROR: publishing service web: getting target resource: resource not found: unable to find a resource tagged with 'azd-service-name: web'", + "", + ); + } else { + // azd deploy retry succeeds once ARG has caught up. + cb(null, "web: Done\n- Endpoint: https://retried-app.7.azurestaticapps.net/\n", ""); + } + }; + + const pending = deployAzureTool.handler({ projectDir: "/proj", location: "eastus" }); + await vi.runAllTimersAsync(); + const r = await pending; + vi.useRealTimers(); + + expect(r.isError).toBeFalsy(); + expect(r.content[0].text).toContain("https://retried-app.7.azurestaticapps.net/"); + const calls = vi.mocked(execFile).mock.calls as unknown as [string, string[]][]; + expect(calls.some((c) => c[1][0] === "up")).toBe(true); + expect(calls.some((c) => c[1][0] === "deploy")).toBe(true); + }); + + it("does not retry (and surfaces the error) when azd up fails for a non-indexing reason", async () => { + execImpl = (_cmd, args, _opts, cb) => { + if (args[0] === "up") { + cb(new Error("exit status 1"), "ERROR: deployment failed: InvalidTemplate — bad bicep", ""); + } else { + cb(null, "should not be called", ""); + } + }; + const r = await deployAzureTool.handler({ projectDir: "/proj", location: "eastus" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("InvalidTemplate"); + const calls = vi.mocked(execFile).mock.calls as unknown as [string, string[]][]; + expect(calls.some((c) => c[1][0] === "deploy")).toBe(false); + }); + + it("reports a friendly message when azd is not installed", async () => { + execImpl = (_cmd, _args, _opts, cb) => + cb(Object.assign(new Error("spawn azd ENOENT"), { code: "ENOENT" }), "", ""); + const r = await deployAzureTool.handler({ projectDir: "/proj", location: "eastus" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("Azure Developer CLI (`azd`) is not installed"); + }); + + it("auto-registers the deployed origin as a SPA redirect URI on the owning app", async () => { + stateStore.appObjectId = "obj-owning"; + stateStore.appId = "app-owning"; + execImpl = (_cmd, _args, _opts, cb) => + cb(null, "web\n - Endpoint: https://my-spa-123.7.azurestaticapps.net/\n", ""); + + const r = await deployAzureTool.handler({ projectDir: "/proj", location: "eastus" }); + + expect(r.isError).toBeFalsy(); + expect(addSpaRedirectUrisMock).toHaveBeenCalledTimes(1); + const [objectId, origins, , options] = addSpaRedirectUrisMock.mock.calls[0]; + expect(objectId).toBe("obj-owning"); + // The scheme+host origin only (no trailing slash / path). + expect(origins).toEqual(["https://my-spa-123.7.azurestaticapps.net"]); + expect(options).toEqual({ bestEffort: true }); + expect(r.content[0].text).toContain("https://my-spa-123.7.azurestaticapps.net"); + expect(r.content[0].text).toContain("SPA"); + }); + + it("emits manual SPA-redirect guidance when no owning app is recorded in state", async () => { + // No appObjectId in state → cannot auto-patch. + execImpl = (_cmd, _args, _opts, cb) => + cb(null, "web\n - Endpoint: https://no-owner.7.azurestaticapps.net/\n", ""); + + const r = await deployAzureTool.handler({ projectDir: "/proj", location: "eastus" }); + + expect(addSpaRedirectUrisMock).not.toHaveBeenCalled(); + expect(r.content[0].text).toContain("https://no-owner.7.azurestaticapps.net"); + expect(r.content[0].text).toContain("SPA"); + }); + + it("falls back to manual guidance when the best-effort SPA patch fails", async () => { + stateStore.appObjectId = "obj-owning"; + stateStore.appId = "app-owning"; + addSpaRedirectUrisMock.mockResolvedValue(undefined); // best-effort failure + execImpl = (_cmd, _args, _opts, cb) => + cb(null, "web\n - Endpoint: https://patch-fail.7.azurestaticapps.net/\n", ""); + + const r = await deployAzureTool.handler({ projectDir: "/proj", location: "eastus" }); + + expect(r.isError).toBeFalsy(); + expect(r.content[0].text).toContain("could not auto-update"); + expect(r.content[0].text).toContain("https://patch-fail.7.azurestaticapps.net"); + expect(r.content[0].text).toContain("app-owning"); + }); +}); diff --git a/src/tools/deploy-azure.ts b/src/tools/deploy-azure.ts new file mode 100644 index 0000000..33940c0 --- /dev/null +++ b/src/tools/deploy-azure.ts @@ -0,0 +1,271 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tool: project_deploy + * + * Deploys the scaffolded reference app to Azure with the Azure Developer CLI + * (`azd up`), then returns the live endpoint. Ports EVAL.md `deploy-to-azure`. + * + * Requires the Azure Developer CLI (`azd`) and a signed-in Azure session. The + * scaffolded project includes `azure.yaml` + `infra/main.bicep`. The C# arch + * uses the ODSP security-approved azd template (subscription-scoped Bicep that + * provisions its own resource group), so `azd up --no-prompt` needs the env + * name, location and subscription supplied non-interactively — we set those (and + * the SPE container type id) from the recorded provisioning state. + */ + +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { bootstrapTokenProvider } from "../bootstrap.js"; +import { addSpaRedirectUris } from "../graph-client.js"; +import { readState } from "../state.js"; +import type { McpTool } from "../types.js"; + +interface DeployArgs { + projectDir?: string; + environmentName?: string; + location?: string; +} + +const AZD_TIMEOUT_MS = 15 * 60_000; // azd up can take several minutes + +/** + * `azd` resolves the service resource to publish by querying Azure Resource + * Graph for the `azd-service-name` tag. ARG is eventually-consistent, so a + * fast-provisioning resource (notably a Static Web App, which is ready in a + * second or two) may not be indexed yet when the publish step runs — `azd up` + * then fails the deploy step even though provisioning succeeded. We detect that + * specific race and retry the deploy alone (provisioning is already done) until + * ARG catches up. + */ +const ARG_LAG_PATTERN = /unable to find a resource tagged/i; +const DEPLOY_RETRY_ATTEMPTS = 4; +const DEPLOY_RETRY_DELAY_MS = 15_000; + +function sleep(ms: number): Promise { + return new Promise((resolve_) => setTimeout(resolve_, ms)); +} + +function execFileAsync( + cmd: string, + args: string[], + opts: { timeout: number; cwd: string; shell?: boolean; env?: NodeJS.ProcessEnv }, +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve_, reject) => { + execFile(cmd, args, opts, (err, stdout, stderr) => { + if (err) reject(Object.assign(err, { stdout, stderr })); + else resolve_({ stdout, stderr }); + }); + }); +} + +/** Extract the deployed app endpoint azd prints. */ +function extractEndpoint(output: string): string | null { + // Prefer azd's explicit "Endpoint:" line — that is the deployed app URL. + const labeled = output.match(/Endpoint:\s*(https:\/\/[^\s)"']+)/i); + if (labeled) return labeled[1]; + // Otherwise the first https URL that is not the Azure Portal deep-link azd + // prints for deployment progress. + const all = output.match(/https:\/\/[^\s)"']+/g) ?? []; + return all.find((u) => !/portal\.azure\.com/i.test(u)) ?? all[0] ?? null; +} + +/** The scheme+host origin of a URL (no path/trailing slash), or null if unparseable. */ +function originOf(url: string): string | null { + try { + return new URL(url).origin; + } catch { + return null; + } +} + +/** + * Best-effort: register the freshly deployed origin as a SPA redirect URI on the + * owning Entra app so the generated browser app can sign in (MSAL.js auth-code + + * PKCE) without AADSTS9002326. Read-modify-write and idempotent — existing URIs + * (including the local http://localhost:5173 dev origin) are preserved. Returns a + * markdown note describing the outcome to append to the deploy report. + * + * Only acts when the owning app's object id is recorded in state (i.e. the app + * was created by project_app_create). For the C# arch — which provisions its own + * Entra app in Bicep with a `web` redirect — there is no owning-app SPA to patch, + * so this is skipped and the caller's manual-add hint still applies. + */ +async function addDeployedOriginToOwningApp(endpoint: string | null): Promise { + if (!endpoint) return ""; + const origin = originOf(endpoint); + if (!origin) return ""; + + const state = readState(); + if (!state.appObjectId) { + // No owning app recorded — fall back to a precise manual instruction. + return ( + `\n\n> **Sign-in:** add \`${origin}\` as a **SPA** redirect URI on your owning ` + + `Entra app (Authentication → Single-page application) so browser sign-in works.` + ); + } + + const result = await addSpaRedirectUris( + state.appObjectId, + [origin], + bootstrapTokenProvider, + { bestEffort: true }, + ); + + if (result && result.added.length > 0) { + return `\n\nAdded \`${origin}\` to the owning app's **SPA** redirect URIs — browser sign-in is ready.`; + } + if (result) { + return `\n\n\`${origin}\` is already a **SPA** redirect URI on the owning app — browser sign-in is ready.`; + } + // best-effort PATCH failed — tell the user exactly what to add manually. + return ( + `\n\n> **Sign-in:** could not auto-update the owning app${state.appId ? ` (\`${state.appId}\`)` : ""}. ` + + `Add \`${origin}\` as a **SPA** redirect URI (Authentication → Single-page application), then retry sign-in.` + ); +} + +/** + * Run `azd up`; if its publish step loses the Resource Graph indexing race + * (provisioning succeeded but the service resource is not tagged-and-indexed + * yet), retry `azd deploy` alone — provisioning is already done — until ARG + * catches up or the attempts are exhausted. Any other failure is rethrown + * immediately so the caller surfaces the real error. + */ +async function deployWithArgLagRetry( + environmentName: string, + dir: string, + env: NodeJS.ProcessEnv, +): Promise<{ stdout: string; stderr: string }> { + const opts = { timeout: AZD_TIMEOUT_MS, cwd: dir, shell: process.platform === "win32", env }; + const argLag = (err: { stdout?: string; stderr?: string }) => + ARG_LAG_PATTERN.test([err.stdout, err.stderr].filter(Boolean).join("\n")); + try { + return await execFileAsync("azd", ["up", "--no-prompt", "--environment", environmentName], opts); + } catch (error) { + if (!argLag(error as { stdout?: string; stderr?: string })) throw error; + let lastError: unknown = error; + for (let attempt = 1; attempt <= DEPLOY_RETRY_ATTEMPTS; attempt++) { + await sleep(DEPLOY_RETRY_DELAY_MS); + try { + return await execFileAsync("azd", ["deploy", "--no-prompt", "--environment", environmentName], opts); + } catch (retryError) { + lastError = retryError; + // A different failure means retrying will not help — surface it now. + if (!argLag(retryError as { stdout?: string; stderr?: string })) throw retryError; + } + } + throw lastError; + } +} + +export const deployAzureTool: McpTool = { + name: "project_deploy", + annotations: { localRequired: true }, + description: + "Deploy the scaffolded SharePoint Embedded app to Azure using the Azure Developer CLI " + + "(`azd up`) and return the live URL. Requires `azd` installed and an Azure login. Provisions " + + "the security-approved infrastructure in the project's infra/ Bicep (managed identity, ACR, " + + "Container Apps) and deploys the app.", + inputSchema: { + type: "object" as const, + properties: { + projectDir: { type: "string", description: "The scaffolded project directory. Default: current directory." }, + environmentName: { type: "string", description: "azd environment name. Default: 'spe-dev'." }, + location: { + type: "string", + description: + "Azure region for the deployment (e.g., 'eastus'). Required by the subscription-scoped " + + "template for non-interactive `azd up`; falls back to the AZURE_LOCATION environment variable.", + }, + }, + }, + handler: async (args) => { + const { projectDir = process.cwd(), environmentName = "spe-dev", location } = args as DeployArgs; + const dir = resolve(projectDir); + + if (!existsSync(join(dir, "azure.yaml"))) { + return { + content: [{ type: "text" as const, text: `Error: no \`azure.yaml\` in \`${dir}\`. Scaffold a reference architecture first.` }], + isError: true, + }; + } + + // The approved template is subscription-scoped and declarative: it reads the + // environment name, location, subscription and SPE container type id from azd + // environment variables. Supply them from state so `--no-prompt` succeeds. + const state = readState(); + const childEnv: NodeJS.ProcessEnv = { ...process.env, AZURE_ENV_NAME: environmentName }; + if (location) childEnv.AZURE_LOCATION = location; + if (state.containerTypeId) childEnv.SPE_CONTAINER_TYPE_ID = state.containerTypeId; + + if (!childEnv.AZURE_LOCATION) { + return { + content: [{ + type: "text" as const, + text: + "Error: no Azure region specified. The security-approved template provisions its own " + + "resource group, so `azd up --no-prompt` needs a location. Pass `location` (e.g., 'eastus') " + + "or set the AZURE_LOCATION environment variable, then retry.", + }], + isError: true, + }; + } + + // Subscription: prefer recorded provisioning state; otherwise fall back to the + // Azure CLI's active subscription. The trial flow records no subscription, so + // without this `azd up --no-prompt` would have none and fail. (After the + // region check so the no-region path makes no exec calls.) + let subscriptionId = state.azureSubscriptionId; + if (!subscriptionId) { + try { + const { stdout } = await execFileAsync("az", ["account", "show", "--query", "id", "--output", "tsv"], { + timeout: 30_000, + cwd: dir, + shell: process.platform === "win32", + env: childEnv, + }); + subscriptionId = stdout.trim() || undefined; + } catch { + /* leave undefined — azd may still resolve it from its own environment */ + } + } + if (subscriptionId) childEnv.AZURE_SUBSCRIPTION_ID = subscriptionId; + + try { + const { stdout, stderr } = await deployWithArgLagRetry(environmentName, dir, childEnv); + const endpoint = extractEndpoint(`${stdout}\n${stderr}`); + + // Auto-register the deployed origin as a SPA redirect URI on the owning app + // (idempotent, best-effort) so the generated browser app can sign in. + const spaNote = await addDeployedOriginToOwningApp(endpoint); + + const output = + "## Deployed to Azure 🌐\n\n" + + (endpoint ? `Your app is live:\n\n→ ${endpoint}\n\n` : "Deployment completed.\n\n") + + "Provisioned and deployed via `azd up` using the project's subscription-scoped " + + "infrastructure (which creates its own resource group)." + + spaNote; + return { content: [{ type: "text" as const, text: output }] }; + } catch (error) { + const e = error as Error & { stdout?: string; stderr?: string; code?: string; message: string }; + // azd writes its version warning to stderr and the ACTIONABLE error to + // stdout, so reading stderr-first masked the real failure. Combine both and + // surface the TAIL (azd prints the actual error at the end of its output). + const combined = [e.stdout, e.stderr].filter(Boolean).join("\n").trim() || (e.message ?? ""); + const detail = combined.length > 1500 ? "…" + combined.slice(-1500) : combined; + // Only a genuine spawn failure means azd is missing — many real azd errors + // legitimately contain "not found" (e.g. ARG resource lookups), so do not + // match on that phrase alone. + if (e.code === "ENOENT" || /not recognized as an internal or external command/i.test(detail)) { + return { + content: [{ type: "text" as const, text: "Error: the Azure Developer CLI (`azd`) is not installed. Install it from https://aka.ms/azd-install, then retry." }], + isError: true, + }; + } + return { content: [{ type: "text" as const, text: `Error deploying to Azure:\n\n${detail}` }], isError: true }; + } + }, +}; diff --git a/src/tools/docs.test.ts b/src/tools/docs.test.ts new file mode 100644 index 0000000..db4168f --- /dev/null +++ b/src/tools/docs.test.ts @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for the documentation tools (docs_search, docs_fetch). + * + * The Microsoft Learn MCP client is mocked so these run offline in CI. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../docs-client.js", () => ({ + searchDocs: vi.fn(), + fetchDoc: vi.fn(), +})); + +import * as docs from "../docs-client.js"; +import { searchDocsTool, fetchDocTool } from "../tools/search-docs.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// ─── docs_search ───────────────────────────────────────────────────────── + +describe("docs_search", () => { + it("has correct metadata", () => { + expect(searchDocsTool.name).toBe("docs_search"); + expect(searchDocsTool.inputSchema.required).toContain("query"); + expect(searchDocsTool.description.length).toBeGreaterThan(20); + }); + + it("returns Learn results for a query", async () => { + vi.mocked(docs.searchDocs).mockResolvedValue("A container type defines the relationship..."); + + const result = await searchDocsTool.handler({ query: "what is a container type" }); + + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain("Microsoft Learn results"); + expect(result.content[0].text).toContain("container type"); + expect(docs.searchDocs).toHaveBeenCalledOnce(); + }); + + it("scopes generic queries to SharePoint Embedded", async () => { + vi.mocked(docs.searchDocs).mockResolvedValue("result"); + + await searchDocsTool.handler({ query: "billing classifications" }); + + const calledWith = vi.mocked(docs.searchDocs).mock.calls[0][0]; + expect(calledWith).toMatch(/SharePoint Embedded/i); + }); + + it("does not double-scope queries that already mention SPE", async () => { + vi.mocked(docs.searchDocs).mockResolvedValue("result"); + + await searchDocsTool.handler({ query: "SharePoint Embedded container limits" }); + + const calledWith = vi.mocked(docs.searchDocs).mock.calls[0][0]; + expect(calledWith).toBe("SharePoint Embedded container limits"); + }); + + it("requires a query", async () => { + const result = await searchDocsTool.handler({}); + expect(result.isError).toBe(true); + expect(docs.searchDocs).not.toHaveBeenCalled(); + }); + + it.each([123, {}, [], true, null])( + "returns a clean validation error for a non-string query (%p) without throwing", + async (query) => { + const result = await searchDocsTool.handler({ query } as Record); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("Error: query is required"); + expect(docs.searchDocs).not.toHaveBeenCalled(); + }, + ); + + it("surfaces upstream errors", async () => { + vi.mocked(docs.searchDocs).mockRejectedValue(new Error("Learn MCP unreachable")); + + const result = await searchDocsTool.handler({ query: "containers" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Learn MCP unreachable"); + }); +}); + +// ─── docs_fetch ─────────────────────────────────────────────────────────── + +describe("docs_fetch", () => { + it("has correct metadata", () => { + expect(fetchDocTool.name).toBe("docs_fetch"); + expect(fetchDocTool.inputSchema.required).toContain("url"); + }); + + it("returns full page content", async () => { + vi.mocked(docs.fetchDoc).mockResolvedValue("# Full page\n\nbody"); + + const result = await fetchDocTool.handler({ + url: "https://learn.microsoft.com/sharepoint/dev/embedded/overview", + }); + + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain("Full page"); + expect(docs.fetchDoc).toHaveBeenCalledWith( + "https://learn.microsoft.com/sharepoint/dev/embedded/overview", + ); + }); + + it("requires a url", async () => { + const result = await fetchDocTool.handler({}); + expect(result.isError).toBe(true); + expect(docs.fetchDoc).not.toHaveBeenCalled(); + }); + + it.each([123, {}, [], true, null])( + "returns a clean validation error for a non-string url (%p) without throwing", + async (url) => { + const result = await fetchDocTool.handler({ url } as Record); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("Error: url is required"); + expect(docs.fetchDoc).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/src/tools/gate-error-classification.test.ts b/src/tools/gate-error-classification.test.ts new file mode 100644 index 0000000..f3ee0c9 --- /dev/null +++ b/src/tools/gate-error-classification.test.ts @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Regression tests for the restart-confirmation gate error classification + * (PR #3 review). + * + * On `contextChoice=confirm` the gate stamps the session confirmed, which writes + * state to disk (writeState → writeSecureFile, a 0o600 write that can fail with + * EACCES/EIO). The gate call was previously ABOVE each mutation tool's try/catch, + * so such a write failure escaped the tool and surfaced through the generic + * dispatch catch instead of the tool's own error classification. The fix moves + * the gate INSIDE each handler's try so a stamp-write failure is classified by + * that tool's own `err(...)` / `fail(...)` envelope, consistent with its other + * errors. + * + * Here `writeState` is mocked to throw; each gated mutation tool must return its + * own classified `isError` result (NOT reject/throw to the dispatcher). + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// writeState throws (simulating a secure-file write failure on the confirm stamp); +// readState returns a gate-arming context (kept realistic though the confirm path +// stamps directly without reading). +vi.mock("../state.js", () => ({ + readState: vi.fn(() => ({ appId: "app-1", tenantId: "t-1", containerTypeId: "ct-1" })), + writeState: vi.fn(() => { + throw new Error("EACCES: permission denied, open '/home/dev/.spe/state.json'"); + }), + clearState: vi.fn(), +})); +// Graph / bootstrap / auth are mocked so the tool modules import offline; none of +// their functions are reached because the gate throws first. +vi.mock("../graph-client.js", () => ({ + registerContainerType: vi.fn(), + createContainerType: vi.fn(), + deleteContainerType: vi.fn(), + listContainerTypes: vi.fn(async () => []), + grantContainerTypeAppPermission: vi.fn(), + revokeContainerTypeAppPermission: vi.fn(), + listContainerTypeAppPermissions: vi.fn(async () => []), + getSignedInUser: vi.fn(async () => ({ id: "user-1", userPrincipalName: "admin@x.com" })), + grantContainerTypeOwner: vi.fn(), + listContainerTypePermissions: vi.fn(async () => []), + revokeContainerTypePermission: vi.fn(), +})); +vi.mock("../bootstrap.js", () => ({ + bootstrapTokenProvider: vi.fn(async () => "boot"), + getSignedInIdentity: vi.fn(async () => ({ tenantId: "t-1", username: "dev@x.com" })), +})); +vi.mock("../auth.js", () => ({ setAuthConfig: vi.fn() })); + +import * as state from "../state.js"; +import type { McpTool } from "../types.js"; +import { registerContainerTypeTool } from "../tools/register-container-type.js"; +import { createContainerTypeTool } from "../tools/create-container-type.js"; +import { addContainerTypeAppGrantTool, removeContainerTypeAppGrantTool } from "../tools/container-type-app-grants.js"; +import { grantContainerTypeOwnerTool, revokeContainerTypeOwnerTool } from "../tools/container-type-permissions.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// Every control-plane MUTATION tool that wires the restart-confirmation gate. +// `contains` is the tool's own error-classifier prefix — proof the failure was +// handled by that tool, not the generic dispatcher. +const gatedTools: Array<{ label: string; tool: McpTool; contains: string }> = [ + { label: "container_type_register", tool: registerContainerTypeTool, contains: "registering container type" }, + { label: "container_type_create", tool: createContainerTypeTool, contains: "creating container type" }, + { label: "container_type_app_grant_add", tool: addContainerTypeAppGrantTool, contains: "granting app permission" }, + { label: "container_type_app_grant_remove", tool: removeContainerTypeAppGrantTool, contains: "removing app permission grant" }, + { label: "container_type_grant_owner", tool: grantContainerTypeOwnerTool, contains: "granting owner" }, + { label: "container_type_revoke_owner", tool: revokeContainerTypeOwnerTool, contains: "revoking owner" }, +]; + +describe("restart-confirmation gate — stamp-write failure is tool-classified (PR #3 review)", () => { + for (const { label, tool, contains } of gatedTools) { + it(`${label}: contextChoice=confirm with a failing writeState returns a classified error (not a throw)`, async () => { + // create requires a displayName to pass its schema before the gate runs. + const args = tool === createContainerTypeTool ? { displayName: "Test CT", contextChoice: "confirm" } : { contextChoice: "confirm" }; + + // Must RESOLVE to an error envelope — never reject to the dispatcher. + const r = await tool.handler(args); + + expect(state.writeState).toHaveBeenCalledTimes(1); // the gate attempted the stamp + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain(contains); // classified by the tool itself + }); + } +}); diff --git a/src/tools/get-container.ts b/src/tools/get-container.ts new file mode 100644 index 0000000..022f5dc --- /dev/null +++ b/src/tools/get-container.ts @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tool: container_get + * + * Get details of a single container including permissions and drive info. + */ + +import { + getContainer, + getContainerDrive, + getCustomProperties, + listContainerPermissions, +} from "../graph-client.js"; +import type { McpTool } from "../types.js"; + +export const getContainerTool: McpTool = { + name: "container_get", + annotations: { readOnly: true }, + description: + "Get detailed information about a SharePoint Embedded container, " + + "including its status, permissions, drive info, and custom properties.", + inputSchema: { + type: "object" as const, + properties: { + containerId: { + type: "string", + description: "The container ID to inspect.", + }, + }, + required: ["containerId"], + }, + handler: async (args) => { + const containerId = args.containerId as string; + if (!containerId) { + return { + content: [{ type: "text", text: "Error: containerId is required" }], + isError: true, + }; + } + + const container = await getContainer(containerId); + + let output = `## Container Details\n\n`; + output += `| Property | Value |\n|----------|-------|\n`; + output += `| **ID** | \`${container.id}\` |\n`; + output += `| **Name** | ${container.displayName} |\n`; + output += `| **Status** | ${container.status} |\n`; + output += `| **Lock State** | ${container.lockState ?? "unlocked"} |\n`; + output += `| **Container Type** | \`${container.containerTypeId}\` |\n`; + output += `| **Created** | ${container.createdDateTime ?? "—"} |\n`; + + // Drive info (non-fatal if unavailable) + try { + const drive = await getContainerDrive(containerId); + output += `| **Drive ID** | \`${drive.id}\` |\n`; + if (drive.webUrl) output += `| **Drive URL** | ${drive.webUrl} |\n`; + if (drive.quota) { + const usedGB = (drive.quota.used / (1024 ** 3)).toFixed(2); + output += `| **Storage Used** | ${usedGB} GB |\n`; + } + } catch { + output += `| **Drive** | (unavailable) |\n`; + } + + // Permissions (non-fatal) + try { + const perms = await listContainerPermissions(containerId); + if (perms.length > 0) { + output += `\n### Permissions (${perms.length})\n\n`; + output += `| User | Role | Permission ID |\n|------|------|---------------|\n`; + for (const p of perms) { + const user = p.grantedToV2?.user?.userPrincipalName ?? p.grantedToV2?.user?.displayName ?? "(unknown)"; + output += `| ${user} | ${p.roles.join(", ")} | \`${p.id}\` |\n`; + } + } + } catch { + output += `\n> Permissions not available.\n`; + } + + // Custom properties (non-fatal) + try { + const props = await getCustomProperties(containerId); + const propKeys = Object.keys(props).filter(k => !k.startsWith("@odata")); + if (propKeys.length > 0) { + output += `\n### Custom Properties\n\n`; + output += `| Key | Value | Searchable |\n|-----|-------|------------|\n`; + for (const key of propKeys) { + const p = props[key]; + output += `| ${key} | ${p.value} | ${p.isSearchable ? "yes" : "no"} |\n`; + } + } + } catch { + // No custom properties or access denied — skip silently + } + + return { content: [{ type: "text", text: output }] }; + }, +}; diff --git a/src/tools/hydrate-config.test.ts b/src/tools/hydrate-config.test.ts new file mode 100644 index 0000000..7c5f73f --- /dev/null +++ b/src/tools/hydrate-config.test.ts @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for project_hydrate_config azure.yaml emission. + * + * The bug: hydrate emitted a constant azure.yaml (name spe-app, language ts, + * host staticwebapp), clobbering a C# (containerapp) scaffold — flipping the host + * and renaming the service. These assert hydrate derives the descriptor from + * the recorded scaffold architecture + projectName and won't destructively + * overwrite an architecture-correct file. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const stateStore: Record = {}; +vi.mock("../state.js", () => ({ + readState: vi.fn(() => ({ ...stateStore })), + writeState: vi.fn((patch: Record) => { + Object.assign(stateStore, patch); + return { ...stateStore }; + }), +})); + +import { hydrateConfigTool } from "../tools/hydrate-config.js"; + +const here = dirname(fileURLToPath(import.meta.url)); +let dir: string; + +beforeEach(() => { + for (const k of Object.keys(stateStore)) delete stateStore[k]; + // Minimum state required for hydrate to run. + stateStore.appId = "app-1"; + stateStore.containerTypeId = "ct-1"; + stateStore.tenantId = "t-1"; + dir = join(here, `__hydrate_tmp_${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe("project_hydrate_config azure.yaml", () => { + it("emits a Container Apps descriptor for a C# scaffold (correct host + name)", async () => { + stateStore.scaffoldArchitecture = "csharp-web"; + stateStore.projectName = "contoso-web"; + + const result = await hydrateConfigTool.handler({ targetDir: dir, formats: ["azureyaml"] }); + expect(result.isError).toBeFalsy(); + + const yaml = readFileSync(join(dir, "azure.yaml"), "utf-8"); + expect(yaml).toContain("name: contoso-web"); + expect(yaml).toContain("language: dotnet"); + expect(yaml).toContain("host: containerapp"); + expect(yaml).not.toContain("host: staticwebapp"); + }); + + it("emits a Static Web Apps descriptor for a React scaffold", async () => { + stateStore.scaffoldArchitecture = "react-spa-functions"; + stateStore.projectName = "contoso-spa"; + + await hydrateConfigTool.handler({ targetDir: dir, formats: ["azureyaml"] }); + + const yaml = readFileSync(join(dir, "azure.yaml"), "utf-8"); + expect(yaml).toContain("name: contoso-spa"); + expect(yaml).toContain("language: ts"); + expect(yaml).toContain("host: staticwebapp"); + expect(yaml).not.toContain("host: appservice"); + }); + + it("does NOT destructively overwrite an architecture-correct azure.yaml", async () => { + stateStore.scaffoldArchitecture = "csharp-web"; + stateStore.projectName = "contoso-web"; + + // Pre-existing correct (scaffolded) C# descriptor with extra custom content. + const scaffolded = + "name: contoso-web\nmetadata:\n template: spe-builder-mcp\n" + + "services:\n web:\n project: .\n language: dotnet\n host: containerapp\n# custom note\n"; + writeFileSync(join(dir, "azure.yaml"), scaffolded, "utf-8"); + + const result = await hydrateConfigTool.handler({ targetDir: dir, formats: ["azureyaml"] }); + + const yaml = readFileSync(join(dir, "azure.yaml"), "utf-8"); + expect(yaml).toBe(scaffolded); // untouched + expect(result.content[0].text).toContain("kept existing"); + }); + + it("merges only MISSING top-level keys into an existing azure.yaml, preserving custom content", async () => { + // No scaffoldArchitecture recorded. The existing file already declares name + // and services (with a custom host + a custom comment); hydrate must NOT + // clobber any of it — it may only fill a missing top-level key (metadata). + stateStore.projectName = "contoso-web"; + const preexisting = + "name: contoso-web\nservices:\n web:\n host: appservice\n# custom note kept\n"; + writeFileSync(join(dir, "azure.yaml"), preexisting, "utf-8"); + + const result = await hydrateConfigTool.handler({ targetDir: dir, formats: ["azureyaml"] }); + expect(result.isError).toBeFalsy(); + + const yaml = readFileSync(join(dir, "azure.yaml"), "utf-8"); + // Custom/scaffold content survives verbatim. + expect(yaml).toContain("name: contoso-web"); + expect(yaml).toContain("host: appservice"); // not flipped to staticwebapp + expect(yaml).toContain("# custom note kept"); + // The missing managed top-level key is filled in. + expect(yaml).toContain("metadata:"); + expect(result.content[0].text).toContain("merged; filled: metadata"); + }); + + it("does NOT rewrite/clobber a stale azure.yaml — scaffold owns the architecture", async () => { + stateStore.scaffoldArchitecture = "csharp-web"; + stateStore.projectName = "contoso-web"; + + // A "stale" descriptor (old constant SWA output) plus user customization. + // Previously hydrate clobbered this; now it must be preserved (only missing + // top-level keys are filled), never overwritten. + const preexisting = + "name: spe-app\nservices:\n web:\n language: ts\n host: staticwebapp\n# do not lose me\n"; + writeFileSync(join(dir, "azure.yaml"), preexisting, "utf-8"); + + await hydrateConfigTool.handler({ targetDir: dir, formats: ["azureyaml"] }); + + const yaml = readFileSync(join(dir, "azure.yaml"), "utf-8"); + // Existing values are preserved — not clobbered with the generated descriptor. + expect(yaml).toContain("name: spe-app"); + expect(yaml).toContain("host: staticwebapp"); + expect(yaml).toContain("# do not lose me"); + // Only the missing top-level metadata key is added. + expect(yaml).toContain("metadata:"); + }); + + it("preserves the project name in the default (no architecture recorded)", async () => { + stateStore.projectName = "just-a-name"; + + await hydrateConfigTool.handler({ targetDir: dir, formats: ["azureyaml"] }); + + const yaml = readFileSync(join(dir, "azure.yaml"), "utf-8"); + expect(yaml).toContain("name: just-a-name"); + }); +}); + +describe("project_hydrate_config formats validation", () => { + it("rejects an invalid format value with NO files written", async () => { + const result = await hydrateConfigTool.handler({ targetDir: dir, formats: ["env", "bogus"] }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("invalid format(s): 'bogus'"); + expect(result.content[0].text).not.toContain("Config Hydrated"); + expect(existsSync(join(dir, ".env"))).toBe(false); + }); + + it("rejects an empty formats array instead of falsely reporting success", async () => { + const result = await hydrateConfigTool.handler({ targetDir: dir, formats: [] }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("non-empty array"); + expect(result.content[0].text).not.toContain("Config Hydrated"); + }); + + it("writes only the requested valid format", async () => { + const result = await hydrateConfigTool.handler({ targetDir: dir, formats: ["env"] }); + + expect(result.isError).toBeFalsy(); + expect(existsSync(join(dir, ".env"))).toBe(true); + expect(existsSync(join(dir, "appsettings.Development.json"))).toBe(false); + expect(result.content[0].text).toContain("Config Hydrated"); + }); + + it("defaults to all three formats when omitted", async () => { + const result = await hydrateConfigTool.handler({ targetDir: dir }); + + expect(result.isError).toBeFalsy(); + expect(existsSync(join(dir, ".env"))).toBe(true); + expect(existsSync(join(dir, "appsettings.Development.json"))).toBe(true); + expect(existsSync(join(dir, "azure.yaml"))).toBe(true); + }); +}); diff --git a/src/tools/hydrate-config.ts b/src/tools/hydrate-config.ts new file mode 100644 index 0000000..17e6ef1 --- /dev/null +++ b/src/tools/hydrate-config.ts @@ -0,0 +1,256 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tool: project_hydrate_config + * + * Writes the current provisioning state (tenant, client, container type, + * container, subscription/RG) into a project so a reference app can consume it + * directly. Emits `.env`, `appsettings.Development.json`, and `azure.yaml` + * fragments. Ports EVAL.md `hydrate-config`. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { buildAzureYaml, findArchitecture } from "../reference-architectures.js"; +import { readState } from "../state.js"; +import type { McpTool } from "../types.js"; + +interface HydrateArgs { + targetDir?: string; + formats?: string[]; +} + +/** Config outputs this tool can emit — must mirror the inputSchema enum. */ +const VALID_FORMATS = ["env", "appsettings", "azureyaml"] as const; + +function envFile(s: ReturnType): string { + const lines = [ + "# SharePoint Embedded configuration (generated by project_hydrate_config)", + `TENANT_ID=${s.tenantId ?? ""}`, + `CLIENT_ID=${s.appId ?? ""}`, + `CONTAINER_TYPE_ID=${s.containerTypeId ?? ""}`, + `CONTAINER_ID=${s.containerId ?? ""}`, + "", + "# Vite-exposed copies — a frontend SPA only sees import.meta.env.VITE_* vars.", + `VITE_TENANT_ID=${s.tenantId ?? ""}`, + `VITE_CLIENT_ID=${s.appId ?? ""}`, + `VITE_CONTAINER_TYPE_ID=${s.containerTypeId ?? ""}`, + `VITE_CONTAINER_ID=${s.containerId ?? ""}`, + ]; + if (s.azureSubscriptionId) lines.push(`AZURE_SUBSCRIPTION_ID=${s.azureSubscriptionId}`); + if (s.resourceGroup) lines.push(`AZURE_RESOURCE_GROUP=${s.resourceGroup}`); + return lines.join("\n") + "\n"; +} + +function appSettings(s: ReturnType): string { + return JSON.stringify( + { + SharePointEmbedded: { + TenantId: s.tenantId ?? "", + ClientId: s.appId ?? "", + ContainerTypeId: s.containerTypeId ?? "", + ContainerId: s.containerId ?? "", + }, + }, + null, + 2, + ) + "\n"; +} + +/** + * Build the `azure.yaml` (azd) descriptor that matches the architecture the user + * actually scaffolded. The previous implementation emitted a constant + * (name `spe-app`, language `ts`, host `staticwebapp`) which clobbered a C# + * App Service scaffold — flipping it to a Static Web App and renaming the + * service. We derive name/language/host from the recorded scaffold instead. + */ +function azureYaml(s: ReturnType): string { + const name = s.projectName ?? "spe-app"; + const arch = s.scaffoldArchitecture ? findArchitecture(s.scaffoldArchitecture) : undefined; + if (arch) { + return buildAzureYaml(name, arch.language, arch.host); + } + // No architecture recorded — fall back to the React/Static Web Apps default + // (the recommended architecture), but still honor the project name. + return buildAzureYaml(name, "ts", "staticwebapp"); +} + +interface YamlBlock { + key: string; + lines: string[]; +} + +/** + * Parse a YAML document into its top-level key blocks. A block is a top-level + * `key:` line plus all subsequent indented / blank / comment continuation lines + * until the next top-level key. Leading comments before the first key stay out + * of the block model — the existing file is preserved verbatim, so they are + * never lost. + */ +function parseTopLevelBlocks(yaml: string): YamlBlock[] { + const blocks: YamlBlock[] = []; + let current: YamlBlock | null = null; + for (const line of yaml.split(/\r?\n/)) { + const m = /^([A-Za-z_][\w-]*):/.exec(line); + if (m) { + current = { key: m[1], lines: [line] }; + blocks.push(current); + } else if (current && (/^\s+/.test(line) || line.trim() === "" || line.startsWith("#"))) { + current.lines.push(line); + } + } + return blocks; +} + +/** + * Merge a generated `azure.yaml` into an existing (scaffolded / user-edited) one + * WITHOUT overwriting anything already present. The scaffold is authoritative + * for architecture-specific values (service name, language, host) and the user + * may have added custom content; we therefore only APPEND the top-level blocks + * the existing file is missing and preserve everything else verbatim + * ("truly merge, don't clobber"). + * + * Returns the merged content and the list of top-level keys that were filled in + * (empty when the existing file already covered everything → preserve verbatim). + */ +export function mergeAzureYaml( + existing: string, + generated: string, +): { content: string; filled: string[] } { + const haveKeys = new Set(parseTopLevelBlocks(existing).map((b) => b.key)); + const additions: string[] = []; + const filled: string[] = []; + for (const block of parseTopLevelBlocks(generated)) { + if (!haveKeys.has(block.key)) { + additions.push(block.lines.join("\n").replace(/\n+$/, "")); + filled.push(block.key); + } + } + if (additions.length === 0) { + return { content: existing, filled: [] }; + } + const base = existing.endsWith("\n") ? existing : existing + "\n"; + return { content: base + additions.join("\n") + "\n", filled }; +} + +export const hydrateConfigTool: McpTool = { + name: "project_hydrate_config", + annotations: { plane: "control" }, + description: + "Write the current SharePoint Embedded provisioning details (tenant, client, container type, " + + "container, subscription/resource group) into a project as .env, appsettings.Development.json, " + + "and azure.yaml so a reference app can consume them. Run after provisioning.", + inputSchema: { + type: "object" as const, + properties: { + targetDir: { type: "string", description: "Project directory to write into. Default: current directory." }, + formats: { + type: "array", + items: { type: "string", enum: ["env", "appsettings", "azureyaml"] }, + description: "Which config files to write. Default: all three.", + }, + }, + }, + handler: async (args) => { + const { targetDir = process.cwd(), formats } = args as HydrateArgs; + const state = readState(); + + // Validate `formats` when provided. The declared enum is not enforced at + // the transport boundary, so an invalid or empty value previously wrote 0 + // files yet reported success. Omitting `formats` keeps the + // documented default (all three); a provided value must be a non-empty + // array of known formats. + let selectedFormats: readonly string[]; + if (formats === undefined) { + selectedFormats = VALID_FORMATS; + } else { + if (!Array.isArray(formats) || formats.length === 0) { + return { + content: [ + { + type: "text" as const, + text: + "Error: formats must be a non-empty array. " + + `Valid values: ${VALID_FORMATS.join(", ")}. Omit "formats" to write all three.`, + }, + ], + isError: true, + }; + } + const invalid = formats.filter((f) => !(VALID_FORMATS as readonly string[]).includes(f)); + if (invalid.length > 0) { + return { + content: [ + { + type: "text" as const, + text: + `Error: invalid format(s): ${invalid.map((f) => `'${String(f)}'`).join(", ")}. ` + + `Valid values: ${VALID_FORMATS.join(", ")}.`, + }, + ], + isError: true, + }; + } + selectedFormats = formats; + } + + if (!state.appId || !state.containerTypeId) { + return { + content: [{ type: "text" as const, text: "Error: nothing to hydrate — provision an SPE app first (project_provision)." }], + isError: true, + }; + } + + try { + const dir = resolve(targetDir); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + + const written: string[] = []; + if (selectedFormats.includes("env")) { + writeFileSync(join(dir, ".env"), envFile(state), "utf-8"); + written.push(".env"); + } + if (selectedFormats.includes("appsettings")) { + writeFileSync(join(dir, "appsettings.Development.json"), appSettings(state), "utf-8"); + written.push("appsettings.Development.json"); + } + if (selectedFormats.includes("azureyaml")) { + const azureYamlPath = join(dir, "azure.yaml"); + const generated = azureYaml(state); + if (!existsSync(azureYamlPath)) { + writeFileSync(azureYamlPath, generated, "utf-8"); + written.push("azure.yaml"); + } else { + // Preserve the scaffold's (and any user-customized) azure.yaml — only + // fill top-level keys it is MISSING; never overwrite existing content + //. A stale host/name from the scaffold is intentionally + // kept: the scaffold owns the architecture, hydrate must not clobber. + const existing = readFileSync(azureYamlPath, "utf-8"); + const { content, filled } = mergeAzureYaml(existing, generated); + if (filled.length > 0) { + writeFileSync(azureYamlPath, content, "utf-8"); + written.push(`azure.yaml (merged; filled: ${filled.join(", ")})`); + } else { + written.push("azure.yaml (kept existing — scaffold config preserved)"); + } + } + } + + const output = + "## Config Hydrated\n\n" + + `Wrote ${written.length} file(s) to \`${dir}\`:\n\n` + + written.map((f) => `- \`${f}\``).join("\n") + + "\n\n| Key | Value |\n|-----|-------|\n" + + `| TENANT_ID | \`${state.tenantId ?? ""}\` |\n` + + `| CLIENT_ID | \`${state.appId}\` |\n` + + `| CONTAINER_TYPE_ID | \`${state.containerTypeId}\` |\n` + + `| CONTAINER_ID | \`${state.containerId ?? "(pending)"}\` |\n`; + + return { content: [{ type: "text" as const, text: output }] }; + } catch (error) { + const msg = error instanceof Error ? error.message : "Unknown error"; + return { content: [{ type: "text" as const, text: `Error hydrating config: ${msg}` }], isError: true }; + } + }, +}; diff --git a/src/tools/list-azure.test.ts b/src/tools/list-azure.test.ts new file mode 100644 index 0000000..e846eae --- /dev/null +++ b/src/tools/list-azure.test.ts @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for azure_subscriptions_list not-signed-in detection. + * + * The bug: `az account list` returns [] with exit 0 when the user is NOT signed + * in, so the tool falsely reported "No enabled Azure subscriptions found for the + * signed-in user" with no `az login` guidance. These assert the tool now probes + * sign-in state and gives the right message for each case. The Azure CLI layer + * is mocked so the tests run offline. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../azure-cli.js", () => ({ + listSubscriptions: vi.fn(), + isSignedIn: vi.fn(), + listResourceGroups: vi.fn(), +})); + +import * as azureCli from "../azure-cli.js"; +import { listSubscriptionsTool } from "../tools/list-azure.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("azure_subscriptions_list", () => { + it("returns actionable az login guidance when NOT signed in (empty list + no session)", async () => { + vi.mocked(azureCli.listSubscriptions).mockResolvedValue([]); + vi.mocked(azureCli.isSignedIn).mockResolvedValue(false); + + const result = await listSubscriptionsTool.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("not signed in"); + expect(result.content[0].text).toContain("az login"); + // Must NOT claim there are simply no subscriptions for the signed-in user. + expect(result.content[0].text).not.toContain("for the signed-in user"); + }); + + it("reports an empty list ONLY when genuinely signed in with zero subscriptions", async () => { + vi.mocked(azureCli.listSubscriptions).mockResolvedValue([]); + vi.mocked(azureCli.isSignedIn).mockResolvedValue(true); + + const result = await listSubscriptionsTool.handler({}); + + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain("No enabled Azure subscriptions found for the signed-in user"); + }); + + it("lists subscriptions when signed in with subscriptions (no sign-in probe needed)", async () => { + vi.mocked(azureCli.listSubscriptions).mockResolvedValue([ + { id: "sub-1", name: "Contoso Prod", state: "Enabled", isDefault: true }, + { id: "sub-2", name: "Contoso Dev", state: "Enabled", isDefault: false }, + ]); + + const result = await listSubscriptionsTool.handler({}); + + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain("Azure Subscriptions (2)"); + expect(result.content[0].text).toContain("Contoso Prod"); + expect(result.content[0].text).toContain("sub-2"); + expect(azureCli.isSignedIn).not.toHaveBeenCalled(); + }); + + it("surfaces a clean error when the Azure CLI call fails", async () => { + vi.mocked(azureCli.listSubscriptions).mockRejectedValue(new Error("az not installed")); + + const result = await listSubscriptionsTool.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("az not installed"); + }); +}); diff --git a/src/tools/list-azure.ts b/src/tools/list-azure.ts new file mode 100644 index 0000000..e94761e --- /dev/null +++ b/src/tools/list-azure.ts @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tools: azure_subscriptions_list, azure_resource_groups_list + * + * Surface the developer's Azure subscriptions and resource groups so an agent + * (or the user via elicitation) can pick where SPE standard billing lands. + * Ports EVAL.md `list-azure-subscriptions` / `list-resource-groups`. + */ + +import { isSignedIn, listResourceGroups, listSubscriptions } from "../azure-cli.js"; +import type { McpTool } from "../types.js"; + +export const listSubscriptionsTool: McpTool = { + name: "azure_subscriptions_list", + annotations: { readOnly: true, localRequired: true }, + description: + "List the Azure subscriptions the signed-in user can access (via Azure CLI). " + + "Use this to choose a subscription for SharePoint Embedded standard billing.", + inputSchema: { type: "object" as const, properties: {} }, + handler: async () => { + try { + const subs = await listSubscriptions(); + if (subs.length === 0) { + // `az account list` returns [] with exit 0 both when the user is not + // signed in AND when they are signed in with zero subscriptions. Probe + // the sign-in state so we give the right guidance. + if (!(await isSignedIn())) { + return { + content: [ + { + type: "text" as const, + text: + "You're not signed in to the Azure CLI, so no subscriptions could be listed.\n\n" + + "Run `az login` (or `az login --allow-no-subscriptions` if your account has no " + + "subscriptions) and try again.", + }, + ], + isError: true, + }; + } + return { + content: [{ type: "text" as const, text: "No enabled Azure subscriptions found for the signed-in user." }], + }; + } + let output = `## Azure Subscriptions (${subs.length})\n\n| Name | Subscription ID | Default |\n|------|-----------------|---------|\n`; + for (const s of subs) { + output += `| ${s.name} | \`${s.id}\` | ${s.isDefault ? "✅" : ""} |\n`; + } + return { content: [{ type: "text" as const, text: output }] }; + } catch (error) { + const msg = error instanceof Error ? error.message : "Unknown error"; + return { content: [{ type: "text" as const, text: `Error listing subscriptions: ${msg}` }], isError: true }; + } + }, +}; + +export const listResourceGroupsTool: McpTool = { + name: "azure_resource_groups_list", + annotations: { readOnly: true, localRequired: true }, + description: + "List the resource groups in an Azure subscription (via Azure CLI). " + + "Use this to choose a resource group for SharePoint Embedded standard billing.", + inputSchema: { + type: "object" as const, + properties: { + subscriptionId: { + type: "string", + description: "The Azure subscription ID to list resource groups for.", + }, + }, + required: ["subscriptionId"], + }, + handler: async (args) => { + const subscriptionId = (args.subscriptionId as string | undefined)?.trim(); + if (!subscriptionId) { + return { content: [{ type: "text" as const, text: "Error: subscriptionId is required" }], isError: true }; + } + try { + const groups = await listResourceGroups(subscriptionId); + if (groups.length === 0) { + return { + content: [{ type: "text" as const, text: `No resource groups found in subscription \`${subscriptionId}\`. You can create one with \`az group create\`.` }], + }; + } + let output = `## Resource Groups (${groups.length})\n\n| Name | Location |\n|------|----------|\n`; + for (const g of groups) { + output += `| ${g.name} | ${g.location} |\n`; + } + return { content: [{ type: "text" as const, text: output }] }; + } catch (error) { + const msg = error instanceof Error ? error.message : "Unknown error"; + return { content: [{ type: "text" as const, text: `Error listing resource groups: ${msg}` }], isError: true }; + } + }, +}; diff --git a/src/tools/list-container-types.ts b/src/tools/list-container-types.ts new file mode 100644 index 0000000..151338d --- /dev/null +++ b/src/tools/list-container-types.ts @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tool: container_type_list + * + * Lists all SharePoint Embedded container types in the tenant. + * Useful for discovering existing container types before creating new ones, + * and for checking the 1:1 owning app relationship. + */ + +import { listContainerTypes } from "../graph-client.js"; +import { ok } from "../responses.js"; +import { clientSafeMessage } from "../errors.js"; +import { paginate, pageFooter, parsePageArgs } from "./pagination.js"; +import type { McpTool } from "../types.js"; + +export const listContainerTypesTool: McpTool = { + name: "container_type_list", + annotations: { readOnly: true }, + description: + "List all SharePoint Embedded container types in the tenant. " + + "Shows container type IDs, display names, owning applications, and billing classification. " + + "Use this to check existing container types before creating new ones " + + "(each owning app can have exactly one container type). " + + "Supports pagination via `top` (page size, max 200) and `skip` (offset).", + inputSchema: { + type: "object" as const, + properties: { + top: { + type: "number", + description: "Maximum container types to return in this page (default 50, max 200).", + }, + skip: { + type: "number", + description: "Number of container types to skip (offset). Use the nextToken/skip from a prior page to continue.", + }, + }, + }, + handler: async (args) => { + try { + const containerTypes = await listContainerTypes(); + + if (containerTypes.length === 0) { + return ok({ items: [], totalCount: 0, hasMore: false }, "No container types found in this tenant."); + } + + const pageArgs = parsePageArgs(args); + const page = paginate(containerTypes, pageArgs); + + let output = `## Container Types (${page.items.length})\n\n`; + output += `| Container Type ID | Display Name | Owning App | Billing |\n`; + output += `|-------------------|-------------|------------|----------|\n`; + for (const ct of page.items) { + output += `| \`${ct.containerTypeId}\` | ${ct.displayName ?? "—"} | \`${ct.owningAppId ?? "—"}\` | ${ct.billingClassification ?? "—"} |\n`; + } + output += pageFooter(page, pageArgs.skip); + + return ok(page, output); + } catch (error) { + return { + content: [{ type: "text" as const, text: `Error listing container types: ${clientSafeMessage(error)}` }], + isError: true, + }; + } + }, +}; diff --git a/src/tools/list-containers.ts b/src/tools/list-containers.ts new file mode 100644 index 0000000..b52301e --- /dev/null +++ b/src/tools/list-containers.ts @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tool: container_list + * + * Lists all containers for a given container type. + */ + +import { listContainers } from "../graph-client.js"; +import { fail, ok } from "../responses.js"; +import { paginate, pageFooter, parsePageArgs } from "./pagination.js"; +import { readState } from "../state.js"; +import type { McpTool } from "../types.js"; + +export const listContainersTool: McpTool = { + name: "container_list", + annotations: { readOnly: true }, + description: + "List SharePoint Embedded containers for a container type. " + + "Use this when you need to discover existing containers or look up a container ID. " + + "Returns container IDs, names, status, and creation dates. " + + "Defaults to the container type from the current provisioning state when none is given. " + + "Supports pagination via `top` (page size, max 200) and `skip` (offset).", + inputSchema: { + type: "object" as const, + properties: { + containerTypeId: { + type: "string", + description: + "The container type ID (GUID) to list containers for. Defaults to the provisioned container type in state.", + }, + top: { + type: "number", + description: "Maximum containers to return in this page (default 50, max 200).", + }, + skip: { + type: "number", + description: "Number of containers to skip (offset). Use the nextToken/skip from a prior page to continue.", + }, + }, + }, + handler: async (args) => { + const containerTypeId = (args.containerTypeId as string) ?? readState().containerTypeId; + if (!containerTypeId) { + return fail( + "INVALID_ARGS", + "containerTypeId is required (none provided and none in provisioning state).", + "Provision an SPE app first (project_provision) or pass a containerTypeId.", + ); + } + + const containers = await listContainers(containerTypeId); + + if (containers.length === 0) { + return ok( + { items: [], totalCount: 0, hasMore: false, containerTypeId }, + `No containers found for container type ${containerTypeId}.`, + ); + } + + const pageArgs = parsePageArgs(args); + const page = paginate(containers, pageArgs); + + let output = `## Containers (${page.items.length})\n\n`; + output += `| Container ID | Display Name | Status | Created |\n`; + output += `|-------------|-------------|--------|----------|\n`; + for (const c of page.items) { + output += `| \`${c.id}\` | ${c.displayName ?? "—"} | ${c.status ?? "—"} | ${c.createdDateTime ?? "—"} |\n`; + } + output += pageFooter(page, pageArgs.skip); + + return ok({ ...page, containerTypeId }, output); + }, +}; diff --git a/src/tools/list-deleted-containers.ts b/src/tools/list-deleted-containers.ts new file mode 100644 index 0000000..7824f06 --- /dev/null +++ b/src/tools/list-deleted-containers.ts @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tool: container_deleted_list + * + * List soft-deleted containers in the tenant recycle bin (Microsoft Graph v1.0: + * GET /storage/fileStorage/deletedContainers), optionally filtered by container + * type. Soft-deleted containers stay recoverable for 93 days and remain + * "blockers": a container type registration cannot be deleted while any deleted + * container still exists. This tool makes the recycle bin visible so those + * blockers can be found and permanently purged (container_delete → + * permanent-delete) or restored (container_archive_restore / restore). + */ + +import { setAuthConfig } from "../auth.js"; +import { listContainerTypeRegistrations, listDeletedContainers } from "../graph-client.js"; +import { ok, fail } from "../responses.js"; +import { clientSafeMessage } from "../errors.js"; +import { paginate, pageFooter, parsePageArgs } from "./pagination.js"; +import { readState } from "../state.js"; +import type { Container, McpTool } from "../types.js"; + +export const listDeletedContainersTool: McpTool = { + name: "container_deleted_list", + annotations: { readOnly: true }, + description: + "List soft-deleted SharePoint Embedded containers in the tenant recycle bin (recoverable for 93 days). " + + "Use this when a container type or its registration can't be deleted due to recycle-bin containers, or to " + + "find a soft-deleted container to restore or permanently purge. Optionally filter by container type. " + + "Supports pagination via `top` and `skip`.", + inputSchema: { + type: "object" as const, + properties: { + containerTypeId: { + type: "string", + description: "Filter to deleted containers of this container type. Default: the provisioned container type, or all if none.", + }, + allContainerTypes: { + type: "boolean", + description: "List deleted containers across ALL container types (ignore the provisioned default). Default: false.", + }, + top: { type: "number", description: "Maximum results to return in this page (default 50, max 200)." }, + skip: { type: "number", description: "Number of results to skip (offset)." }, + }, + }, + handler: async (args) => { + const state = readState(); + if (state.appId && state.tenantId) { + setAuthConfig({ clientId: state.appId, tenantId: state.tenantId }); + } + + const explicitCt = (args.containerTypeId as string) || undefined; + const allContainerTypes = args.allContainerTypes === true; + + // The Graph deletedContainers endpoint REQUIRES a containerTypeId filter, so + // there is no single "all" call. For allContainerTypes we fan out: list the + // tenant's registrations and query each container type's recycle bin. + let deleted: Container[]; + try { + if (allContainerTypes) { + const regs = await listContainerTypeRegistrations(); + const ids = regs.map((r) => r.id).filter((id): id is string => !!id); + const perType = await Promise.all(ids.map((id) => listDeletedContainers(id))); + deleted = perType.flat(); + } else { + const filterCt = explicitCt || state.containerTypeId; + if (!filterCt) { + return fail( + "INVALID_ARGS", + "a containerTypeId is required (the recycle-bin API is per-container-type and none is provisioned).", + "Pass containerTypeId, or set allContainerTypes=true to scan every container type.", + ); + } + deleted = await listDeletedContainers(filterCt); + } + } catch (e) { + return fail("UPSTREAM", `listing deleted containers: ${clientSafeMessage(e)}`); + } + + const scopeCt = allContainerTypes ? undefined : explicitCt || state.containerTypeId; + + if (deleted.length === 0) { + const scope = scopeCt ? ` for container type \`${scopeCt}\`` : ""; + return ok( + { items: [], totalCount: 0, hasMore: false, containerTypeId: scopeCt }, + `No soft-deleted containers in the recycle bin${scope}.`, + ); + } + + const pageArgs = parsePageArgs(args); + const page = paginate(deleted, pageArgs); + + let output = `## Deleted Containers (recycle bin) — ${page.items.length}\n\n`; + output += `| Container ID | Display Name | Container Type | Deleted |\n`; + output += `|-------------|-------------|----------------|----------|\n`; + for (const c of page.items) { + output += `| \`${c.id}\` | ${c.displayName ?? "—"} | \`${c.containerTypeId ?? "—"}\` | ${c.createdDateTime ?? "—"} |\n`; + } + output += pageFooter(page, pageArgs.skip); + output += + "\n\n> Permanently purge a blocker with `container_delete` (action `permanent-delete`, `confirm=true`), " + + "or recover it with `container_delete` (action `restore`)."; + + return ok({ ...page, containerTypeId: scopeCt }, output); + }, +}; diff --git a/src/tools/manage-permissions.test.ts b/src/tools/manage-permissions.test.ts new file mode 100644 index 0000000..c22437d --- /dev/null +++ b/src/tools/manage-permissions.test.ts @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for container_permissions_manage, focused on role validation. + * + * Regression: the handler previously defaulted a missing/invalid `role` to + * "writer" and passed arbitrary strings straight to Graph. It must now reject + * invalid or missing roles for add/update with a clear validation-error + * envelope and must never silently default. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../graph-client.js", () => ({ + addContainerPermission: vi.fn(), + updateContainerPermission: vi.fn(), + removeContainerPermission: vi.fn(), +})); + +import * as graph from "../graph-client.js"; +import { managePermissionsTool } from "../tools/manage-permissions.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("container_permissions_manage role validation", () => { + it("rejects add with an invalid role and does not call Graph", async () => { + const r = await managePermissionsTool.handler({ + containerId: "c1", action: "add", userPrincipalName: "user@contoso.com", role: "admin", + }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("invalid role"); + expect(r.content[0].text).toContain("reader, writer, manager, owner"); + expect(graph.addContainerPermission).not.toHaveBeenCalled(); + }); + + it("rejects add with a missing role (no silent default to writer)", async () => { + const r = await managePermissionsTool.handler({ + containerId: "c1", action: "add", userPrincipalName: "user@contoso.com", + }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("missing"); + expect(graph.addContainerPermission).not.toHaveBeenCalled(); + }); + + it("rejects update with an invalid role and does not call Graph", async () => { + const r = await managePermissionsTool.handler({ + containerId: "c1", action: "update", permissionId: "p1", role: "superuser", + }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("invalid role"); + expect(graph.updateContainerPermission).not.toHaveBeenCalled(); + }); + + it("rejects update with a missing role", async () => { + const r = await managePermissionsTool.handler({ + containerId: "c1", action: "update", permissionId: "p1", + }); + expect(r.isError).toBe(true); + expect(graph.updateContainerPermission).not.toHaveBeenCalled(); + }); + + it.each(["reader", "writer", "manager", "owner"])( + "accepts valid role '%s' for add", + async (role) => { + vi.mocked(graph.addContainerPermission).mockResolvedValue({ id: "perm1", roles: [role] }); + const r = await managePermissionsTool.handler({ + containerId: "c1", action: "add", userPrincipalName: "user@contoso.com", role, + }); + expect(r.isError).toBeFalsy(); + expect(graph.addContainerPermission).toHaveBeenCalledWith("c1", "user@contoso.com", role); + expect(r.content[0].text).toContain(role); + }, + ); + + it("passes a validated role through on update", async () => { + vi.mocked(graph.updateContainerPermission).mockResolvedValue(undefined); + const r = await managePermissionsTool.handler({ + containerId: "c1", action: "update", permissionId: "p1", role: "manager", + }); + expect(r.isError).toBeFalsy(); + expect(graph.updateContainerPermission).toHaveBeenCalledWith("c1", "p1", "manager"); + expect(r.content[0].text).toContain("manager"); + }); + + it("does not require a role for remove", async () => { + vi.mocked(graph.removeContainerPermission).mockResolvedValue(undefined); + const r = await managePermissionsTool.handler({ + containerId: "c1", action: "remove", permissionId: "p1", + }); + expect(r.isError).toBeFalsy(); + expect(graph.removeContainerPermission).toHaveBeenCalledWith("c1", "p1"); + }); + + it("still validates containerId/action before role", async () => { + const r = await managePermissionsTool.handler({ action: "add", role: "reader" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("containerId and action are required"); + }); +}); diff --git a/src/tools/manage-permissions.ts b/src/tools/manage-permissions.ts new file mode 100644 index 0000000..b304645 --- /dev/null +++ b/src/tools/manage-permissions.ts @@ -0,0 +1,174 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tool: container_permissions_manage + * + * Add, update, or remove user permissions on a container. + */ + +import { + addContainerPermission, + removeContainerPermission, + updateContainerPermission, +} from "../graph-client.js"; +import type { McpTool, McpToolResult } from "../types.js"; + +/** Roles accepted by the SPE container permissions API. Keep in sync with inputSchema.role.enum. */ +const VALID_ROLES = ["reader", "writer", "manager", "owner"] as const; +type PermissionRole = (typeof VALID_ROLES)[number]; + +function isValidRole(value: unknown): value is PermissionRole { + return typeof value === "string" && (VALID_ROLES as readonly string[]).includes(value); +} + +/** + * Validate the `role` input for actions that change a user's role (add/update). + * Returns an error envelope when the role is missing or not one of VALID_ROLES, + * or `null` when the role is valid. + */ +function roleValidationError(action: string, role: unknown): McpToolResult | null { + if (isValidRole(role)) { + return null; + } + const isMissing = role === undefined || role === null || role === ""; + const reason = isMissing + ? `role is required for ${action} (missing)` + : `invalid role '${String(role)}'`; + return { + content: [{ + type: "text", + text: + `Error: ${reason}. The 'role' parameter is required for ` + + `action '${action}' and must be one of: ${VALID_ROLES.join(", ")}.`, + }], + isError: true, + }; +} + +export const managePermissionsTool: McpTool = { + name: "container_permissions_manage", + annotations: { destructive: true, plane: "control" }, + description: + "Add, update, or remove user permissions on a SharePoint Embedded container. " + + "Valid roles: reader, writer, manager, owner.", + inputSchema: { + type: "object" as const, + properties: { + containerId: { + type: "string", + description: "The container ID.", + }, + action: { + type: "string", + enum: ["add", "update", "remove"], + description: "The permission action to perform.", + }, + userPrincipalName: { + type: "string", + description: "User's UPN (e.g., user@contoso.com). Required for 'add'.", + }, + role: { + type: "string", + enum: ["reader", "writer", "manager", "owner"], + description: "Permission role. Required for 'add' and 'update'.", + }, + permissionId: { + type: "string", + description: "Permission ID. Required for 'update' and 'remove'. Use container_get to find IDs.", + }, + }, + required: ["containerId", "action"], + }, + handler: async (args) => { + const containerId = args.containerId as string; + const action = args.action as string; + const userPrincipalName = args.userPrincipalName as string | undefined; + const role = args.role; + const permissionId = args.permissionId as string | undefined; + + if (!containerId || !action) { + return { + content: [{ type: "text", text: "Error: containerId and action are required" }], + isError: true, + }; + } + + switch (action) { + case "add": { + if (!userPrincipalName) { + return { + content: [{ type: "text", text: "Error: userPrincipalName is required for add" }], + isError: true, + }; + } + // Role must be explicitly provided and valid — never silently default + // to a role (e.g. writer), which could grant broader access than intended. + const roleErr = roleValidationError("add", role); + if (roleErr) return roleErr; + const roleValue = role as PermissionRole; + try { + const result = await addContainerPermission(containerId, userPrincipalName, roleValue); + return { + content: [{ + type: "text", + text: `Permission added: ${userPrincipalName} = ${roleValue} (ID: \`${result.id}\`)`, + }], + }; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (msg.includes("409") || msg.toLowerCase().includes("conflict") || msg.toLowerCase().includes("already")) { + return { + content: [{ + type: "text", + text: `User ${userPrincipalName} already has permissions. Use action 'update' with the permissionId to change the role.`, + }], + }; + } + throw error; + } + } + + case "update": { + if (!permissionId) { + return { + content: [{ type: "text", text: "Error: permissionId is required for update" }], + isError: true, + }; + } + const roleErr = roleValidationError("update", role); + if (roleErr) return roleErr; + const roleValue = role as PermissionRole; + await updateContainerPermission(containerId, permissionId, roleValue); + return { + content: [{ + type: "text", + text: `Permission ${permissionId} updated to role: ${roleValue}`, + }], + }; + } + + case "remove": { + if (!permissionId) { + return { + content: [{ type: "text", text: "Error: permissionId is required for remove" }], + isError: true, + }; + } + await removeContainerPermission(containerId, permissionId); + return { + content: [{ + type: "text", + text: `Permission ${permissionId} removed from container ${containerId}`, + }], + }; + } + + default: + return { + content: [{ type: "text", text: `Unknown action: ${action}. Use add, update, or remove.` }], + isError: true, + }; + } + }, +}; diff --git a/src/tools/manage-sharing.ts b/src/tools/manage-sharing.ts new file mode 100644 index 0000000..e17a307 --- /dev/null +++ b/src/tools/manage-sharing.ts @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tool: content_sharing_manage + * + * Create, list, or revoke sharing links for files. + */ + +import { + createSharingLink, + getContainerDrive, + getDriveItem, + listDriveItemPermissions, + revokeSharingLink, +} from "../graph-client.js"; +import { requireContentAccess } from "./content-access.js"; +import type { McpTool } from "../types.js"; + +export const manageSharingTool: McpTool = { + name: "content_sharing_manage", + annotations: { plane: "content", requiresConsent: true }, + description: + "Create, list, or revoke sharing links for files in a SharePoint Embedded container.", + inputSchema: { + type: "object" as const, + properties: { + containerId: { + type: "string", + description: "The container ID.", + }, + filePath: { + type: "string", + description: "Path to the file (e.g., 'report.pdf').", + }, + action: { + type: "string", + enum: ["create", "list", "revoke"], + description: "The sharing action.", + }, + linkType: { + type: "string", + enum: ["view", "edit"], + description: "For 'create': link type. Default: 'view'.", + }, + linkScope: { + type: "string", + enum: ["anonymous", "organization", "users"], + description: "For 'create': link scope. Default: 'organization'.", + }, + permissionId: { + type: "string", + description: "For 'revoke': the permission ID to remove.", + }, + }, + required: ["containerId", "filePath", "action"], + }, + handler: async (args) => { + const gate = requireContentAccess(); + if (gate) return gate; + + const containerId = args.containerId as string; + const filePath = args.filePath as string; + const action = args.action as string; + const linkType = (args.linkType as string) ?? "view"; + const linkScope = (args.linkScope as string) ?? "organization"; + const permissionId = args.permissionId as string | undefined; + + if (!containerId || !filePath || !action) { + return { + content: [{ type: "text", text: "Error: containerId, filePath, and action are required" }], + isError: true, + }; + } + + const drive = await getContainerDrive(containerId); + const itemPath = filePath.startsWith("/") ? filePath : `/${filePath}`; + const item = await getDriveItem(drive.id, itemPath); + + switch (action) { + case "create": { + const link = await createSharingLink(drive.id, item.id, linkType, linkScope); + return { + content: [{ + type: "text", + text: `Sharing link created for "${item.name}":\n` + + `- **Type:** ${linkType}\n` + + `- **Scope:** ${linkScope}\n` + + `- **URL:** ${link.link?.webUrl ?? "(no URL)"}\n` + + `- **Permission ID:** \`${link.id}\``, + }], + }; + } + + case "list": { + const perms = await listDriveItemPermissions(drive.id, item.id); + const links = perms.filter(p => p.link); + if (links.length === 0) { + return { + content: [{ type: "text", text: `No sharing links found for "${item.name}".` }], + }; + } + let output = `## Sharing Links for "${item.name}" (${links.length})\n\n`; + output += `| ID | Type | Scope | URL |\n|----|------|-------|-----|\n`; + for (const l of links) { + output += `| \`${l.id}\` | ${l.link?.type ?? "—"} | ${l.link?.scope ?? "—"} | ${l.link?.webUrl ?? "—"} |\n`; + } + return { content: [{ type: "text", text: output }] }; + } + + case "revoke": { + if (!permissionId) { + return { + content: [{ type: "text", text: "Error: permissionId is required for revoke" }], + isError: true, + }; + } + await revokeSharingLink(drive.id, item.id, permissionId); + return { + content: [{ + type: "text", + text: `Sharing link ${permissionId} revoked from "${item.name}".`, + }], + }; + } + + default: + return { + content: [{ type: "text", text: `Unknown action: ${action}` }], + isError: true, + }; + } + }, +}; diff --git a/src/tools/orchestration.test.ts b/src/tools/orchestration.test.ts new file mode 100644 index 0000000..3873628 --- /dev/null +++ b/src/tools/orchestration.test.ts @@ -0,0 +1,586 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for Phase 2/3/5 tools: provision orchestrator, scaffold, hydrate + * config, content access, and cleanup. External effects (Graph, az, MSAL, fs) + * are mocked so these run offline. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mkdtempSync, existsSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// ── Mocks ──────────────────────────────────────────────────────────────────── +vi.mock("../graph-client.js", () => ({ + findApplicationByName: vi.fn(), + findApplicationByAppId: vi.fn(), + createApplication: vi.fn(), + addSpePermissions: vi.fn(), + createContainerType: vi.fn(), + listContainerTypes: vi.fn(async () => []), + registerContainerType: vi.fn(), + createContainer: vi.fn(), + activateContainer: vi.fn(), + deleteContainerType: vi.fn(), + deleteContainerTypeRegistration: vi.fn(), + listContainers: vi.fn(async () => []), + listDeletedContainers: vi.fn(async () => []), + deleteApplication: vi.fn(), + getSignedInUser: vi.fn(async () => ({ id: "user-1", userPrincipalName: "admin@x.com" })), + grantContainerTypeOwner: vi.fn(async () => ({ id: "perm-1", roles: ["owner"] })), +})); +vi.mock("../bootstrap.js", () => ({ + bootstrapTokenProvider: vi.fn(async () => "boot"), + getSignedInIdentity: vi.fn(async () => ({ tenantId: "t-1", username: "dev@x.com" })), +})); +vi.mock("../azure-cli.js", async (importActual) => ({ + // Keep the REAL pure region helpers (isSyntexRegionSupported / + // assertSyntexRegionSupported) so the pre-flight region validation runs for + // real in tests; only the az-shelling functions are mocked. + ...(await importActual()), + ensureSyntexProviderRegistered: vi.fn(async () => ({ namespace: "Microsoft.Syntex", registrationState: "Registered" })), + createSyntexAccount: vi.fn(async () => "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Syntex/accounts/acc-1"), + getSyntexAccounts: vi.fn(async () => []), + // Guided standard-billing sub/RG selection (PR #3 review) lists these inline; + // default to empty and let each test set the shape it needs. + listSubscriptions: vi.fn(async () => []), + listResourceGroups: vi.fn(async () => []), +})); +vi.mock("../auth.js", () => ({ setAuthConfig: vi.fn() })); + +const stateStore: Record = {}; +vi.mock("../state.js", () => ({ + readState: vi.fn(() => ({ ...stateStore })), + writeState: vi.fn((p: Record) => { Object.assign(stateStore, p); return { ...stateStore }; }), + clearState: vi.fn(() => { for (const k of Object.keys(stateStore)) delete stateStore[k]; }), +})); + +import * as graph from "../graph-client.js"; +import * as azureCli from "../azure-cli.js"; +import * as bootstrap from "../bootstrap.js"; +import { provisionTool } from "../tools/provision.js"; +import { getSessionId } from "../session.js"; +import { scaffoldTool } from "../tools/scaffold.js"; +import { hydrateConfigTool } from "../tools/hydrate-config.js"; +import { grantContentAccessTool, revokeContentAccessTool, isContentAccessGranted } from "../tools/content-access.js"; +import { cleanupTool } from "../tools/cleanup.js"; + +beforeEach(() => { + vi.clearAllMocks(); + for (const k of Object.keys(stateStore)) delete stateStore[k]; + // Reset the guided sub/RG listings to their empty defaults each test — + // vi.clearAllMocks() clears call history but NOT mockResolvedValue + // implementations, so without this a prior test's shape would leak forward. + vi.mocked(azureCli.listSubscriptions).mockResolvedValue([]); + vi.mocked(azureCli.listResourceGroups).mockResolvedValue([]); +}); + +// ── project_provision ───────────────────────────────────────────────────────────── + +describe("project_provision", () => { + // Least-privilege intent (PR #3 review) is settled here so the ownerScope gate + // is a resumable no-op for these end-to-end chains; the gate's own + // elicit/persist behavior is covered separately in provision-prompt.test.ts. + beforeEach(() => { + stateStore.ownerScope = "selected"; + }); + + it("elicits billing model when not provided", async () => { + const r = await provisionTool.handler({ appDisplayName: "App" }); + expect(r.content[0].text).toContain("billing model"); + expect(graph.createApplication).not.toHaveBeenCalled(); + }); + + it("guides subscription selection inline (fallback) when standard billing lacks a subscription", async () => { + // PR #3 review: instead of punting to azure_subscriptions_list + a manual + // re-invoke, the tool lists the subscriptions itself and (with >1) asks the + // user to pick. No native elicitation is wired in tests, so elicitChoice + // degrades to the agent-guided ask keyed on `azureSubscriptionId`. + vi.mocked(azureCli.listSubscriptions).mockResolvedValue([ + { id: "sub-a", name: "Sub A", state: "Enabled" }, + { id: "sub-b", name: "Sub B", state: "Enabled" }, + ]); + + const r = await provisionTool.handler({ appDisplayName: "App", billingClassification: "standard" }); + + // The tool ran the listing and surfaced a subscription choice (not the old + // "run azure_subscriptions_list yourself" punt), and created nothing yet. + expect(azureCli.listSubscriptions).toHaveBeenCalled(); + expect(r.content[0].text).toContain("azureSubscriptionId=sub-a"); + expect(r.content[0].text).toContain("azureSubscriptionId=sub-b"); + expect(r.content[0].text).not.toContain("azure_subscriptions_list"); + expect(graph.createApplication).not.toHaveBeenCalled(); + }); + + it("guides resource-group selection inline (fallback) once a subscription is known", async () => { + // With the subscription supplied, the tool lists resource groups WITHIN it + // and asks the user to pick (agent-guided fallback keyed on `resourceGroup`). + vi.mocked(azureCli.listResourceGroups).mockResolvedValue([ + { name: "rg-x", location: "eastus", id: "/subscriptions/sub-1/resourceGroups/rg-x" }, + { name: "rg-y", location: "westus", id: "/subscriptions/sub-1/resourceGroups/rg-y" }, + ]); + + const r = await provisionTool.handler({ appDisplayName: "App", billingClassification: "standard", azureSubscriptionId: "sub-1" }); + + expect(azureCli.listResourceGroups).toHaveBeenCalledWith("sub-1"); + expect(r.content[0].text).toContain("resourceGroup=rg-x"); + expect(r.content[0].text).toContain("resourceGroup=rg-y"); + expect(graph.createApplication).not.toHaveBeenCalled(); + }); + + it("runs the full trial chain: app → CT → register → container", async () => { + vi.mocked(graph.findApplicationByName).mockResolvedValue(null); + vi.mocked(graph.createApplication).mockResolvedValue({ appId: "app-1", objectId: "obj-1", displayName: "App" }); + vi.mocked(graph.createContainerType).mockResolvedValue({ containerTypeId: "ct-1", owningAppId: "app-1", displayName: "App Container Type" }); + vi.mocked(graph.createContainer).mockResolvedValue({ id: "c-1", displayName: "Default Container", containerTypeId: "ct-1", status: "inactive" }); + + const r = await provisionTool.handler({ appDisplayName: "App", billingClassification: "trial" }); + + expect(graph.createApplication).toHaveBeenCalled(); + expect(graph.createContainerType).toHaveBeenCalled(); + // Full-setup registers WITHOUT app-only permissions (exactly 2 args) — the + // real registerContainerType then defaults applicationPermissions to ["none"] + // (PR #3 review: least privilege; opt in to ["full"] only for daemon apps). + expect(graph.registerContainerType).toHaveBeenCalledWith("ct-1", "app-1"); + expect(graph.grantContainerTypeOwner).toHaveBeenCalledWith("ct-1", "user-1"); + expect(graph.createContainer).toHaveBeenCalledWith("ct-1", "Default Container"); + expect(graph.activateContainer).toHaveBeenCalledWith("c-1"); + expect(r.content[0].text).toContain("SPE Provisioned"); + expect(r.content[0].text).toContain("app-1"); + expect(stateStore.containerId).toBe("c-1"); + }); + + it("appends a NON-BLOCKING guest heads-up when signed in as a B2B guest — provisioning is NOT blocked (PR #3 review)", async () => { + vi.mocked(bootstrap.getSignedInIdentity).mockResolvedValueOnce({ + tenantId: "t-1", + username: "alice_corp.com#EXT#@resourcetenant.onmicrosoft.com", + }); + vi.mocked(graph.findApplicationByName).mockResolvedValue(null); + vi.mocked(graph.createApplication).mockResolvedValue({ appId: "app-1", objectId: "obj-1", displayName: "App" }); + vi.mocked(graph.createContainerType).mockResolvedValue({ containerTypeId: "ct-1", owningAppId: "app-1", displayName: "App Container Type" }); + vi.mocked(graph.createContainer).mockResolvedValue({ id: "c-1", displayName: "Default Container", containerTypeId: "ct-1", status: "inactive" }); + + const r = await provisionTool.handler({ appDisplayName: "App", billingClassification: "trial" }); + + // Not blocked: the full chain still runs and provisioning completes. + expect(r.isError).toBeFalsy(); + expect(graph.createApplication).toHaveBeenCalled(); + expect(graph.createContainer).toHaveBeenCalledWith("ct-1", "Default Container"); + expect(r.content[0].text).toContain("SPE Provisioned"); + // The informational note is present. + expect(r.content[0].text).toContain("guest (B2B)"); + expect(r.content[0].text).toContain("Heads-up"); + }); + + it("does NOT append the guest note for a member identity", async () => { + vi.mocked(graph.findApplicationByName).mockResolvedValue(null); + vi.mocked(graph.createApplication).mockResolvedValue({ appId: "app-1", objectId: "obj-1", displayName: "App" }); + vi.mocked(graph.createContainerType).mockResolvedValue({ containerTypeId: "ct-1", owningAppId: "app-1", displayName: "App Container Type" }); + vi.mocked(graph.createContainer).mockResolvedValue({ id: "c-1", displayName: "Default Container", containerTypeId: "ct-1", status: "inactive" }); + + // Default bootstrap mock signs in as the member `dev@x.com`. + const r = await provisionTool.handler({ appDisplayName: "App", billingClassification: "trial" }); + + expect(r.content[0].text).toContain("SPE Provisioned"); + expect(r.content[0].text).not.toContain("guest (B2B)"); + expect(r.content[0].text).not.toContain("Heads-up"); + }); + + it("runs the standard chain: app -> CT(standard) -> RP -> Syntex account -> register -> container", async () => { + vi.mocked(graph.findApplicationByName).mockResolvedValue(null); + vi.mocked(graph.createApplication).mockResolvedValue({ appId: "app-1", objectId: "obj-1", displayName: "App" }); + vi.mocked(graph.createContainerType).mockResolvedValue({ containerTypeId: "ct-1", owningAppId: "app-1", displayName: "App Container Type", billingClassification: "standard" }); + vi.mocked(graph.createContainer).mockResolvedValue({ id: "c-1", displayName: "Default Container", containerTypeId: "ct-1", status: "active" }); + + const r = await provisionTool.handler({ appDisplayName: "App", billingClassification: "standard", azureSubscriptionId: "sub-1", resourceGroup: "rg-1", region: "eastus", confirmBilling: true }); + + expect(azureCli.ensureSyntexProviderRegistered).toHaveBeenCalledWith("sub-1"); + expect(azureCli.createSyntexAccount).toHaveBeenCalledWith("sub-1", "rg-1", "eastus", "ct-1"); + expect(graph.registerContainerType).toHaveBeenCalledWith("ct-1", "app-1"); + expect(r.content[0].text).toContain("SPE Provisioned"); + expect(stateStore.syntexAccountResourceId).toBe("/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Syntex/accounts/acc-1"); + }); + + // Financial-safety gate (per PR #3 review): standard billing must not create the + // chargeable Microsoft.Syntex account without explicit confirmBilling=true. + it("requires confirmBilling before the chargeable standard path — preview only, nothing created", async () => { + vi.mocked(graph.findApplicationByName).mockResolvedValue(null); + + const r = await provisionTool.handler({ appDisplayName: "App", billingClassification: "standard", azureSubscriptionId: "sub-1", resourceGroup: "rg-1", region: "eastus" }); + + expect(r.content[0].text).toContain("confirmBilling=true"); + expect(r.content[0].text).toContain("sub-1"); + // No owning app, container type, or billing account created without confirmation. + expect(graph.createApplication).not.toHaveBeenCalled(); + expect(graph.createContainerType).not.toHaveBeenCalled(); + expect(azureCli.createSyntexAccount).not.toHaveBeenCalled(); + }); + + it("skips the billing confirmation on a genuine same-target resume and creates NO new billing account", async () => { + // A true resume: same remembered app + same subscription/RG, and a Syntex + // account already exists for the reused container type. + stateStore.appId = "app-1"; + stateStore.appDisplayName = "App"; + stateStore.azureSubscriptionId = "sub-1"; + stateStore.resourceGroup = "rg-1"; + stateStore.containerTypeId = "ct-1"; + stateStore.syntexAccountResourceId = "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Syntex/accounts/acc-1"; + stateStore.confirmedSessionId = getSessionId(); // context already confirmed → app gate does not fire + vi.mocked(graph.findApplicationByName).mockResolvedValueOnce({ appId: "app-1", objectId: "obj-1", displayName: "App" }); + vi.mocked(graph.listContainerTypes).mockResolvedValueOnce([{ containerTypeId: "ct-1", owningAppId: "app-1", displayName: "App Container Type", billingClassification: "standard" }]); + vi.mocked(azureCli.getSyntexAccounts).mockResolvedValueOnce([ + { id: "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Syntex/accounts/acc-1", properties: { identityId: "ct-1", provisioningState: "Succeeded" } }, + ]); + vi.mocked(graph.createContainer).mockResolvedValueOnce({ id: "c-1", displayName: "Default Container", containerTypeId: "ct-1", status: "active" }); + + const r = await provisionTool.handler({ appDisplayName: "App", billingClassification: "standard", azureSubscriptionId: "sub-1", resourceGroup: "rg-1", region: "eastus" }); + + // Already-configured, same-target billing must not re-prompt, and must NOT + // create a new chargeable account (it reuses the existing one). + expect(r.content[0].text).not.toContain("Confirm standard (paid) billing"); + expect(azureCli.createSyntexAccount).not.toHaveBeenCalled(); + expect(graph.createContainerType).not.toHaveBeenCalled(); + }); + + it("still requires confirmBilling when a stale Syntex id belongs to a DIFFERENT app/target (no silent charge)", async () => { + // Stale scalar from a previous, unrelated standard build must NOT wave a new + // chargeable account through — this is the financial-safety regression guard. + stateStore.syntexAccountResourceId = "/subscriptions/old-sub/resourceGroups/old-rg/providers/Microsoft.Syntex/accounts/acc-old"; + stateStore.appDisplayName = "OldApp"; + stateStore.azureSubscriptionId = "old-sub"; + stateStore.resourceGroup = "old-rg"; + vi.mocked(graph.findApplicationByName).mockResolvedValue(null); + + const r = await provisionTool.handler({ appDisplayName: "NewApp", appSelection: "new", billingClassification: "standard", azureSubscriptionId: "sub-2", resourceGroup: "rg-2", region: "eastus" }); + + expect(r.content[0].text).toContain("confirmBilling=true"); + expect(graph.createApplication).not.toHaveBeenCalled(); + expect(graph.createContainerType).not.toHaveBeenCalled(); + expect(azureCli.createSyntexAccount).not.toHaveBeenCalled(); + }); + + it("rejects an unsupported standard billing region BEFORE creating anything (no orphaned CT)", async () => { + // Regression: 'westus2' previously passed pre-flight, created a standard CT, + // then failed at billing-account creation — and the CT could not be rolled + // back ("Cannot delete container type for non trial"), stranding an orphan. + // The region must be validated up front so nothing is created. + vi.mocked(graph.findApplicationByName).mockResolvedValue(null); + + const r = await provisionTool.handler({ appDisplayName: "App", billingClassification: "standard", azureSubscriptionId: "sub-1", resourceGroup: "rg-1", region: "westus2", confirmBilling: true }); + + expect(r.isError).toBe(true); + expect(r.content[0].text).toMatch(/not available for Microsoft\.Syntex/i); + // Nothing was created — no app, no container type, no billing account. + expect(graph.createApplication).not.toHaveBeenCalled(); + expect(graph.createContainerType).not.toHaveBeenCalled(); + expect(azureCli.createSyntexAccount).not.toHaveBeenCalled(); + }); + + it("rolls back a just-created standard CT when the Syntex billing account fails", async () => { + vi.mocked(graph.findApplicationByName).mockResolvedValue(null); + vi.mocked(graph.createApplication).mockResolvedValue({ appId: "app-1", objectId: "obj-1", displayName: "App" }); + vi.mocked(graph.createContainerType).mockResolvedValue({ containerTypeId: "ct-1", owningAppId: "app-1", displayName: "App Container Type", billingClassification: "standard" }); + vi.mocked(azureCli.createSyntexAccount).mockRejectedValueOnce(new Error("ARM 409")); + + const r = await provisionTool.handler({ appDisplayName: "App", billingClassification: "standard", azureSubscriptionId: "sub-1", resourceGroup: "rg-1", region: "eastus", confirmBilling: true }); + + expect(graph.deleteContainerType).toHaveBeenCalledWith("ct-1"); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("rolled back"); + expect(graph.registerContainerType).not.toHaveBeenCalled(); + }); + + it("asks before reusing a remembered app (no appDisplayName/appSelection)", async () => { + stateStore.appId = "remembered-app"; + stateStore.appDisplayName = "Remembered App"; + + const r = await provisionTool.handler({ billingClassification: "trial" }); + + // The app gate precedes app resolution and billing — it just asks. + expect(r.content[0].text).toContain("Reuse"); + expect(r.content[0].text).toContain("appSelection=reuse"); + expect(graph.findApplicationByAppId).not.toHaveBeenCalled(); + expect(graph.createApplication).not.toHaveBeenCalled(); + }); + + it("reuses the remembered app when appSelection='reuse'", async () => { + stateStore.appId = "remembered-app"; + stateStore.appDisplayName = "Remembered App"; + vi.mocked(graph.findApplicationByAppId).mockResolvedValue({ appId: "remembered-app", objectId: "obj-r", displayName: "Remembered App" }); + vi.mocked(graph.createContainerType).mockResolvedValue({ containerTypeId: "ct-1", owningAppId: "remembered-app", displayName: "Remembered App Container Type" }); + vi.mocked(graph.createContainer).mockResolvedValue({ id: "c-1", displayName: "Default Container", containerTypeId: "ct-1", status: "active" }); + + const r = await provisionTool.handler({ billingClassification: "trial", appSelection: "reuse" }); + + expect(graph.findApplicationByAppId).toHaveBeenCalledWith("remembered-app", expect.any(Function)); + expect(graph.findApplicationByName).not.toHaveBeenCalled(); + expect(graph.createApplication).not.toHaveBeenCalled(); + expect(r.content[0].text).toContain("Reused owning app"); + expect(r.content[0].text).toContain("SPE Provisioned"); + }); + + it("does NOT reopen the app choice on a later billing round-trip that omits appSelection (PR #3 review)", async () => { + // Regression guard for the confirm-stamp ordering fix. A remembered app on an + // unconfirmed session: the first call SETTLES the owning app (explicit + // appSelection) and then stops at the billing prompt. Because confirmation is + // now stamped as soon as the app is settled — BEFORE the billing elicitation — + // a follow-up call that answers billing but DROPS appSelection must not re-fire + // the always-ask app gate (previously it did, because confirmation was only + // stamped after app resolution, which the billing round-trip never reached). + stateStore.appId = "remembered-app"; + stateStore.appDisplayName = "Remembered App"; + + // Call 1: app settled via appSelection=reuse, but billing is missing → the + // tool asks for the billing model and returns (app not yet resolved). + const r1 = await provisionTool.handler({ appSelection: "reuse" }); + expect(r1.content[0].text).toContain("billing model"); + expect(graph.findApplicationByAppId).not.toHaveBeenCalled(); + // The session is stamped confirmed even though the app was not yet resolved. + expect(stateStore.confirmedSessionId).toBe(getSessionId()); + + // Call 2: answer billing, but OMIT appSelection (the agent dropped it). + vi.mocked(graph.findApplicationByAppId).mockResolvedValue({ appId: "remembered-app", objectId: "obj-r", displayName: "Remembered App" }); + vi.mocked(graph.createContainerType).mockResolvedValue({ containerTypeId: "ct-1", owningAppId: "remembered-app", displayName: "Remembered App Container Type" }); + vi.mocked(graph.createContainer).mockResolvedValue({ id: "c-1", displayName: "Default Container", containerTypeId: "ct-1", status: "active" }); + + const r2 = await provisionTool.handler({ billingClassification: "trial" }); + + // The app gate must NOT re-open — no reuse/switch ask — and the remembered app + // is resumed by its persisted id. + expect(r2.content[0].text).not.toContain("appSelection=reuse"); + expect(r2.content[0].text).not.toContain("Reuse it, or use a different app"); + expect(graph.findApplicationByAppId).toHaveBeenCalledWith("remembered-app", expect.any(Function)); + expect(r2.content[0].text).toContain("Reused owning app"); + }); +}); + +// ── ownerScope least-privilege intent (PR #3 review) ──────────────────────────────── + +describe("project_provision — ownerScope intent", () => { + // A fresh, unconfirmed session with no recorded ownerScope must ask which + // scope posture the owning app should take before provisioning. + it("elicits ownerScope when unset and not resumable from state", async () => { + const r = await provisionTool.handler({ appDisplayName: "App", billingClassification: "trial" }); + + expect(r.content[0].text).toContain("manage ALL container types"); + // Both options are offered with their re-run hints, and nothing was created. + expect(r.content[0].text).toContain("ownerScope=manage-all"); + expect(r.content[0].text).toContain("ownerScope=selected"); + expect(graph.createApplication).not.toHaveBeenCalled(); + }); + + it("does NOT re-elicit when ownerScope is resumable from state", async () => { + stateStore.ownerScope = "selected"; + vi.mocked(graph.findApplicationByName).mockResolvedValue(null); + vi.mocked(graph.createApplication).mockResolvedValue({ appId: "app-1", objectId: "obj-1", displayName: "App" }); + vi.mocked(graph.createContainerType).mockResolvedValue({ containerTypeId: "ct-1", owningAppId: "app-1", displayName: "App Container Type" }); + vi.mocked(graph.createContainer).mockResolvedValue({ id: "c-1", displayName: "Default Container", containerTypeId: "ct-1", status: "inactive" }); + + const r = await provisionTool.handler({ appDisplayName: "App", billingClassification: "trial" }); + + expect(r.content[0].text).not.toContain("manage ALL container types"); + expect(graph.createApplication).toHaveBeenCalled(); + }); + + it("manage-all persists ownerScope and sets owningAppManagesAllContainerTypes=true", async () => { + vi.mocked(graph.findApplicationByName).mockResolvedValue(null); + vi.mocked(graph.createApplication).mockResolvedValue({ appId: "app-1", objectId: "obj-1", displayName: "App" }); + vi.mocked(graph.createContainerType).mockResolvedValue({ containerTypeId: "ct-1", owningAppId: "app-1", displayName: "App Container Type" }); + vi.mocked(graph.createContainer).mockResolvedValue({ id: "c-1", displayName: "Default Container", containerTypeId: "ct-1", status: "inactive" }); + + const r = await provisionTool.handler({ appDisplayName: "App", billingClassification: "trial", ownerScope: "manage-all" }); + + expect(r.content[0].text).toContain("SPE Provisioned"); + // Broad admin/console intent → request the manage-all scope set and flag it. + expect(graph.addSpePermissions).toHaveBeenCalledWith("obj-1", expect.any(Function), { ownerScope: "manage-all" }); + expect(stateStore.ownerScope).toBe("manage-all"); + expect(stateStore.owningAppManagesAllContainerTypes).toBe(true); + }); + + it("selected persists ownerScope; a freshly created selected app still holds Manage.All so the flag is true", async () => { + vi.mocked(graph.findApplicationByName).mockResolvedValue(null); + vi.mocked(graph.createApplication).mockResolvedValue({ appId: "app-1", objectId: "obj-1", displayName: "App" }); + vi.mocked(graph.createContainerType).mockResolvedValue({ containerTypeId: "ct-1", owningAppId: "app-1", displayName: "App Container Type" }); + vi.mocked(graph.createContainer).mockResolvedValue({ id: "c-1", displayName: "Default Container", containerTypeId: "ct-1", status: "inactive" }); + + const r = await provisionTool.handler({ appDisplayName: "App", billingClassification: "trial", ownerScope: "selected" }); + + expect(r.content[0].text).toContain("SPE Provisioned"); + // Least-privilege intent → the NARROW scope set is requested. But a freshly + // CREATED app is still granted FileStorageContainerType.Manage.All (kept in the + // selected set), so it CAN enumerate all container types → flag true. The false + // case comes only from the runtime 403 self-correction for reused/external apps + // that lack the scope (PR #3 review). + expect(graph.addSpePermissions).toHaveBeenCalledWith("obj-1", expect.any(Function), { ownerScope: "selected" }); + expect(stateStore.ownerScope).toBe("selected"); + expect(stateStore.owningAppManagesAllContainerTypes).toBe(true); + }); + + it("does NOT stamp the managesAll flag from intent when reusing an existing app (defers to runtime detection)", async () => { + // A reused/external app may or may not hold Manage.All, so the stamp must omit + // the flag and let the runtime listContainerTypes 403-check decide (PR #3 review). + vi.mocked(graph.findApplicationByName).mockResolvedValue({ appId: "app-x", objectId: "obj-x", displayName: "App" }); + vi.mocked(graph.createContainerType).mockResolvedValue({ containerTypeId: "ct-1", owningAppId: "app-x", displayName: "App Container Type" }); + vi.mocked(graph.createContainer).mockResolvedValue({ id: "c-1", displayName: "Default Container", containerTypeId: "ct-1", status: "inactive" }); + + const r = await provisionTool.handler({ appDisplayName: "App", appSelection: "reuse", billingClassification: "trial", ownerScope: "selected" }); + + expect(r.content[0].text).toContain("SPE Provisioned"); + expect(stateStore.ownerScope).toBe("selected"); + // graph-client is mocked, so the real runtime wrapper doesn't run → flag stays + // unset for the reused app (not asserted true from intent). + expect(stateStore.owningAppManagesAllContainerTypes).toBeUndefined(); + }); +}); + +// ── project_scaffold ────────────────────────────────────────────────────────────── + +describe("project_scaffold", () => { + it("lists architectures when none chosen", async () => { + const r = await scaffoldTool.handler({}); + expect(r.content[0].text).toContain("Which reference architecture"); + expect(r.content[0].text).toContain("react-spa-functions"); + }); + + it("materializes the chosen architecture to disk", async () => { + const dir = mkdtempSync(join(tmpdir(), "spe-scaffold-")); + try { + const r = await scaffoldTool.handler({ architecture: "react-spa-functions", targetDir: dir, projectName: "demo" }); + expect(r.isError).toBeFalsy(); + expect(existsSync(join(dir, "package.json"))).toBe(true); + expect(existsSync(join(dir, "azure.yaml"))).toBe(true); + expect(existsSync(join(dir, "infra/main.bicep"))).toBe(true); + // The SPA can create containers (owning-app PCA) via the beta endpoint. + const appTsx = readFileSync(join(dir, "src/App.tsx"), "utf-8"); + expect(appTsx).toContain("Create container"); + expect(appTsx).toContain("graph.microsoft.com/beta"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("errors on unknown architecture", async () => { + const r = await scaffoldTool.handler({ architecture: "nope" }); + expect(r.isError).toBe(true); + }); +}); + +// ── project_hydrate_config ──────────────────────────────────────────────────────── + +describe("project_hydrate_config", () => { + it("writes .env with provisioning state", async () => { + Object.assign(stateStore, { tenantId: "t-1", appId: "app-1", containerTypeId: "ct-1", containerId: "c-1" }); + const dir = mkdtempSync(join(tmpdir(), "spe-hydrate-")); + try { + const r = await hydrateConfigTool.handler({ targetDir: dir, formats: ["env"] }); + expect(r.isError).toBeFalsy(); + const env = readFileSync(join(dir, ".env"), "utf-8"); + expect(env).toContain("CLIENT_ID=app-1"); + expect(env).toContain("CONTAINER_TYPE_ID=ct-1"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("errors when nothing is provisioned", async () => { + const r = await hydrateConfigTool.handler({}); + expect(r.isError).toBe(true); + }); +}); + +// ── content access ──────────────────────────────────────────────────────────── + +describe("content access", () => { + it("requires confirmation before granting", async () => { + const r = await grantContentAccessTool.handler({}); + expect(r.content[0].text).toContain("Enable content access"); + expect(isContentAccessGranted()).toBe(false); + }); + + it("grants with confirm=true and revokes", async () => { + await grantContentAccessTool.handler({ confirm: true }); + expect(isContentAccessGranted()).toBe(true); + await revokeContentAccessTool.handler({}); + expect(isContentAccessGranted()).toBe(false); + }); +}); + +// ── project_cleanup ─────────────────────────────────────────────────────────────── + +describe("project_cleanup", () => { + it("requires confirmation", async () => { + Object.assign(stateStore, { appId: "app-1", appObjectId: "obj-1", containerTypeId: "ct-1", billingClassification: "trial" }); + const r = await cleanupTool.handler({}); + expect(r.content[0].text).toContain("Confirm cleanup"); + expect(graph.deleteContainerType).not.toHaveBeenCalled(); + }); + + it("deletes a TRIAL container type + owning app with confirm=true", async () => { + Object.assign(stateStore, { appId: "app-1", appObjectId: "obj-1", containerTypeId: "ct-1", billingClassification: "trial" }); + const r = await cleanupTool.handler({ confirm: true }); + // Registration is deleted before the container type (teardown order). + expect(graph.deleteContainerTypeRegistration).toHaveBeenCalledWith("ct-1"); + expect(graph.deleteContainerType).toHaveBeenCalledWith("ct-1"); + expect(graph.deleteApplication).toHaveBeenCalledWith("obj-1", expect.any(Function)); + expect(r.content[0].text).toContain("Cleanup Complete"); + }); + + it("PAUSES and preserves app + state when containers still block a trial container type", async () => { + Object.assign(stateStore, { appId: "app-1", appObjectId: "obj-1", containerTypeId: "ct-1", billingClassification: "trial" }); + vi.mocked(graph.listContainers).mockResolvedValueOnce([{ id: "c-1" } as never]); + const r = await cleanupTool.handler({ confirm: true }); + expect(graph.deleteContainerTypeRegistration).not.toHaveBeenCalled(); + expect(graph.deleteContainerType).not.toHaveBeenCalled(); + expect(graph.deleteApplication).not.toHaveBeenCalled(); // app preserved (shared with containers) + expect(stateStore.containerTypeId).toBe("ct-1"); // state retained for resume + expect(r.content[0].text).toContain("Cleanup Paused"); + expect(r.content[0].text).toContain("1 live container(s)"); + }); + + it("PAUSES when container listing fails (uncertain != empty), preserving app + state", async () => { + Object.assign(stateStore, { appId: "app-1", appObjectId: "obj-1", containerTypeId: "ct-1", billingClassification: "trial" }); + vi.mocked(graph.listContainers).mockRejectedValueOnce(new Error("transient Graph error")); + const r = await cleanupTool.handler({ confirm: true }); + expect(graph.deleteContainerTypeRegistration).not.toHaveBeenCalled(); + expect(graph.deleteContainerType).not.toHaveBeenCalled(); + expect(graph.deleteApplication).not.toHaveBeenCalled(); + expect(stateStore.containerTypeId).toBe("ct-1"); + expect(r.content[0].text).toContain("Cleanup Paused"); + }); + + it("PRESERVES a standard container type + owning app without the override", async () => { + Object.assign(stateStore, { appId: "app-1", appObjectId: "obj-1", containerTypeId: "ct-1", billingClassification: "standard" }); + const r = await cleanupTool.handler({ confirm: true }); + expect(graph.deleteContainerType).not.toHaveBeenCalled(); + expect(graph.deleteApplication).not.toHaveBeenCalled(); + expect(r.content[0].text).toContain("protected container type"); + // state is retained (not cleared) so the resources remain tracked + expect(stateStore.containerTypeId).toBe("ct-1"); + }); + + it("treats an unknown/missing classification as protected (fail safe)", async () => { + Object.assign(stateStore, { appId: "app-1", appObjectId: "obj-1", containerTypeId: "ct-1" }); + const r = await cleanupTool.handler({ confirm: true }); + expect(graph.deleteContainerType).not.toHaveBeenCalled(); + expect(graph.deleteApplication).not.toHaveBeenCalled(); + expect(r.content[0].text).toContain("protected"); + }); + + it("deletes a standard container type only with the explicit deleteStandard override", async () => { + Object.assign(stateStore, { appId: "app-1", appObjectId: "obj-1", containerTypeId: "ct-1", billingClassification: "standard" }); + const r = await cleanupTool.handler({ confirm: true, deleteStandard: true }); + expect(graph.deleteContainerType).toHaveBeenCalledWith("ct-1"); + expect(graph.deleteApplication).toHaveBeenCalledWith("obj-1", expect.any(Function)); + expect(r.content[0].text).toContain("Override"); + }); + + it("preview warns (and does not delete) for a protected direct-to-customer container type", async () => { + Object.assign(stateStore, { appId: "app-1", appObjectId: "obj-1", containerTypeId: "ct-1", billingClassification: "directToCustomer" }); + const r = await cleanupTool.handler({}); + expect(r.content[0].text).toContain("Protected"); + expect(r.content[0].text).toContain("deleteStandard=true"); + expect(graph.deleteContainerType).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tools/pagination.test.ts b/src/tools/pagination.test.ts new file mode 100644 index 0000000..e8a496e --- /dev/null +++ b/src/tools/pagination.test.ts @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, it, expect } from "vitest"; +import { parsePageArgs, paginate, pageFromServerWindow, pageFooter } from "./pagination.js"; + +describe("parsePageArgs (TOOL-003)", () => { + it("applies defaults when no paging args are present", () => { + expect(parsePageArgs({})).toEqual({ top: 50, skip: 0 }); + expect(parsePageArgs({}, { defaultTop: 25 })).toEqual({ top: 25, skip: 0 }); + }); + + it("clamps top to [1, maxTop]", () => { + expect(parsePageArgs({ top: 5000 }).top).toBe(200); + expect(parsePageArgs({ top: 0 }).top).toBe(1); // below-min clamps up to 1 + expect(parsePageArgs({ top: -3 }).top).toBe(50); // invalid (negative) -> default + expect(parsePageArgs({ top: 5000 }, { maxTop: 100 }).top).toBe(100); + }); + + it("accepts limit and maxResults as aliases for top", () => { + expect(parsePageArgs({ limit: 10 }).top).toBe(10); + expect(parsePageArgs({ maxResults: 7 }).top).toBe(7); + }); + + it("reads skip from skip, continuationToken, or nextToken", () => { + expect(parsePageArgs({ skip: 20 }).skip).toBe(20); + expect(parsePageArgs({ continuationToken: "40" }).skip).toBe(40); + expect(parsePageArgs({ nextToken: "60" }).skip).toBe(60); + }); +}); + +describe("paginate (client-side)", () => { + const items = Array.from({ length: 10 }, (_, i) => i); + + it("returns the requested window with a resumable nextToken", () => { + const page = paginate(items, { top: 3, skip: 0 }); + expect(page.items).toEqual([0, 1, 2]); + expect(page.totalCount).toBe(10); + expect(page.hasMore).toBe(true); + expect(page.nextToken).toBe("3"); + }); + + it("has no nextToken on the final page", () => { + const page = paginate(items, { top: 5, skip: 5 }); + expect(page.items).toEqual([5, 6, 7, 8, 9]); + expect(page.hasMore).toBe(false); + expect(page.nextToken).toBeUndefined(); + }); +}); + +describe("pageFromServerWindow", () => { + it("derives hasMore from the server-reported total", () => { + const page = pageFromServerWindow([1, 2, 3], { top: 3, skip: 0 }, 9); + expect(page.hasMore).toBe(true); + expect(page.nextToken).toBe("3"); + expect(page.totalCount).toBe(9); + }); + + it("stops when the window reaches the total", () => { + const page = pageFromServerWindow([7, 8, 9], { top: 3, skip: 6 }, 9); + expect(page.hasMore).toBe(false); + expect(page.nextToken).toBeUndefined(); + }); +}); + +describe("pageFooter", () => { + it("is empty on a single unpaginated page", () => { + const page = paginate([1, 2], { top: 50, skip: 0 }); + expect(pageFooter(page, 0)).toBe(""); + }); + + it("describes the window and how to continue", () => { + const page = paginate(Array.from({ length: 10 }, (_, i) => i), { top: 3, skip: 0 }); + const footer = pageFooter(page, 0); + expect(footer).toContain("Showing 1–3 of 10"); + expect(footer).toContain("skip: 3"); + }); +}); diff --git a/src/tools/pagination.ts b/src/tools/pagination.ts new file mode 100644 index 0000000..f2f9431 --- /dev/null +++ b/src/tools/pagination.ts @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Shared pagination helpers (TOOL-003). + * + * List/search tools accept `top` (page size) and `skip` (offset) and return a + * structured `{ items, totalCount, hasMore, nextToken }` envelope alongside the + * human-readable markdown summary, so MCP agents can traverse large tenants + * deterministically instead of overflowing a single markdown table. + * + * `nextToken` is the opaque resumable cursor — it encodes the next `skip` value + * and can be passed back as `skip` (or `continuationToken`) on the next call. + */ + +export interface PageArgs { + top: number; + skip: number; +} + +export interface PageResult { + items: T[]; + totalCount: number; + hasMore: boolean; + /** Opaque cursor for the next page, or undefined when there is no next page. */ + nextToken?: string; +} + +const DEFAULT_TOP = 50; +const MAX_TOP = 200; + +function toPositiveInt(value: unknown, fallback: number): number { + const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN; + if (!Number.isFinite(n) || n < 0) return fallback; + return Math.floor(n); +} + +/** + * Parse `top`/`limit` and `skip`/`continuationToken`/`nextToken` from raw tool + * args, clamping `top` to `[1, maxTop]`. + */ +export function parsePageArgs( + args: Record, + opts: { defaultTop?: number; maxTop?: number } = {}, +): PageArgs { + const defaultTop = opts.defaultTop ?? DEFAULT_TOP; + const maxTop = opts.maxTop ?? MAX_TOP; + + // `maxResults` is accepted as a back-compat alias for `top`. + const rawTop = args.top ?? args.limit ?? args.maxResults; + const top = Math.min(Math.max(toPositiveInt(rawTop, defaultTop), 1), maxTop); + + // A continuation token / nextToken is just an encoded skip offset. + const rawSkip = args.skip ?? args.continuationToken ?? args.nextToken; + const skip = toPositiveInt(rawSkip, 0); + + return { top, skip }; +} + +/** Slice an already-materialized collection into a page (client-side). */ +export function paginate(items: T[], { top, skip }: PageArgs): PageResult { + const totalCount = items.length; + const page = items.slice(skip, skip + top); + const nextSkip = skip + page.length; + const hasMore = nextSkip < totalCount; + return { + items: page, + totalCount, + hasMore, + ...(hasMore ? { nextToken: String(nextSkip) } : {}), + }; +} + +/** + * Build a page envelope when the underlying source already applied the window + * server-side (e.g. Microsoft Search `from`/`size`). `total` is the full result + * count reported by the source. + */ +export function pageFromServerWindow( + items: T[], + { skip }: PageArgs, + total: number, +): PageResult { + const nextSkip = skip + items.length; + const hasMore = nextSkip < total; + return { + items, + totalCount: total, + hasMore, + ...(hasMore ? { nextToken: String(nextSkip) } : {}), + }; +} + +/** A short markdown footer describing the current page, when paginating. */ +export function pageFooter(page: PageResult, skip: number): string { + if (skip === 0 && !page.hasMore) return ""; + const from = page.items.length === 0 ? 0 : skip + 1; + const to = skip + page.items.length; + let line = `\n_Showing ${from}–${to} of ${page.totalCount}._`; + if (page.hasMore) line += ` Pass \`skip: ${page.nextToken}\` for the next page.`; + return line; +} diff --git a/src/tools/preview-file.ts b/src/tools/preview-file.ts new file mode 100644 index 0000000..fa84b2d --- /dev/null +++ b/src/tools/preview-file.ts @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tool: content_file_preview + * + * Generate a preview URL for a file in a container. + */ + +import { getContainerDrive, getDriveItem, previewDriveItem } from "../graph-client.js"; +import { requireContentAccess } from "./content-access.js"; +import type { McpTool } from "../types.js"; + +export const previewFileTool: McpTool = { + name: "content_file_preview", + annotations: { readOnly: true, plane: "content", requiresConsent: true }, + description: + "Generate a preview URL for a file in a SharePoint Embedded container. " + + "The preview URL can be opened in a browser to view the file.", + inputSchema: { + type: "object" as const, + properties: { + containerId: { + type: "string", + description: "The container ID.", + }, + filePath: { + type: "string", + description: "Path to the file (e.g., 'report.pdf' or 'Documents/report.pdf').", + }, + }, + required: ["containerId", "filePath"], + }, + handler: async (args) => { + const gate = requireContentAccess(); + if (gate) return gate; + + const containerId = args.containerId as string; + const filePath = args.filePath as string; + + if (!containerId || !filePath) { + return { + content: [{ type: "text", text: "Error: containerId and filePath are required" }], + isError: true, + }; + } + + const drive = await getContainerDrive(containerId); + const itemPath = filePath.startsWith("/") ? filePath : `/${filePath}`; + const item = await getDriveItem(drive.id, itemPath); + const preview = await previewDriveItem(drive.id, item.id); + + let output = `## File Preview\n\n`; + output += `| Property | Value |\n|----------|-------|\n`; + output += `| **File** | ${item.name} |\n`; + output += `| **Size** | ${item.size ? `${(item.size / 1024).toFixed(1)} KB` : "—"} |\n`; + output += `| **Preview URL** | ${preview.getUrl} |\n`; + + return { content: [{ type: "text", text: output }] }; + }, +}; diff --git a/src/tools/provision-guided-billing.test.ts b/src/tools/provision-guided-billing.test.ts new file mode 100644 index 0000000..fb98742 --- /dev/null +++ b/src/tools/provision-guided-billing.test.ts @@ -0,0 +1,259 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Guided standard-billing subscription/resource-group selection (PR #3 review). + * + * These tests drive project_provision with NATIVE elicitation mocked so the + * guided sub/RG helper resolves in-band (as a capable MCP client would). They + * verify: + * 1. multiple subs + RGs → the tool elicits BOTH and proceeds with the CHOSEN + * values (not the first) threaded all the way to createSyntexAccount. + * 2. exactly one sub + one RG → auto-selected with NO prompt, and the choice is + * surfaced as a note. + * 3. zero subs → a clear, non-crashing error; nothing is created. + * 5. the confirmBilling financial-safety gate STILL fires after guided + * selection (no silent charge), and region validation still runs first. + * + * The agent-guided FALLBACK path (native elicitation unavailable) is covered in + * orchestration.test.ts. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// ── Mocks ──────────────────────────────────────────────────────────────────── +vi.mock("../graph-client.js", () => ({ + findApplicationByName: vi.fn(), + findApplicationByAppId: vi.fn(), + createApplication: vi.fn(), + addSpePermissions: vi.fn(), + createContainerType: vi.fn(), + listContainerTypes: vi.fn(async () => []), + registerContainerType: vi.fn(), + createContainer: vi.fn(), + activateContainer: vi.fn(), + deleteContainerType: vi.fn(), + deleteContainerTypeRegistration: vi.fn(), + listContainers: vi.fn(async () => []), + listDeletedContainers: vi.fn(async () => []), + deleteApplication: vi.fn(), + getSignedInUser: vi.fn(async () => ({ id: "user-1", userPrincipalName: "admin@x.com" })), + grantContainerTypeOwner: vi.fn(async () => ({ id: "perm-1", roles: ["owner"] })), +})); +vi.mock("../bootstrap.js", () => ({ + bootstrapTokenProvider: vi.fn(async () => "boot"), + getSignedInIdentity: vi.fn(async () => ({ tenantId: "t-1", username: "dev@x.com" })), +})); +vi.mock("../azure-cli.js", async (importActual) => ({ + // Keep the REAL region helpers so region validation runs for real; only the + // az-shelling functions are mocked. + ...(await importActual()), + ensureSyntexProviderRegistered: vi.fn(async () => ({ namespace: "Microsoft.Syntex", registrationState: "Registered" })), + createSyntexAccount: vi.fn(async () => "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Syntex/accounts/acc-1"), + getSyntexAccounts: vi.fn(async () => []), + listSubscriptions: vi.fn(async () => []), + listResourceGroups: vi.fn(async () => []), + // Default to "verified" so existing RG-path tests are unaffected; overridden + // per-test for the 0-RG entered-name existence check (PR #3 review). + resourceGroupExists: vi.fn(async () => true), +})); +vi.mock("../auth.js", () => ({ setAuthConfig: vi.fn() })); + +// Native elicitation mocked: a capable client resolves the pick in-band. For the +// guided sub/RG picks we deliberately choose the LAST option to prove the CHOSEN +// value (not merely the first) threads through provisioning. Any other gate +// (not exercised here — state pre-seeds ownerScope) auto-resolves to its first. +// vi.hoisted so the spies exist when the mock factory runs during module load. +const { elicitChoiceMock, elicitTextMock } = vi.hoisted(() => ({ + elicitChoiceMock: vi.fn( + async (_question: string, options: { value: string }[], paramName: string) => ({ + resolved: true as const, + value: + paramName === "azureSubscriptionId" || paramName === "resourceGroup" + ? options[options.length - 1].value + : options[0].value, + }), + ), + elicitTextMock: vi.fn(async () => ({ resolved: false as const, result: null })), +})); +vi.mock("../elicitation.js", () => ({ + elicitChoice: elicitChoiceMock, + elicitText: elicitTextMock, + needChoice: vi.fn(() => ({ content: [{ type: "text", text: "needChoice" }] })), +})); + +const stateStore: Record = {}; +vi.mock("../state.js", () => ({ + readState: vi.fn(() => ({ ...stateStore })), + writeState: vi.fn((p: Record) => { Object.assign(stateStore, p); return { ...stateStore }; }), + clearState: vi.fn(() => { for (const k of Object.keys(stateStore)) delete stateStore[k]; }), +})); + +import * as graph from "../graph-client.js"; +import * as azureCli from "../azure-cli.js"; +import { provisionTool } from "../tools/provision.js"; + +beforeEach(() => { + vi.clearAllMocks(); + for (const k of Object.keys(stateStore)) delete stateStore[k]; + // Settle least-privilege intent so the ownerScope gate is a resumable no-op and + // the ONLY elicitation in these tests is the guided sub/RG selection. + stateStore.ownerScope = "selected"; + // Happy-path Graph mocks for a full standard chain. + vi.mocked(graph.findApplicationByName).mockResolvedValue(null); + vi.mocked(graph.createApplication).mockResolvedValue({ appId: "app-1", objectId: "obj-1", displayName: "App" }); + vi.mocked(graph.createContainerType).mockResolvedValue({ containerTypeId: "ct-1", owningAppId: "app-1", displayName: "App Container Type", billingClassification: "standard" }); + vi.mocked(graph.createContainer).mockResolvedValue({ id: "c-1", displayName: "Default Container", containerTypeId: "ct-1", status: "active" }); +}); + +describe("project_provision — guided standard-billing selection (native elicitation)", () => { + it("elicits subscription AND resource group, then provisions with the CHOSEN values", async () => { + vi.mocked(azureCli.listSubscriptions).mockResolvedValue([ + { id: "sub-8", name: "Sub 8", state: "Enabled" }, + { id: "sub-9", name: "Sub 9", state: "Enabled" }, + ]); + vi.mocked(azureCli.listResourceGroups).mockResolvedValue([ + { name: "rg-8", location: "eastus", id: "/subscriptions/sub-9/resourceGroups/rg-8" }, + { name: "rg-9", location: "eastus", id: "/subscriptions/sub-9/resourceGroups/rg-9" }, + ]); + + const r = await provisionTool.handler({ + appDisplayName: "App", + billingClassification: "standard", + region: "eastus", + confirmBilling: true, + }); + + // Both picks were elicited (subscription first, then resource group). + expect(elicitChoiceMock).toHaveBeenCalledTimes(2); + expect(elicitChoiceMock.mock.calls[0][2]).toBe("azureSubscriptionId"); + expect(elicitChoiceMock.mock.calls[1][2]).toBe("resourceGroup"); + // Resource groups were listed for the CHOSEN subscription (sub-9). + expect(azureCli.listResourceGroups).toHaveBeenCalledWith("sub-9"); + // The chosen (last) values thread all the way to the billing account. + expect(azureCli.createSyntexAccount).toHaveBeenCalledWith("sub-9", "rg-9", "eastus", "ct-1"); + expect(r.content[0].text).toContain("SPE Provisioned"); + }); + + it("auto-selects a lone subscription and resource group without prompting", async () => { + vi.mocked(azureCli.listSubscriptions).mockResolvedValue([ + { id: "solo-sub", name: "Solo Sub", state: "Enabled" }, + ]); + vi.mocked(azureCli.listResourceGroups).mockResolvedValue([ + { name: "solo-rg", location: "eastus", id: "/subscriptions/solo-sub/resourceGroups/solo-rg" }, + ]); + + const r = await provisionTool.handler({ + appDisplayName: "App", + billingClassification: "standard", + region: "eastus", + confirmBilling: true, + }); + + // Trivial single choices are auto-selected — no elicitation prompt. + expect(elicitChoiceMock).not.toHaveBeenCalled(); + expect(azureCli.createSyntexAccount).toHaveBeenCalledWith("solo-sub", "solo-rg", "eastus", "ct-1"); + expect(r.content[0].text).toContain("Using the only Azure subscription"); + expect(r.content[0].text).toContain("Using the only resource group"); + expect(r.content[0].text).toContain("SPE Provisioned"); + }); + + it("errors clearly when there are no Azure subscriptions (no crash, nothing created)", async () => { + vi.mocked(azureCli.listSubscriptions).mockResolvedValue([]); + + const r = await provisionTool.handler({ + appDisplayName: "App", + billingClassification: "standard", + region: "eastus", + confirmBilling: true, + }); + + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("az login"); + expect(elicitChoiceMock).not.toHaveBeenCalled(); + expect(graph.createApplication).not.toHaveBeenCalled(); + expect(azureCli.createSyntexAccount).not.toHaveBeenCalled(); + }); + + it("STILL requires confirmBilling after guided selection — no silent charge", async () => { + // Singletons auto-fill the target, but the financial-safety gate must still + // fire because confirmBilling was not passed. This proves guided selection + // runs BEFORE the gate and never bypasses it. + vi.mocked(azureCli.listSubscriptions).mockResolvedValue([ + { id: "solo-sub", name: "Solo Sub", state: "Enabled" }, + ]); + vi.mocked(azureCli.listResourceGroups).mockResolvedValue([ + { name: "solo-rg", location: "eastus", id: "/subscriptions/solo-sub/resourceGroups/solo-rg" }, + ]); + + const r = await provisionTool.handler({ + appDisplayName: "App", + billingClassification: "standard", + region: "eastus", + // confirmBilling intentionally omitted + }); + + expect(r.content[0].text).toContain("Confirm standard (paid) billing"); + expect(r.content[0].text).toContain("confirmBilling=true"); + // The auto-selected target appears in the confirmation preview. + expect(r.content[0].text).toContain("solo-sub"); + // Nothing chargeable was created. + expect(graph.createApplication).not.toHaveBeenCalled(); + expect(graph.createContainerType).not.toHaveBeenCalled(); + expect(azureCli.createSyntexAccount).not.toHaveBeenCalled(); + }); + + it("validates the region after guided selection and before creating anything", async () => { + vi.mocked(azureCli.listSubscriptions).mockResolvedValue([ + { id: "solo-sub", name: "Solo Sub", state: "Enabled" }, + ]); + vi.mocked(azureCli.listResourceGroups).mockResolvedValue([ + { name: "solo-rg", location: "westus2", id: "/subscriptions/solo-sub/resourceGroups/solo-rg" }, + ]); + + const r = await provisionTool.handler({ + appDisplayName: "App", + billingClassification: "standard", + region: "westus2", // unsupported for Microsoft.Syntex + confirmBilling: true, + }); + + expect(r.isError).toBe(true); + expect(r.content[0].text).toMatch(/not available for Microsoft\.Syntex/i); + // Region guard fires before creation, so nothing is stranded. + expect(graph.createApplication).not.toHaveBeenCalled(); + expect(graph.createContainerType).not.toHaveBeenCalled(); + expect(azureCli.createSyntexAccount).not.toHaveBeenCalled(); + }); + + it("fails cost-free when a user-entered resource group does not exist — nothing created (PR #3 review)", async () => { + // One subscription (auto-selected) with ZERO resource groups drives the + // elicitText "enter a new RG name" path; the entered name is then probed. + vi.mocked(azureCli.listSubscriptions).mockResolvedValue([ + { id: "solo-sub", name: "Solo Sub", state: "Enabled" }, + ]); + vi.mocked(azureCli.listResourceGroups).mockResolvedValue([]); + // The agent supplies a name in-band... + elicitTextMock.mockResolvedValueOnce({ resolved: true, value: "typo-rg" }); + // ...but it does not exist in the subscription. + vi.mocked(azureCli.resourceGroupExists).mockResolvedValue(false); + + const r = await provisionTool.handler({ + appDisplayName: "App", + billingClassification: "standard", + region: "eastus", + confirmBilling: true, + }); + + // The entered name was probed against the auto-selected subscription. + expect(azureCli.resourceGroupExists).toHaveBeenCalledWith("typo-rg", "solo-sub"); + // Actionable, cost-free guidance is returned instead of proceeding. + expect(r.content[0].text).toContain("does not exist"); + expect(r.content[0].text).toContain("az group create"); + // Fails BEFORE the region check, the confirmBilling gate, and any creation — + // so no container type is stranded and no billing account is created. + expect(graph.createApplication).not.toHaveBeenCalled(); + expect(graph.createContainerType).not.toHaveBeenCalled(); + expect(azureCli.createSyntexAccount).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tools/provision-progress.test.ts b/src/tools/provision-progress.test.ts new file mode 100644 index 0000000..cf000e0 --- /dev/null +++ b/src/tools/provision-progress.test.ts @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * WI-16: long-running provisioning UX. + * + * Verifies that when project_provision fails MID-FLOW, the error return includes + * a summary of the steps completed so far (partial progress) so a + * partially-provisioned, idempotent run is debuggable and resumable — rather + * than surfacing only the terminal error text. Covers both the standard-billing + * failure path and the generic catch-all (a throw from any later Graph call). + * External effects (Graph, az, MSAL, fs) are mocked so these run offline. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../graph-client.js", () => ({ + findApplicationByName: vi.fn(), + findApplicationByAppId: vi.fn(), + createApplication: vi.fn(), + addSpePermissions: vi.fn(), + createContainerType: vi.fn(), + listContainerTypes: vi.fn(async () => []), + registerContainerType: vi.fn(), + createContainer: vi.fn(), + activateContainer: vi.fn(), + deleteContainerType: vi.fn(), + getSignedInUser: vi.fn(async () => ({ id: "user-1", userPrincipalName: "admin@x.com" })), + grantContainerTypeOwner: vi.fn(async () => ({ id: "perm-1", roles: ["owner"] })), +})); +vi.mock("../bootstrap.js", () => ({ + bootstrapTokenProvider: vi.fn(async () => "boot"), + getSignedInIdentity: vi.fn(async () => ({ tenantId: "t-1", username: "dev@x.com" })), +})); +vi.mock("../azure-cli.js", async (importActual) => ({ + ...(await importActual()), + ensureSyntexProviderRegistered: vi.fn(async () => ({ namespace: "Microsoft.Syntex", registrationState: "Registered" })), + createSyntexAccount: vi.fn(async () => "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Syntex/accounts/acc-1"), + getSyntexAccounts: vi.fn(async () => []), +})); +vi.mock("../auth.js", () => ({ setAuthConfig: vi.fn() })); + +const stateStore: Record = {}; +vi.mock("../state.js", () => ({ + readState: vi.fn(() => ({ ...stateStore })), + writeState: vi.fn((p: Record) => { Object.assign(stateStore, p); return { ...stateStore }; }), + clearState: vi.fn(() => { for (const k of Object.keys(stateStore)) delete stateStore[k]; }), +})); + +import * as graph from "../graph-client.js"; +import * as azureCli from "../azure-cli.js"; +import { provisionTool } from "../tools/provision.js"; + +beforeEach(() => { + vi.clearAllMocks(); + for (const k of Object.keys(stateStore)) delete stateStore[k]; + // Settle least-privilege intent (PR #3 review) so the ownerScope gate is a + // resumable no-op for these mid-flow-failure chains. + stateStore.ownerScope = "selected"; +}); + +describe("project_provision — partial progress on mid-flow failure (WI-16)", () => { + it("includes completed steps when a later Graph call throws (catch-all path)", async () => { + vi.mocked(graph.findApplicationByName).mockResolvedValue(null); + vi.mocked(graph.createApplication).mockResolvedValue({ appId: "app-1", objectId: "obj-1", displayName: "App" }); + vi.mocked(graph.createContainerType).mockResolvedValue({ containerTypeId: "ct-1", owningAppId: "app-1", displayName: "App Container Type" }); + // The app + container type steps succeed; registration then throws mid-flow. + vi.mocked(graph.registerContainerType).mockRejectedValue(new Error("Graph 500: registration failed")); + + const r = await provisionTool.handler({ appDisplayName: "App", billingClassification: "trial" }); + + expect(r.isError).toBe(true); + // Terminal error text is preserved … + expect(r.content[0].text).toContain("Graph 500: registration failed"); + // … AND the steps completed before the failure are surfaced. + expect(r.content[0].text).toContain("Progress before this stop"); + expect(r.content[0].text).toContain("Created owning app"); + expect(r.content[0].text).toContain("Created container type"); + // Shows where it stopped and that the flow is resumable. + expect(r.content[0].text).toContain("Stopped at"); + expect(r.content[0].text).toContain("re-run `project_provision`"); + // Registration never succeeded, so it must NOT appear as a completed step. + expect(r.content[0].text).not.toContain("Registered container type on tenant"); + }); + + it("includes completed steps when standard billing account creation fails", async () => { + vi.mocked(graph.findApplicationByName).mockResolvedValue(null); + vi.mocked(graph.createApplication).mockResolvedValue({ appId: "app-1", objectId: "obj-1", displayName: "App" }); + vi.mocked(graph.createContainerType).mockResolvedValue({ containerTypeId: "ct-1", owningAppId: "app-1", displayName: "App Container Type", billingClassification: "standard" }); + vi.mocked(azureCli.createSyntexAccount).mockRejectedValueOnce(new Error("ARM 409")); + + const r = await provisionTool.handler({ appDisplayName: "App", billingClassification: "standard", azureSubscriptionId: "sub-1", resourceGroup: "rg-1", region: "eastus", confirmBilling: true }); + + expect(r.isError).toBe(true); + // Existing behaviour (rollback) is preserved … + expect(r.content[0].text).toContain("rolled back"); + // … plus the partial-steps summary now accompanies the failure. + expect(r.content[0].text).toContain("Progress before this stop"); + expect(r.content[0].text).toContain("Created owning app"); + expect(r.content[0].text).toContain("Microsoft.Syntex provider"); + expect(r.content[0].text).toContain("Created container type"); + }); + + it("emits each completed step to the server log for live progress", async () => { + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + vi.mocked(graph.findApplicationByName).mockResolvedValue(null); + vi.mocked(graph.createApplication).mockResolvedValue({ appId: "app-1", objectId: "obj-1", displayName: "App" }); + vi.mocked(graph.createContainerType).mockResolvedValue({ containerTypeId: "ct-1", owningAppId: "app-1", displayName: "App Container Type" }); + // Reset registration to succeed (a prior test may leave it rejecting — + // vi.clearAllMocks clears call history but not implementations). + vi.mocked(graph.registerContainerType).mockResolvedValue(undefined as never); + vi.mocked(graph.createContainer).mockResolvedValue({ id: "c-1", displayName: "Default Container", containerTypeId: "ct-1", status: "active" }); + + const r = await provisionTool.handler({ appDisplayName: "App", billingClassification: "trial" }); + + expect(r.isError).toBeFalsy(); + const logged = errSpy.mock.calls.map((c) => String(c[0])).join("\n"); + // Live step logging is prefixed and numbered so operators see movement. + expect(logged).toContain("[Provision] step 1:"); + expect(logged).toContain("Created owning app"); + expect(logged).toContain("Created container"); + } finally { + errSpy.mockRestore(); + } + }); +}); diff --git a/src/tools/provision-prompt.test.ts b/src/tools/provision-prompt.test.ts new file mode 100644 index 0000000..f8cfff2 --- /dev/null +++ b/src/tools/provision-prompt.test.ts @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * TEST-002 — provision golden-trajectory test. + * + * Locks the `provision_spe_app` guided prompt to its required *sequencing* so a + * wording tweak stays green but a regression that drops or reorders a beat fails. + * Assertions target stable substrings (tool names, key phrases) and relative + * ordering rather than exact prose. + * + * Required trajectory beats: + * (i) sign-in / status check FIRST + * (ii) provisioning (owning app -> container type -> register -> container) + * (iii) an explicit "wait for the user to choose / confirm" step (no silent + * app reuse) + * (iv) a billing choice point (trial vs standard) + */ + +import { describe, expect, it } from "vitest"; +import { getPromptMessages } from "../prompts.js"; + +function provisionText(idea = ""): string { + const result = getPromptMessages("provision_spe_app", idea ? { idea } : {}); + // Shape: { description, messages: [{ role: "user", content: { type, text } }] } + expect(result.messages).toHaveLength(1); + const msg = result.messages[0]; + expect(msg.role).toBe("user"); + expect(msg.content.type).toBe("text"); + return msg.content.text; +} + +describe("provision_spe_app golden trajectory", () => { + it("returns a single user text message", () => { + const text = provisionText(); + expect(typeof text).toBe("string"); + expect(text.length).toBeGreaterThan(0); + }); + + it("threads the caller's idea into the guidance when provided", () => { + const text = provisionText("manage construction documents"); + expect(text).toContain("manage construction documents"); + }); + + // (i) Sign-in / status check comes FIRST. + it("(i) checks prerequisites / sign-in before anything else", () => { + const text = provisionText(); + expect(text).toContain("status_get"); + expect(text).toContain("az login --allow-no-subscriptions"); + // status_get must precede the provisioning and billing steps. + expect(text.indexOf("status_get")).toBeGreaterThanOrEqual(0); + expect(text.indexOf("status_get")).toBeLessThan(text.indexOf("project_provision")); + }); + + // (ii) Provisioning step. + // + // NOTE/GAP: the prompt delegates the create owning app -> container type -> + // register -> container ORDERING to the `project_provision` orchestrator tool + // rather than spelling those four sub-steps out inline. We therefore assert + // the provisioning beat that IS present (project_provision + billing + // classification + the explicit owning-app reuse choice). + // TODO(TEST-002): if/when prompts.ts is revised to enumerate the + // app -> container type -> register -> container sub-steps explicitly, tighten + // this to assert that inline ordering. Owner: prompts.ts workstream. + it("(ii) drives provisioning via project_provision with a billing classification", () => { + const text = provisionText(); + expect(text).toContain("project_provision"); + expect(text).toContain("billingClassification"); + // The owning-app concept is surfaced as a user choice (not a hidden step). + expect(text).toContain("owning app"); + }); + + // (iii) Explicit wait-for-user / confirm step, and NO silent app reuse. + it("(iii) waits for the user to choose and never silently reuses the last app", () => { + const text = provisionText(); + expect(text).toContain("Never silently reuse the last app"); + expect(text.toLowerCase()).toContain("wait for the user"); + }); + + // (iv) Billing choice point: trial vs standard. + it("(iv) presents a billing choice point (trial vs standard)", () => { + const text = provisionText(); + expect(text).toContain("Trial"); + expect(text).toContain("Standard"); + // Asked as a question to the user, not silently chosen. + expect(text).toMatch(/ask the user/i); + }); + + // Cross-cutting: overall sequencing of the major beats is stable. + it("orders the beats: sign-in -> billing -> provision -> scaffold", () => { + const text = provisionText(); + const idxStatus = text.indexOf("status_get"); + const idxBilling = text.indexOf("Billing"); + const idxProvision = text.indexOf("project_provision"); + const idxScaffold = text.indexOf("project_scaffold"); + + expect(idxStatus).toBeGreaterThanOrEqual(0); + expect(idxBilling).toBeGreaterThan(idxStatus); + expect(idxProvision).toBeGreaterThan(idxBilling); + expect(idxScaffold).toBeGreaterThan(idxProvision); + }); +}); diff --git a/src/tools/provision.ts b/src/tools/provision.ts new file mode 100644 index 0000000..b90e5b2 --- /dev/null +++ b/src/tools/provision.ts @@ -0,0 +1,609 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tool: project_provision + * + * One-call orchestrator for the SharePoint Embedded control-plane setup: + * owning app → container type → (standard billing) → registration → container. + * + * Composes the lower-level operations with the two-token handoff handled + * internally (az bootstrap token creates the app; the owning-app token does the + * SPE operations). Idempotent and resumable via ~/.spe-mcp/state.json. + * + * Billing: when `billingClassification` is "standard", a subscription + + * resource group are required; the tool registers the Microsoft.Syntex provider + * and creates the container type linked to that subscription. When omitted and + * none can be defaulted, the tool asks the user to choose (agent-guided + * elicitation) rather than guessing. + */ + +import { bootstrapTokenProvider, getSignedInIdentity } from "../bootstrap.js"; +import { createSyntexAccount, ensureSyntexProviderRegistered, getSyntexAccounts, assertSyntexRegionSupported } from "../azure-cli.js"; +import { + activateContainer, + addSpePermissions, + createApplication, + createContainer, + createContainerType, + deleteContainerType, + findApplicationByAppId, + findApplicationByName, + getSignedInUser, + grantContainerTypeOwner, + listContainerTypes, + registerContainerType, +} from "../graph-client.js"; +import { setAuthConfig } from "../auth.js"; +import { guestSignInAdvisory } from "../guest-advisory.js"; +import { + CONTAINER_CREATE_MAX_ATTEMPTS, + containerCreateBackoffMs, + isContainerPropagationError, + toClassifiableError, + type ClassifiableError, +} from "../container-retry.js"; +import { elicitChoice, elicitText } from "../elicitation.js"; +import { resolveStandardBillingTarget } from "./standard-billing-target.js"; +import { isContextConfirmedThisSession, stampContextConfirmed } from "../session.js"; +import { readState, writeState } from "../state.js"; +import type { Guid, McpTool, OwnerScope } from "../types.js"; + +interface ProvisionArgs { + appDisplayName?: string; + appSelection?: "reuse" | "new"; + ownerScope?: OwnerScope; + containerTypeName?: string; + containerName?: string; + billingClassification?: "trial" | "standard"; + azureSubscriptionId?: Guid; + resourceGroup?: string; + region?: string; + confirmBilling?: boolean; + seedSampleData?: boolean; +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +/** + * Structured stderr log (matches the per-module convention used in bootstrap.ts + * / graph-client.ts). Provisioning is a multi-minute orchestration; without a + * live signal the buffered `steps` array only surfaces at the very end, so the + * server log looks frozen mid-run. Logging each completed step here (rather than + * threading MCP `notifications/progress` through index.ts, which another work + * item owns this batch) gives operators live movement with no protocol plumbing. + */ +function log(message: string): void { + console.error(`[${new Date().toISOString()}] [Provision] ${message}`); +} + +/** + * Append a completed step to the running list AND emit it to the server log so + * progress is visible while the orchestration is still in flight. + */ +function recordStep(steps: string[], text: string): void { + steps.push(text); + log(`step ${steps.length}: ${text}`); +} + +/** + * Render the steps completed so far as a partial-progress block for an + * early-return / failure path. A mid-flow failure that returned only its own + * error text used to hide how far provisioning got; because the flow is + * idempotent/resumable, surfacing the completed steps (and where it stopped) + * makes a half-finished run debuggable and resumable. Returns "" when no steps + * have run yet (e.g. sign-in / elicitation gates), so those returns are + * unchanged rather than carrying an empty, noisy section. + */ +function partialProgress(steps: string[], stoppedAt: string): string { + if (steps.length === 0) return ""; + const done = steps.map((s, i) => `${i + 1}. ${s}`).join("\n"); + return ( + "\n\n### Progress before this stop\n\n" + + done + + `\n\n_Stopped at: ${stoppedAt}. Provisioning is idempotent — re-run \`project_provision\` to resume from here._` + ); +} + +export const provisionTool: McpTool = { + name: "project_provision", + annotations: { plane: "control" }, + description: + "Provision a complete SharePoint Embedded setup in one call: create the owning Entra app, " + + "create and register a container type, and create an active container. Supports trial or " + + "standard billing (standard requires an Azure subscription + resource group). Idempotent and " + + "resumable. Returns the IDs needed to wire an app. Use this for 'build me an SPE app' requests.", + inputSchema: { + type: "object" as const, + properties: { + appDisplayName: { type: "string", description: "Owning app display name. Default: 'SPE Builder App'." }, + appSelection: { + type: "string", + enum: ["reuse", "new"], + description: + "When a previously-used owning app is remembered, set 'reuse' to use it again or 'new' to " + + "create/target a different one. If omitted and an app is remembered, the tool asks first.", + }, + ownerScope: { + type: "string", + enum: ["manage-all", "selected"], + description: + "Least-privilege intent for the owning app's SPE permissions (PR #3 review). " + + "'selected' requests only the scopes needed to manage this app's own container type (standard " + + "ISV/LOB). 'manage-all' also requests the broad *.Manage.All scopes to administer ALL container " + + "types in the tenant (admin/console app). If omitted on an unconfirmed session, the tool asks. " + + "Persisted and reused on later runs.", + }, + containerTypeName: { type: "string", description: "Container type name. Default: ' Container Type'." }, + containerName: { type: "string", description: "First container name. Default: 'Default Container'." }, + billingClassification: { + type: "string", + enum: ["trial", "standard"], + description: "Billing model. 'trial' (free, 30 days, max 3) or 'standard' (Azure subscription).", + }, + azureSubscriptionId: { type: "string", description: "Azure subscription ID (required for standard billing)." }, + resourceGroup: { type: "string", description: "Azure resource group (required for standard billing)." }, + region: { type: "string", description: "Azure region for standard billing. Default: eastus." }, + confirmBilling: { + type: "boolean", + description: + "Must be true to create the IRREVERSIBLE, CHARGEABLE standard Azure billing account " + + "(Microsoft.Syntex/accounts). Without it, standard provisioning returns a cost preview and " + + "makes no change. Ignored for trial billing and for resumes where billing is already set up.", + }, + seedSampleData: { type: "boolean", description: "Reserved: seed sample containers/docs after provisioning (see project_seed_sample_data)." }, + }, + }, + handler: async (args) => { + const state = readState(); + const { + containerTypeName, + containerName = "Default Container", + region = "eastus", + confirmBilling = false, + } = args as ProvisionArgs; + // `let` because standard-billing guided selection can resolve these in-band: + // the user picks (or we auto-select) a subscription + resource group, and the + // chosen values then flow through the region check, the confirmBilling gate, + // and billing-account creation below. `?? state.…` matches the prior + // destructuring defaults exactly (both only substitute on undefined). + let azureSubscriptionId = (args as ProvisionArgs).azureSubscriptionId ?? state.azureSubscriptionId; + let resourceGroup = (args as ProvisionArgs).resourceGroup ?? state.resourceGroup; + // `let` because native elicitation can resolve these in-band: a "different + // app" prompt supplies a name, and the billing prompt supplies the model. + // `?? "SPE Builder App"` matches the prior destructuring default exactly + // (both only substitute on undefined). + let appDisplayName = (args as ProvisionArgs).appDisplayName ?? "SPE Builder App"; + let billingClassification = (args as ProvisionArgs).billingClassification; + // An explicit appDisplayName targets that named app even when state holds + // another appId; absent one, resume by the persisted appId below. + const explicitAppName = + typeof args.appDisplayName === "string" && args.appDisplayName.trim() !== "" + ? args.appDisplayName + : undefined; + // The user's explicit decision about a remembered owning app: "reuse" the + // last one or use a "new"/different one. Undefined until asked. + let appSelection = + args.appSelection === "reuse" || args.appSelection === "new" ? args.appSelection : undefined; + + // Declared before the try so every early-return error path AND the catch-all + // below can surface how far provisioning got (partial-steps summary). + const steps: string[] = []; + + try { + // 0. Confirm signed-in identity (bootstrap/control plane). + const identity = await getSignedInIdentity(); + if (!identity) { + return { + content: [{ type: "text" as const, text: "⛔ Not signed in to Azure CLI. Run `az login --allow-no-subscriptions`, then retry." + partialProgress(steps, "sign-in check") }], + isError: true, + }; + } + + // Ask before silently reusing the last app (PM feedback: "it favors using + // the last one — it should ask"). Critical always-ask (r-appgate): fire + // whenever an app is remembered, this call carries no appSelection, and + // the context is NOT confirmed under the current session — so a freshly + // restarted process always re-asks even though state already holds an app. + // + // Prefer NATIVE MCP elicitation (PR #3 review): a capable client prompts + // the user directly and we CONTINUE in-band with their pick. Without + // elicitation support, elicitChoice falls back to the agent-guided text ask + // and the agent re-invokes with appSelection (reuse/new) — no loop, and + // behavior identical to before. An explicit appDisplayName alone no longer + // bypasses the ask on an unconfirmed session (appSelection is the + // new-vs-existing answer). Comes before the billing prompt so the app is + // settled first; the chosen app also drives which signed-in identity the + // SPE calls use. + if (state.appId && !appSelection && !isContextConfirmedThisSession(state)) { + const choice = await elicitChoice( + `You previously used the owning app "${state.appDisplayName ?? state.appId}". Reuse it, or use a different app?`, + [ + { + label: `Reuse "${state.appDisplayName ?? state.appId}"`, + value: "reuse", + description: `the remembered app (client ID ${state.appId})`, + }, + { + label: "Use a different app", + value: "new", + description: "create or target another owning app — also pass appDisplayName with its name", + }, + ], + "appSelection", + ); + if (!choice.resolved) return choice.result; + appSelection = choice.value as "reuse" | "new"; + // Native path resolved the ask in-band. For a "different app" with no + // explicit name, prompt for one too so the flow is complete instead of + // silently defaulting; on decline/no-capability we keep the default. + if (appSelection === "new" && !explicitAppName) { + const name = await elicitText("Name for the new owning app?", "appDisplayName", { + title: "New app name", + }); + if (name.resolved) appDisplayName = name.value; + } + } + + // Stamp the session confirmed as soon as the OWNING APP is settled — BEFORE + // the billing-model / sub-RG elicitations below can return an agent-guided + // ask and be re-invoked. Without this, if the agent drops `appSelection` + // while answering a LATER billing prompt, the always-ask app gate above + // would re-fire (it keys on an unconfirmed session), because confirmation + // was previously only stamped after app resolution — well after billing. + // Only the confirmation FLAG is written here (no app identity; the app is + // resolved further below, still behind the confirmBilling financial-safety + // gate). The full stamp with the resolved app fields runs after creation. + // This does NOT bypass confirmBilling, which is independent of session + // confirmation. (PR #3 review.) + stampContextConfirmed(); + + // Elicit billing model if not provided and not already standard in state. + // Native elicitation prompts the user and continues in-band; the fallback + // returns the agent-guided text ask (re-invoked with billingClassification). + if (!billingClassification) { + const choice = await elicitChoice( + "What billing model for your container type?", + [ + { label: "Trial", value: "trial", description: "free, 30 days, max 3 per tenant" }, + { label: "Standard", value: "standard", description: "billed to an Azure subscription you choose" }, + ], + "billingClassification", + ); + if (!choice.resolved) return choice.result; + billingClassification = choice.value as "trial" | "standard"; + } + + // For standard billing, guide the user to a subscription + resource group + // INLINE instead of punting to azure_subscriptions_list / + // azure_resource_groups_list and a manual re-invoke (PR #3 review). The + // helper lists the subscriptions, auto-selects a lone one (else prompts via + // native elicitation, falling back to the agent-guided ask), then lists the + // resource groups WITHIN the chosen subscription and resolves it the same + // way. Runs BEFORE the region check and the confirmBilling financial-safety + // gate so those still fire with the resolved target. On the fallback path + // the helper returns the agent-guided ask, which the orchestrator re-invokes + // with the chosen arg (threaded on re-invoke — no loop). + if (billingClassification === "standard" && (!azureSubscriptionId || !resourceGroup)) { + const target = await resolveStandardBillingTarget({ azureSubscriptionId, resourceGroup }); + if (!target.resolved) return target.result; + azureSubscriptionId = target.azureSubscriptionId; + resourceGroup = target.resourceGroup; + for (const note of target.notes) recordStep(steps, note); + } + + // Pre-flight: validate the Azure region BEFORE creating anything. A + // standard container type CANNOT be deleted (Graph 422 "Cannot delete + // container type for non trial"), so if we only discovered an unsupported + // region at billing-account creation time — after the CT already exists — + // the rollback would fail and leave an orphaned CT (observed with + // 'westus2'). Failing here keeps an invalid region cost-free and + // reversible. (per PR #3 review — provisioning safety) + if (billingClassification === "standard") { + try { + assertSyntexRegionSupported(region); + } catch (regionError) { + return { + content: [{ + type: "text" as const, + text: + `${regionError instanceof Error ? regionError.message : String(regionError)}` + + partialProgress(steps, "standard billing region validation"), + }], + isError: true, + }; + } + } + + // Financial-safety gate (per PR #3 review): standard billing creates a + // CHARGEABLE Microsoft.Syntex (RaaS) Azure account. Require an explicit + // confirmation before ANY owning app / container type / billing account is + // created, so a "build me an SPE app" request can never silently incur + // Azure cost. Skipped for trial. The skip for an idempotent resume is + // scoped to the SAME billing target — reusing the remembered app AND the + // same subscription/resource group — so a stale Syntex id from a previous + // (different) app can't wave through a brand-new chargeable account. The + // check is fail-closed: only a literal `true` proceeds. Makes no change. + const resumingSameBillingTarget = + !!state.syntexAccountResourceId && + appSelection !== "new" && + (!explicitAppName || explicitAppName === state.appDisplayName) && + azureSubscriptionId === state.azureSubscriptionId && + resourceGroup === state.resourceGroup; + if (billingClassification === "standard" && confirmBilling !== true && !resumingSameBillingTarget) { + return { + content: [{ + type: "text" as const, + text: + "### Confirm standard (paid) billing\n\n" + + "**Standard** billing will create a **chargeable** `Microsoft.Syntex/accounts` (RaaS) Azure " + + "billing account, plus a new owning Entra app and container type. This incurs Azure costs " + + "and **cannot be reverted to trial**.\n\n" + + `- **Subscription:** \`${azureSubscriptionId}\`\n` + + `- **Resource group:** ${resourceGroup}\n` + + `- **Region:** ${region}\n\n` + + "> Re-run **project_provision** with `confirmBilling=true` to proceed. For a free setup, " + + "re-run with `billingClassification=trial` instead. No changes were made.", + }], + }; + } + + // Least-privilege intent gate (PR #3 review): choose whether the owning app + // administers ALL container types in the tenant (broad *.Manage.All scopes, + // an admin/console app) or only its own container type (least privilege, + // standard ISV/LOB). Placed AFTER the billing gates so the app + billing are + // settled first. Fires whenever no intent is known — neither the arg nor + // persisted state has one. It intentionally does NOT also require an + // unconfirmed session: the app-confirmation stamp now happens earlier (as + // soon as the owning app is settled, above), so gating this on confirmation + // too would silently suppress the ownerScope ask on the same run. Resolving + // by arg / persisted state alone still prevents a loop — the agent + // re-invokes with ownerScope and provisioning persists it below. + let resolvedOwnerScope: OwnerScope | undefined = + args.ownerScope === "manage-all" || args.ownerScope === "selected" + ? args.ownerScope + : state.ownerScope; + if (resolvedOwnerScope === undefined) { + // Prefer NATIVE MCP elicitation (PR #3 review): a capable client prompts + // the user and we continue in-band with their pick; without support, + // elicitChoice falls back to the agent-guided text ask (re-invoked with + // ownerScope). Resumable, never loops. + const choice = await elicitChoice( + "Should this owning app manage ALL container types (an admin/console app), or just this one app's container type (standard ISV/LOB)?", + [ + { + label: "Manage all container types", + value: "manage-all", + description: "admin/console app — requests the broad *.Manage.All scopes for every container type", + }, + { + label: "This app only (least privilege)", + value: "selected", + description: "standard ISV/LOB — only the scopes needed for this app's own container type", + }, + ], + "ownerScope", + ); + if (!choice.resolved) return choice.result; + resolvedOwnerScope = choice.value as OwnerScope; + } + // Least privilege by default once the session is confirmed but no intent was + // recorded (e.g., an older resumed setup provisioned before this prompt). + const ownerScope: OwnerScope = resolvedOwnerScope ?? "selected"; + + // 1. Owning app (bootstrap token), idempotent. + const getToken = bootstrapTokenProvider; + // Resolution order: an EXPLICIT appDisplayName targets that named app + // (created if missing). Otherwise "reuse" (or a first run with nothing + // remembered) resumes by the persisted appId (stable identity), while + // "new" forces name/default resolution instead of the remembered id. + const resumeByAppId = !explicitAppName && appSelection !== "new" && !!state.appId; + let app = explicitAppName + ? await findApplicationByName(explicitAppName, getToken) + : resumeByAppId + ? await findApplicationByAppId(state.appId as string, getToken) + : await findApplicationByName(appDisplayName, getToken); + // Capture created-vs-reused BEFORE `app` is reassigned in the create branch: + // a found app is reused, a null result means we freshly create one below. + const reusedApp = !!app; + if (!app) { + app = await createApplication(appDisplayName, getToken); + recordStep(steps, `Created owning app **${app.displayName}** (\`${app.appId}\`)`); + // Create path: permissions are required, so errors propagate. + await addSpePermissions(app.objectId, getToken, { ownerScope }); + } else { + recordStep(steps, `Reused owning app **${app.displayName}** (\`${app.appId}\`)`); + // Attach/reuse path: adding permissions is best-effort and non-blocking. + await addSpePermissions(app.objectId, getToken, { bestEffort: true, ownerScope }); + } + // Mark this session confirmed (r-appgate) as the app is settled, and hand + // off to the owning-app token for SPE operations. Confirming here keeps the + // always-ask above from re-firing on later calls in the same process. + // Record the least-privilege intent too (PR #3 review). Both scope sets + // (manage-all AND selected) grant FileStorageContainerType.Manage.All, so a + // freshly CREATED owning app can enumerate all container types → flag true. + // For a REUSED app we defer the flag to the listContainerTypes call below, + // which self-corrects it from the live grant (a 403 sets it false). + stampContextConfirmed({ + tenantId: identity.tenantId, + appId: app.appId, + appObjectId: app.objectId, + appDisplayName: app.displayName, + ownerScope, + ...(reusedApp ? {} : { owningAppManagesAllContainerTypes: true }), + }); + setAuthConfig({ clientId: app.appId, tenantId: identity.tenantId }); + + // 2. Standard billing prerequisite: register the Syntex provider. + if (billingClassification === "standard" && azureSubscriptionId) { + const provider = await ensureSyntexProviderRegistered(azureSubscriptionId); + recordStep(steps, `Microsoft.Syntex provider: ${provider.registrationState}`); + } + + // 3. Container type (reuse by owning app — 1:1), with billing. + const ctName = containerTypeName ?? `${appDisplayName} Container Type`; + const existingCts = await listContainerTypes(); + let containerTypeId = + existingCts.find((c) => c.owningAppId?.toLowerCase() === app!.appId.toLowerCase())?.containerTypeId; + let createdCt = false; + if (!containerTypeId) { + const ct = await createContainerType({ + displayName: ctName, + owningAppId: app.appId, + billingClassification, + azureSubscriptionId: billingClassification === "standard" ? azureSubscriptionId : undefined, + resourceGroup: billingClassification === "standard" ? resourceGroup : undefined, + region: billingClassification === "standard" ? region : undefined, + }); + containerTypeId = ct.containerTypeId; + createdCt = true; + recordStep(steps, `Created container type **${ctName}** (\`${containerTypeId}\`, ${billingClassification})`); + } else { + recordStep(steps, `Reused container type \`${containerTypeId}\``); + } + + // 3a. Standard billing: create the Microsoft.Syntex (RaaS) ARM billing + // account (az). Orchestration parity with the VS Code extension: on ANY + // billing failure, roll back by deleting a JUST-CREATED CT (transactional); + // a reused/pre-existing CT is never deleted. + let syntexAccountResourceId: string | undefined; + if (billingClassification === "standard" && azureSubscriptionId && resourceGroup) { + // Idempotency: reuse an already-Succeeded Microsoft.Syntex account for this + // CT (mirrors billing_setup) instead of attempting a duplicate that would + // fail; only create one when none is attached yet. + const existingAccounts = await getSyntexAccounts(azureSubscriptionId, resourceGroup).catch(() => []); + syntexAccountResourceId = existingAccounts.find( + (a) => a.properties?.identityId === containerTypeId && a.properties?.provisioningState === "Succeeded", + )?.id; + try { + if (syntexAccountResourceId) { + recordStep(steps, `Reused Microsoft.Syntex billing account (\`${syntexAccountResourceId}\`)`); + } else { + syntexAccountResourceId = await createSyntexAccount( + azureSubscriptionId, resourceGroup, region, containerTypeId, + ); + recordStep(steps, `Created Microsoft.Syntex billing account (\`${syntexAccountResourceId}\`)`); + } + } catch (billingError) { + const billingMsg = billingError instanceof Error ? billingError.message : String(billingError); + if (createdCt && containerTypeId) { + let rollbackNote = " The just-created container type was rolled back (deleted)."; + try { + await deleteContainerType(containerTypeId); + } catch (rbErr) { + const rb = rbErr instanceof Error ? rbErr.message : String(rbErr); + rollbackNote = ` WARNING: rollback ALSO failed — container type \`${containerTypeId}\` may still exist and should be deleted manually (${rb}).`; + } + return { + content: [{ type: "text" as const, text: `Error during provisioning: standard billing account creation failed: ${billingMsg}.${rollbackNote} Fix the Azure billing prerequisite and re-run project_provision.` + partialProgress(steps, "standard billing account creation (Microsoft.Syntex)") }], + isError: true, + }; + } + return { + content: [{ type: "text" as const, text: `Error during provisioning: standard billing account creation failed: ${billingMsg}. The pre-existing container type was left intact — re-run billing_setup once the prerequisite is fixed.` + partialProgress(steps, "standard billing account creation (Microsoft.Syntex)") }], + isError: true, + }; + } + } + + writeState({ + containerTypeId, + containerTypeName: ctName, + billingClassification, + ...(billingClassification === "standard" + ? { azureSubscriptionId, resourceGroup, syntexAccountResourceId } + : {}), + }); + + // 4. Register on the tenant (required before containers). + await registerContainerType(containerTypeId, app.appId); + recordStep(steps, "Registered container type on tenant"); + + // 4a. Grant the signed-in user the `owner` role on the container type + // (Graph beta). Owners can create containers using a public client (PCA), + // so the deployed sample app's user — not just this bootstrap path — can + // create containers. Best-effort: the container type's creator is already + // an auto-owner, so a failure here is non-fatal. + try { + const me = await getSignedInUser(getToken); + await grantContainerTypeOwner(containerTypeId, me.id); + recordStep(steps, `Granted owner role to ${me.userPrincipalName ?? me.id} (enables PCA container creation)`); + } catch (grantError) { + recordStep(steps, `⚠️ Owner grant skipped: ${grantError instanceof Error ? grantError.message : String(grantError)}`); + } + + // 5. Create + activate a container, with propagation backoff. + let containerId = ""; + let lastError = ""; + let lastErrorClass: ClassifiableError = { message: "" }; + for (let attempt = 1; attempt <= CONTAINER_CREATE_MAX_ATTEMPTS; attempt++) { + try { + const container = await createContainer(containerTypeId, containerName); + containerId = container.id; + if (container.status !== "active") { + try { + await activateContainer(containerId); + } catch { + /* activation may lag; status will settle */ + } + } + break; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + lastErrorClass = toClassifiableError(error); + // Retry only genuine registration-propagation delays. A wrong/ + // unregistered container type (404) or authorization failure (403) + // is permanent — fail fast rather than hang ~150s through every + // backoff. Classify on the error object (HTTP status), not a message + // substring. + if (attempt < CONTAINER_CREATE_MAX_ATTEMPTS && isContainerPropagationError(lastErrorClass)) { + await sleep(containerCreateBackoffMs(attempt)); + continue; + } + break; + } + } + if (containerId) { + writeState({ containerId, containerName }); + recordStep(steps, `Created container **${containerName}** (\`${containerId}\`)`); + } else if (isContainerPropagationError(lastErrorClass)) { + // Transient: the grant is still propagating. The earlier steps DID + // succeed; the container can be created later with `container_create`. + recordStep(steps, `⚠️ Container creation still pending (registration propagation) — retry with \`container_create\`: ${lastError}`); + } else { + // Permanent: a typo'd/unknown containerTypeId or unrecoverable error. + // This will NOT self-resolve — surface it as a failure, not "pending". + recordStep(steps, `❌ Container creation FAILED (not recoverable by retry — check the container type ID): ${lastError}`); + } + + const summary = + "## SPE Provisioned\n\n" + + steps.map((s, i) => `${i + 1}. ${s}`).join("\n") + + "\n\n### Config\n\n" + + "| Key | Value |\n|-----|-------|\n" + + `| TENANT_ID | \`${identity.tenantId}\` |\n` + + `| CLIENT_ID | \`${app.appId}\` |\n` + + `| CONTAINER_TYPE_ID | \`${containerTypeId}\` |\n` + + `| CONTAINER_ID | \`${containerId || "(pending)"}\` |\n` + + (billingClassification === "standard" + ? `| SUBSCRIPTION_ID | \`${azureSubscriptionId}\` |\n| RESOURCE_GROUP | ${resourceGroup} |\n| SYNTEX_ACCOUNT | \`${syntexAccountResourceId ?? "(pending)"}\` |\n` + : "") + + "\n> Next: `project_hydrate_config` to write these into a project, then `project_scaffold` to generate an app." + + // NON-BLOCKING heads-up appended for a B2B guest identity (guests often + // lack permission to create Entra apps / own container types). Empty for + // a member; provisioning is never blocked by it. (PR #3 review.) + guestSignInAdvisory(identity.username); + + return { content: [{ type: "text" as const, text: summary }] }; + } catch (error) { + const msg = error instanceof Error ? error.message : "Unknown error"; + // A mid-flow throw (e.g. permissions, container-type, or registration + // call failing) used to surface only this message and hide how far the + // orchestration got. Include the completed steps so a partially-provisioned + // run is debuggable and resumable. + return { content: [{ type: "text" as const, text: `Error during provisioning: ${msg}` + partialProgress(steps, `an unexpected error (${msg})`) }], isError: true }; + } + }, +}; diff --git a/src/tools/provisioning.test.ts b/src/tools/provisioning.test.ts new file mode 100644 index 0000000..7f61c89 --- /dev/null +++ b/src/tools/provisioning.test.ts @@ -0,0 +1,279 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for Phase 1 provisioning tools: + * project_app_create, container_type_register, container_create. + * Graph client, bootstrap, auth, and state are mocked so these run offline. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../graph-client.js", () => ({ + findApplicationByName: vi.fn(), + findApplicationByAppId: vi.fn(), + createApplication: vi.fn(), + addSpePermissions: vi.fn(), + addSpaRedirectUris: vi.fn(), + registerContainerType: vi.fn(), + createContainer: vi.fn(), + activateContainer: vi.fn(), +})); +vi.mock("../bootstrap.js", () => ({ + bootstrapTokenProvider: vi.fn(async () => "boot-token"), + getSignedInIdentity: vi.fn(), +})); +vi.mock("../auth.js", () => ({ setAuthConfig: vi.fn() })); + +const stateStore: Record = {}; +vi.mock("../state.js", () => ({ + readState: vi.fn(() => ({ ...stateStore })), + writeState: vi.fn((patch: Record) => { + Object.assign(stateStore, patch); + return { ...stateStore }; + }), +})); + +import * as graph from "../graph-client.js"; +import * as bootstrap from "../bootstrap.js"; +import { setAuthConfig } from "../auth.js"; +import { createAppTool } from "../tools/create-app.js"; +import { registerContainerTypeTool } from "../tools/register-container-type.js"; +import { createContainerTool } from "../tools/create-container.js"; +import { getSessionId } from "../session.js"; + +beforeEach(() => { + vi.clearAllMocks(); + for (const k of Object.keys(stateStore)) delete stateStore[k]; +}); + +// ─── project_app_create ────────────────────────────────────────────────────────── + +describe("project_app_create", () => { + it("creates an owning app, adds permissions, persists state, points auth at it", async () => { + vi.mocked(bootstrap.getSignedInIdentity).mockResolvedValue({ tenantId: "t-1", username: "dev@x.com" }); + vi.mocked(graph.findApplicationByName).mockResolvedValue(null); + vi.mocked(graph.createApplication).mockResolvedValue({ appId: "app-1", objectId: "obj-1", displayName: "My App" }); + + const result = await createAppTool.handler({ displayName: "My App" }); + + expect(result.isError).toBeFalsy(); + expect(graph.createApplication).toHaveBeenCalledWith("My App", expect.any(Function)); + expect(graph.addSpePermissions).toHaveBeenCalledWith("obj-1", expect.any(Function), { ownerScope: "selected" }); + expect(setAuthConfig).toHaveBeenCalledWith({ clientId: "app-1", tenantId: "t-1" }); + expect(result.content[0].text).toContain("app-1"); + expect(stateStore.appId).toBe("app-1"); + }); + + it("reuses an existing app (idempotent) without creating", async () => { + // "Idempotent" here means: running the tool again with the same input yields + // the same result (the same owning app) WITHOUT creating a second/duplicate + // app or erroring. Below, an app already exists by that name, so the tool + // attaches to it (createApplication is never called). + vi.mocked(bootstrap.getSignedInIdentity).mockResolvedValue({ tenantId: "t-1", username: "dev@x.com" }); + vi.mocked(graph.findApplicationByName).mockResolvedValue({ appId: "app-9", objectId: "obj-9", displayName: "Existing" }); + + const result = await createAppTool.handler({ displayName: "Existing" }); + + expect(graph.createApplication).not.toHaveBeenCalled(); + expect(result.content[0].text).toContain("Found"); + expect(stateStore.appId).toBe("app-9"); + }); + + it("targets an explicit displayName even when state holds a different appId", async () => { + // Display names are NOT unique in Entra (two apps can share "Other App"), + // whereas the appId (client ID) is the unique key. An explicit displayName is + // a best-effort convenience lookup; once resolved, state persists the unique + // appId ("named-app") so later runs resume by appId rather than by name. + // r-appgate: the explicit-name fast path only applies on a CONFIRMED session; + // on a fresh restart the always-ask fires first (covered in create-app.test.ts), + // so seed confirmedSessionId to exercise the post-confirmation behavior here. + stateStore.appId = "persisted-app"; + stateStore.confirmedSessionId = getSessionId(); + vi.mocked(bootstrap.getSignedInIdentity).mockResolvedValue({ tenantId: "t-1", username: "dev@x.com" }); + vi.mocked(graph.findApplicationByName).mockResolvedValue({ appId: "named-app", objectId: "obj-2", displayName: "Other App" }); + + const result = await createAppTool.handler({ displayName: "Other App" }); + + expect(result.isError).toBeFalsy(); + // Explicit name -> resolve BY NAME, not by the persisted appId. + expect(graph.findApplicationByName).toHaveBeenCalledWith("Other App", expect.any(Function)); + expect(graph.findApplicationByAppId).not.toHaveBeenCalled(); + expect(stateStore.appId).toBe("named-app"); + }); + + it("asks before reusing a remembered app when no displayName/appSelection is given", async () => { + stateStore.appId = "persisted-app"; + stateStore.appDisplayName = "Remembered App"; + vi.mocked(bootstrap.getSignedInIdentity).mockResolvedValue({ tenantId: "t-1", username: "dev@x.com" }); + + const result = await createAppTool.handler({}); + + // Elicitation happens in create-app's handler: when an app is remembered but + // the caller gave no displayName/appSelection, it returns needChoice(...) + // (see src/elicitation.ts) — an agent-guided "choose one" prompt — instead of + // silently resuming. The `appSelection=reuse` hint below is needChoice's + // rendered option, and no graph lookup/create runs until the user chooses. + expect(result.content[0].text).toContain("Reuse"); + expect(result.content[0].text).toContain("appSelection=reuse"); + expect(graph.findApplicationByAppId).not.toHaveBeenCalled(); + expect(graph.findApplicationByName).not.toHaveBeenCalled(); + expect(graph.createApplication).not.toHaveBeenCalled(); + }); + + it("resumes the persisted appId when reuse is chosen", async () => { + stateStore.appId = "persisted-app"; + vi.mocked(bootstrap.getSignedInIdentity).mockResolvedValue({ tenantId: "t-1", username: "dev@x.com" }); + vi.mocked(graph.findApplicationByAppId).mockResolvedValue({ appId: "persisted-app", objectId: "obj-3", displayName: "SPE Builder App" }); + + const result = await createAppTool.handler({ appSelection: "reuse" }); + + expect(result.isError).toBeFalsy(); + expect(graph.findApplicationByAppId).toHaveBeenCalledWith("persisted-app", expect.any(Function)); + expect(graph.findApplicationByName).not.toHaveBeenCalled(); + expect(stateStore.appId).toBe("persisted-app"); + }); + + it("errors when not signed into az", async () => { + vi.mocked(bootstrap.getSignedInIdentity).mockResolvedValue(null); + + const result = await createAppTool.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("az login"); + expect(graph.createApplication).not.toHaveBeenCalled(); + }); + + it("surfaces Azure CLI *install* guidance when az is not installed", async () => { + // When az is missing entirely, getSignedInIdentity throws the not-installed + // error (from bootstrap.ts) rather than returning null. The handler's catch + // must propagate that guidance so the user is told HOW to install az — not + // just told to `az login`. Assert the install URL reaches the client. + vi.mocked(bootstrap.getSignedInIdentity).mockRejectedValue( + new Error( + "Azure CLI ('az') is not installed. Install it from https://aka.ms/install-azure-cli, " + + "then run `az login --allow-no-subscriptions`.", + ), + ); + + const result = await createAppTool.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("https://aka.ms/install-azure-cli"); + expect(graph.createApplication).not.toHaveBeenCalled(); + }); +}); + +// ─── container_type_register ───────────────────────────────────────────── + +describe("container_type_register", () => { + // r-appgate: container_type_register is a control-plane MUTATION, so it is + // gated by the restart confirmation guard. These tests exercise the tool's own + // logic on an ALREADY-confirmed session; the gate itself is covered in + // context-gate.test.ts. Seed confirmedSessionId so the gate no-ops here. + beforeEach(() => { + stateStore.confirmedSessionId = getSessionId(); + }); + + it("registers using state defaults", async () => { + // Precondition: in the real flow the containerTypeId is produced by + // container_type_create (tool `container_type_create`) and persisted to + // state. This unit test seeds it directly to keep the register tool isolated + // from the create tool — it verifies register reads its inputs from state, + // not the end-to-end create→register ordering (covered elsewhere). + stateStore.containerTypeId = "ct-1"; + stateStore.appId = "app-1"; + + const result = await registerContainerTypeTool.handler({}); + + expect(graph.registerContainerType).toHaveBeenCalledWith("ct-1", "app-1"); + expect(result.content[0].text).toContain("Registered"); + }); + + it("errors when no owning app is available", async () => { + stateStore.containerTypeId = "ct-1"; + + const result = await registerContainerTypeTool.handler({}); + + expect(result.isError).toBe(true); + // The error must tell the user HOW to get an owning app, not just that one is + // missing — it points them at project_app_create (or passing an explicit appId). + expect(result.content[0].text).toContain("project_app_create"); + expect(graph.registerContainerType).not.toHaveBeenCalled(); + }); +}); + +// ─── container_create ──────────────────────────────────────────────────── + +describe("container_create", () => { + it("creates and activates a container, persisting state", async () => { + stateStore.containerTypeId = "ct-1"; + vi.mocked(graph.createContainer).mockResolvedValue({ + id: "c-1", displayName: "Files", containerTypeId: "ct-1", status: "inactive", + }); + + const result = await createContainerTool.handler({ displayName: "Files" }); + + expect(graph.createContainer).toHaveBeenCalledWith("ct-1", "Files"); + expect(graph.activateContainer).toHaveBeenCalledWith("c-1"); + expect(result.content[0].text).toContain("Container Created"); + expect(stateStore.containerId).toBe("c-1"); + }); + + it("does not re-activate an already-active container", async () => { + stateStore.containerTypeId = "ct-1"; + vi.mocked(graph.createContainer).mockResolvedValue({ + id: "c-2", displayName: "Files", containerTypeId: "ct-1", status: "active", + }); + + await createContainerTool.handler({ displayName: "Files" }); + + expect(graph.activateContainer).not.toHaveBeenCalled(); + }); + + it("errors when no container type is available", async () => { + const result = await createContainerTool.handler({ displayName: "Files" }); + expect(result.isError).toBe(true); + expect(graph.createContainer).not.toHaveBeenCalled(); + }); + + // permanent errors must fail fast (no ~150s propagation hang). + it("fails fast on a permanent invalid containerTypeId (no retry)", async () => { + stateStore.containerTypeId = "bad-ct"; + vi.mocked(graph.createContainer).mockRejectedValue( + new Error("Resource not found: container type does not exist"), + ); + + const result = await createContainerTool.handler({ displayName: "Files" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("after 1 attempt"); + // Exactly one attempt — no transient-style retry/backoff. + expect(graph.createContainer).toHaveBeenCalledTimes(1); + expect(graph.activateContainer).not.toHaveBeenCalled(); + }); + + it("retries through a registration-propagation delay, then succeeds", async () => { + vi.useFakeTimers(); + try { + stateStore.containerTypeId = "ct-1"; + vi.mocked(graph.createContainer) + .mockRejectedValueOnce(new Error("Container type is not registered on this tenant yet")) + .mockResolvedValueOnce({ + id: "c-9", displayName: "Files", containerTypeId: "ct-1", status: "active", + }); + + const promise = createContainerTool.handler({ displayName: "Files" }); + // Advance past the first backoff (15s) so the retry runs. + await vi.advanceTimersByTimeAsync(15_000); + const result = await promise; + + expect(result.isError).toBeFalsy(); + expect(graph.createContainer).toHaveBeenCalledTimes(2); + expect(result.content[0].text).toContain("Container Created"); + expect(stateStore.containerId).toBe("c-9"); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/tools/register-container-type.ts b/src/tools/register-container-type.ts new file mode 100644 index 0000000..95294fb --- /dev/null +++ b/src/tools/register-container-type.ts @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tool: container_type_register + * + * Registers a container type on the local tenant with the owning app's + * application permission grants. This MUST run before containers can be created + * — without `applicationPermissionGrants` the platform rejects container + * creation with UnauthorizedAccessException (full-setup skill 04.2, gotchas #3). + * + * Idempotent: the PUT registration endpoint is safe to call repeatedly. + */ + +import { registerContainerType } from "../graph-client.js"; +import { readState, writeState } from "../state.js"; +import { resolveContextGate } from "./context-gate.js"; +import type { McpTool } from "../types.js"; + +interface RegisterArgs { + containerTypeId?: string; + appId?: string; + contextChoice?: "confirm" | "switch"; +} + +export const registerContainerTypeTool: McpTool = { + name: "container_type_register", + annotations: { plane: "control" }, + description: + "Register a SharePoint Embedded container type on the local tenant with the owning app's " + + "permission grants. Required before any containers can be created. Defaults the container " + + "type ID and owning app ID from the current provisioning state when omitted.", + inputSchema: { + type: "object" as const, + properties: { + containerTypeId: { + type: "string", + description: "The container type ID to register. Defaults to the most recently created one.", + }, + appId: { + type: "string", + description: "The owning app (client) ID to grant. Defaults to the provisioned owning app.", + }, + contextChoice: { + type: "string", + enum: ["confirm", "switch"], + description: + "On a freshly restarted session, confirm the remembered owning app / container type " + + "('confirm') or switch to a different one ('switch'). Supplied in response to the " + + "confirmation prompt; omit on the first call.", + }, + }, + }, + handler: async (args) => { + try { + // Restart confirmation gate (r-appgate): confirm the remembered owning app / + // container type before mutating tenant registration on a fresh session. + // Inside the try so a stamp-write failure on `contextChoice=confirm` + // (writeState / writeSecureFile) is classified by this tool's own error + // handling below, like its other errors, rather than the generic dispatch + // catch. (PR #3 review.) + const gate = await resolveContextGate((args as RegisterArgs).contextChoice); + if (gate) return gate; + + const state = readState(); + const { containerTypeId = state.containerTypeId, appId = state.appId } = args as RegisterArgs; + + if (!containerTypeId) { + return { + content: [{ type: "text" as const, text: "Error: containerTypeId is required (none in state)." }], + isError: true, + }; + } + if (!appId) { + return { + content: [{ type: "text" as const, text: "Error: appId is required (no owning app in state). Run project_app_create first." }], + isError: true, + }; + } + + await registerContainerType(containerTypeId, appId); + writeState({ containerTypeId }); + + const output = + "## Container Type Registered\n\n" + + "| Property | Value |\n|----------|-------|\n" + + `| **Container Type ID** | \`${containerTypeId}\` |\n` + + `| **Owning App** | \`${appId}\` |\n` + + `| **Permissions** | delegated: full · application: none (opt-in) |\n\n` + + "> Registration can take 10–30s to propagate. Container creation retries automatically."; + + return { content: [{ type: "text" as const, text: output }] }; + } catch (error) { + const msg = error instanceof Error ? error.message : "Unknown error"; + return { + content: [{ type: "text" as const, text: `Error registering container type: ${msg}` }], + isError: true, + }; + } + }, +}; diff --git a/src/tools/registry.test.ts b/src/tools/registry.test.ts new file mode 100644 index 0000000..5e0468d --- /dev/null +++ b/src/tools/registry.test.ts @@ -0,0 +1,230 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for the tool registry and server setup. + * + * Validates that all tools are registered, have valid schemas, + * and the MCP server dispatches correctly. + */ + +import { describe, it, expect, vi } from "vitest"; + +// Mock auth to avoid real MSAL initialization +vi.mock("../auth.js", () => ({ + getAccessToken: vi.fn().mockResolvedValue("mock-token"), + initializeAuth: vi.fn().mockResolvedValue(undefined), + setAuthConfig: vi.fn(), +})); + +// Get tool list by reading index.ts tool registration +// We re-import the tools directly to validate their metadata +import { listContainerTypesTool } from "../tools/list-container-types.js"; +import { createContainerTypeTool } from "../tools/create-container-type.js"; +import { listContainersTool } from "../tools/list-containers.js"; +import { getContainerTool } from "../tools/get-container.js"; +import { managePermissionsTool } from "../tools/manage-permissions.js"; +import { archiveRestoreTool } from "../tools/archive-restore.js"; +import { deleteContainerTool } from "../tools/delete-container.js"; +import { uploadFileTool } from "../tools/upload-file.js"; +import { createFolderTool } from "../tools/create-folder.js"; +import { searchContentTool } from "../tools/search-content.js"; +import { previewFileTool } from "../tools/preview-file.js"; +import { manageSharingTool } from "../tools/manage-sharing.js"; +import { checkBillingTool } from "../tools/check-billing.js"; +import { setupBillingTool } from "../tools/setup-billing.js"; +import { searchDocsTool, fetchDocTool } from "../tools/search-docs.js"; +import { statusTool } from "../tools/status.js"; +import { createAppTool } from "../tools/create-app.js"; +import { registerContainerTypeTool } from "../tools/register-container-type.js"; +import { getContainerTypeTool, updateContainerTypeTool, deleteContainerTypeTool } from "../tools/container-type-crud.js"; +import { grantContainerTypeOwnerTool, listContainerTypeOwnersTool, revokeContainerTypeOwnerTool } from "../tools/container-type-permissions.js"; +import { addContainerTypeAppGrantTool, listContainerTypeAppGrantsTool, removeContainerTypeAppGrantTool } from "../tools/container-type-app-grants.js"; +import { getContainerTypeRegistrationTool, listContainerTypeRegistrationsTool, deleteContainerTypeRegistrationTool } from "../tools/container-type-registration.js"; +import { createContainerTool } from "../tools/create-container.js"; +import { updateContainerTool } from "../tools/update-container.js"; +import { listDeletedContainersTool } from "../tools/list-deleted-containers.js"; +import { provisionTool } from "../tools/provision.js"; +import { listSubscriptionsTool, listResourceGroupsTool } from "../tools/list-azure.js"; +import { hydrateConfigTool } from "../tools/hydrate-config.js"; +import { scaffoldTool } from "../tools/scaffold.js"; +import { seedSampleDataTool } from "../tools/seed-sample-data.js"; +import { runLocalTool } from "../tools/run-local.js"; +import { deployAzureTool } from "../tools/deploy-azure.js"; +import { grantContentAccessTool, revokeContentAccessTool } from "../tools/content-access.js"; +import { cleanupTool } from "../tools/cleanup.js"; +import type { McpTool } from "../types.js"; + +const ALL_TOOLS: McpTool[] = [ + statusTool, + createAppTool, + provisionTool, + listContainerTypesTool, + createContainerTypeTool, + registerContainerTypeTool, + getContainerTypeTool, + updateContainerTypeTool, + deleteContainerTypeTool, + grantContainerTypeOwnerTool, + listContainerTypeOwnersTool, + revokeContainerTypeOwnerTool, + addContainerTypeAppGrantTool, + listContainerTypeAppGrantsTool, + removeContainerTypeAppGrantTool, + getContainerTypeRegistrationTool, + listContainerTypeRegistrationsTool, + deleteContainerTypeRegistrationTool, + createContainerTool, + listContainersTool, + getContainerTool, + updateContainerTool, + managePermissionsTool, + archiveRestoreTool, + deleteContainerTool, + listDeletedContainersTool, + uploadFileTool, + createFolderTool, + searchContentTool, + previewFileTool, + manageSharingTool, + checkBillingTool, + setupBillingTool, + listSubscriptionsTool, + listResourceGroupsTool, + hydrateConfigTool, + scaffoldTool, + seedSampleDataTool, + runLocalTool, + deployAzureTool, + grantContentAccessTool, + revokeContentAccessTool, + cleanupTool, + searchDocsTool, + fetchDocTool, +]; + +describe("Tool Registry", () => { + it("has 45 tools registered", () => { + expect(ALL_TOOLS).toHaveLength(45); + }); + + it("all tools have unique names", () => { + const names = ALL_TOOLS.map(t => t.name); + expect(new Set(names).size).toBe(names.length); + }); + + it("all tool names use portable grouped snake_case format", () => { + for (const tool of ALL_TOOLS) { + // Permanent portable-format invariant (replaces the legacy /^spe_/ prefix check). + expect(tool.name).toMatch(/^[a-z][a-z0-9_]*$/); + expect(tool.name).toMatch(/^[a-zA-Z0-9_-]{1,64}$/); + expect(tool.name.length).toBeLessThanOrEqual(64); + } + }); + + it("all tools have descriptions", () => { + for (const tool of ALL_TOOLS) { + expect(tool.description).toBeTruthy(); + expect(tool.description.length).toBeGreaterThan(20); + } + }); + + it("all tools have valid input schemas", () => { + for (const tool of ALL_TOOLS) { + expect(tool.inputSchema.type).toBe("object"); + expect(tool.inputSchema.properties).toBeDefined(); + } + }); + + it("all tools have handler functions", () => { + for (const tool of ALL_TOOLS) { + expect(typeof tool.handler).toBe("function"); + } + }); + + it("tool names match expected catalog", () => { + const expected = [ + "status_get", + "project_app_create", + "project_provision", + "container_type_list", + "container_type_create", + "container_type_register", + "container_type_get", + "container_type_update", + "container_type_delete", + "container_type_grant_owner", + "container_type_owners_list", + "container_type_revoke_owner", + "container_type_app_grant_add", + "container_type_app_grants_list", + "container_type_app_grant_remove", + "container_type_registration_get", + "container_type_registration_list", + "container_type_registration_delete", + "container_create", + "container_list", + "container_get", + "container_update", + "container_permissions_manage", + "container_archive_restore", + "container_delete", + "container_deleted_list", + "content_file_upload", + "content_folder_create", + "content_search", + "content_file_preview", + "content_sharing_manage", + "billing_check", + "billing_setup", + "azure_subscriptions_list", + "azure_resource_groups_list", + "project_hydrate_config", + "project_scaffold", + "project_seed_sample_data", + "project_run_local", + "project_deploy", + "content_access_grant", + "content_access_revoke", + "project_cleanup", + "docs_search", + "docs_fetch", + ]; + const actual = ALL_TOOLS.map(t => t.name).sort(); + expect(actual).toEqual(expected.sort()); + }); +}); + +describe("Tool Input Validation", () => { + it("tools with required params list them in schema", () => { + // Tools that should have required params + const toolsWithRequired = [ + createContainerTypeTool, + getContainerTool, + managePermissionsTool, + archiveRestoreTool, + deleteContainerTool, + uploadFileTool, + createFolderTool, + searchContentTool, + previewFileTool, + manageSharingTool, + listResourceGroupsTool, + ]; + + for (const tool of toolsWithRequired) { + expect(tool.inputSchema.required?.length).toBeGreaterThan(0); + } + }); + + it("listContainerTypes has no required params", () => { + expect(listContainerTypesTool.inputSchema.required).toBeUndefined(); + }); + + it("container_list and billing_check have no required params (default containerTypeId from state)", () => { + // These read tools default the container type from provisioning state, so a + // 0-knowledge developer can call them with no arguments after provisioning. + expect(listContainersTool.inputSchema.required).toBeUndefined(); + expect(checkBillingTool.inputSchema.required).toBeUndefined(); + }); +}); diff --git a/src/tools/run-local.test.ts b/src/tools/run-local.test.ts new file mode 100644 index 0000000..65b79ad --- /dev/null +++ b/src/tools/run-local.test.ts @@ -0,0 +1,207 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for project_run_local. + * + * Focus: + * - The reported URL/port is derived from the detected project type + * (Vite → 5173, not the old hardcoded 3000; Node default 3000; .NET 5000). + * - A failed process launch (spawn error / ENOENT) is reflected as isError + * instead of a false "running" success. + * + * node:child_process and node:fs are mocked so nothing actually spawns. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { EventEmitter } from "node:events"; + +let files: Record = {}; +let spawnBehavior: "spawn" | "error" | "exit-nonzero" = "spawn"; +const spawnError = "spawn npm ENOENT"; + +vi.mock("node:fs", () => ({ + existsSync: vi.fn((p: string) => Object.keys(files).some((f) => String(p).endsWith(f))), + readFileSync: vi.fn((p: string) => { + const key = Object.keys(files).find((f) => String(p).endsWith(f)); + if (key) return files[key]; + throw new Error("ENOENT"); + }), +})); + +vi.mock("node:child_process", () => ({ + spawn: vi.fn(() => { + const child = new EventEmitter() as EventEmitter & { unref: () => void }; + child.unref = () => {}; + queueMicrotask(() => { + if (spawnBehavior === "error") { + child.emit("error", new Error(spawnError)); + } else if (spawnBehavior === "exit-nonzero") { + // win32 shell:true false-success path: the OS spawns cmd.exe ('spawn' + // fires), then the shell exits non-zero because the toolchain is missing. + child.emit("spawn"); + child.emit("exit", 1, null); + } else { + // Healthy launch: 'spawn' fires, then a clean exit. The dev server uses + // startDetached (ignores a code-0 close and resolves on its grace timer), + // while `npm install` uses runToCompletion (resolves on this close). + child.emit("spawn"); + child.emit("close", 0, null); + } + }); + return child; + }), +})); + +// Readiness probe is mocked so tests never open real sockets; serverReady +// controls whether the launched server is reported as reachable. +let serverReady = true; +vi.mock("../server-readiness.js", () => ({ + waitForServerReady: vi.fn(async () => serverReady), +})); + +import { spawn } from "node:child_process"; +import { waitForServerReady } from "../server-readiness.js"; +import { runLocalTool } from "../tools/run-local.js"; + +function pkg(scripts: Record): string { + return JSON.stringify({ name: "app", scripts }); +} + +beforeEach(() => { + vi.clearAllMocks(); + files = {}; + spawnBehavior = "spawn"; + serverReady = true; +}); + +describe("project_run_local — URL/port detection", () => { + it("reports Vite's 5173 (not the old hardcoded 3000) for a React/Vite project", async () => { + files = { "package.json": pkg({ dev: "vite", build: "vite build" }) }; + + const result = await runLocalTool.handler({ projectDir: "/proj" }); + + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain("http://localhost:5173"); + expect(result.content[0].text).not.toContain("3000"); + }); + + it("honors an explicit --port flag in the dev script", async () => { + files = { "package.json": pkg({ dev: "vite --port 4280" }) }; + + const result = await runLocalTool.handler({ projectDir: "/proj" }); + + expect(result.content[0].text).toContain("http://localhost:4280"); + }); + + it("falls back to 3000 for a generic Node dev server", async () => { + files = { "package.json": pkg({ dev: "node server.js" }) }; + + const result = await runLocalTool.handler({ projectDir: "/proj" }); + + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain("http://localhost:3000"); + }); + + it("detects a .NET project and reports 5000", async () => { + files = { "Program.cs": "// app" }; + + const result = await runLocalTool.handler({ projectDir: "/proj" }); + + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain("http://localhost:5000"); + expect(spawn).toHaveBeenCalledWith("dotnet", ["run"], expect.objectContaining({ detached: true })); + }); + + it("errors when no runnable project is present", async () => { + files = {}; + const result = await runLocalTool.handler({ projectDir: "/proj" }); + expect(result.isError).toBe(true); + expect(spawn).not.toHaveBeenCalled(); + }); +}); + +describe("project_run_local — start-outcome reflection", () => { + it("surfaces a spawn failure as isError instead of false success (Node)", async () => { + files = { "package.json": pkg({ dev: "vite" }) }; + spawnBehavior = "error"; + + const result = await runLocalTool.handler({ projectDir: "/proj" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("failed to start"); + expect(result.content[0].text).not.toContain("App Running Locally"); + }); + + it("surfaces a spawn failure as isError instead of false success (.NET)", async () => { + files = { "Program.cs": "// app" }; + spawnBehavior = "error"; + + const result = await runLocalTool.handler({ projectDir: "/proj" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("failed to start"); + }); + + it("surfaces a non-zero early EXIT as isError (win32 shell:true false-success path, Node)", async () => { + files = { "package.json": pkg({ dev: "vite" }) }; + spawnBehavior = "exit-nonzero"; + + const result = await runLocalTool.handler({ projectDir: "/proj" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("failed to start"); + expect(result.content[0].text).not.toContain("App Running Locally"); + }); + + it("surfaces a non-zero early EXIT as isError (.NET)", async () => { + files = { "Program.cs": "// app" }; + spawnBehavior = "exit-nonzero"; + + const result = await runLocalTool.handler({ projectDir: "/proj" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("failed to start"); + }); +}); + +describe("project_run_local — readiness verification", () => { + it("returns the URL only after the server is verified ready", async () => { + files = { "package.json": pkg({ dev: "vite" }) }; + serverReady = true; + + const result = await runLocalTool.handler({ projectDir: "/proj" }); + + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain("http://localhost:5173"); + expect(result.content[0].text).toContain("accepting connections"); + // Surfaces the server-side Entra app-registration sign-in note + // so a SPA redirect-URI failure isn't mistaken for a stale dev server. + expect(result.content[0].text).toContain("AADSTS9002326"); + expect(result.content[0].text).toContain("not** picked up by client hot-reload"); + // The probe is driven off the detected dev-server port (Vite → 5173). + expect(waitForServerReady).toHaveBeenCalledWith(5173); + }); + + it("returns a failure (no URL) when the server launches but never becomes ready", async () => { + files = { "package.json": pkg({ dev: "vite" }) }; + serverReady = false; + + const result = await runLocalTool.handler({ projectDir: "/proj" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("never became ready"); + expect(result.content[0].text).not.toContain("App Running Locally"); + }); + + it("probes the .NET port (5000) and returns the URL once ready", async () => { + files = { "Program.cs": "// app" }; + serverReady = true; + + const result = await runLocalTool.handler({ projectDir: "/proj" }); + + expect(result.isError).toBeFalsy(); + expect(waitForServerReady).toHaveBeenCalledWith(5000); + expect(result.content[0].text).toContain("http://localhost:5000"); + }); +}); diff --git a/src/tools/run-local.ts b/src/tools/run-local.ts new file mode 100644 index 0000000..fdd9bf7 --- /dev/null +++ b/src/tools/run-local.ts @@ -0,0 +1,296 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tool: project_run_local + * + * Installs dependencies and starts the scaffolded reference app locally, then + * reports the local URL(s). Detects the project type (Node vs .NET) and derives + * the correct dev-server port from the project (e.g. Vite serves 5173, not 3000). + * Ports EVAL.md `run-local`. + * + * The dev server is started detached so the MCP server stays responsive. The + * tool waits for the immediate spawn outcome so a failed launch (e.g. a missing + * toolchain / ENOENT) is reflected as an error instead of a false "running" + * success, and then probes the derived port for readiness (a bounded TCP + * connect poll) so the URL is only returned once the server is actually + * accepting connections — never a URL that refuses. + */ + +import { spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { LOCAL_DEV_PORT } from "../constants.js"; +import { waitForServerReady } from "../server-readiness.js"; +import type { McpTool } from "../types.js"; + +interface RunLocalArgs { + projectDir?: string; +} + +interface DetectedProject { + kind: "node" | "dotnet"; + port: number; + url: string; +} + +/** + * Derive the dev-server port a Node project actually serves on, instead of + * assuming 3000. An explicit `--port N` in the dev/start script wins; otherwise + * a Vite project serves 5173 by default and other Node servers fall back to 3000. + */ +function detectNodePort(dir: string): number { + try { + const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as { + scripts?: Record; + }; + const scripts = pkg.scripts ?? {}; + const dev = String(scripts.dev ?? ""); + const start = String(scripts.start ?? ""); + const combined = `${dev} ${start}`; + + // Explicit port flag in the script (e.g. "vite --port 4280") takes priority. + const portFlag = combined.match(/--port[=\s]+(\d{2,5})/); + if (portFlag) return Number(portFlag[1]); + + // Vite's dev server port — our scaffold pins it to LOCAL_DEV_PORT in + // vite.config. + if (/\bvite\b/.test(combined)) return LOCAL_DEV_PORT; + // create-react-app / Next.js default to 3000. + if (/\b(react-scripts|next)\b/.test(combined)) return 3000; + } catch { + // Fall through to the generic Node default on any parse/read failure. + } + return 3000; +} + +function detectProject(dir: string): DetectedProject | null { + if (existsSync(join(dir, "package.json"))) { + const port = detectNodePort(dir); + return { kind: "node", port, url: `http://localhost:${port}` }; + } + // any .csproj or Program.cs implies a .NET project + if (existsSync(join(dir, "Program.cs"))) { + return { kind: "dotnet", port: 5000, url: "http://localhost:5000" }; + } + return null; +} + +interface SpawnOutcome { + ok: boolean; + error?: string; +} + +/** + * Spawn a detached process and resolve with its immediate launch outcome: + * `ok:false` if the OS could not start it (e.g. ENOENT for a missing command) + * OR if it exits with a non-zero code within the grace window (the win32 + * `shell:true` case, where a missing toolchain spawns cmd.exe and only the shell + * exit code reveals the failure); `ok:true` once it has spawned and survived the + * grace window without an early non-zero exit. Resolves optimistically after the + * short grace period so we never block the MCP server. + * + * Note: we deliberately do NOT resolve success on `'spawn'` alone — `'spawn'` + * only means the OS created the process (or the shell), not that the underlying + * command exists. We wait out the grace window so an early non-zero exit can + * still flip the outcome to failure. + */ +function startDetached(command: string, args: string[], cwd: string): Promise { + return new Promise((resolveOutcome) => { + let settled = false; + const finish = (outcome: SpawnOutcome): void => { + if (settled) return; + settled = true; + clearTimeout(grace); + resolveOutcome(outcome); + }; + + let child; + try { + child = spawn(command, args, { + cwd, + detached: true, + stdio: "ignore", + shell: process.platform === "win32", + }); + } catch (error) { + finish({ ok: false, error: error instanceof Error ? error.message : String(error) }); + return; + } + + const detachedChild = child; + const grace = setTimeout(() => { + // Survived the grace window with no error or early non-zero exit — treat + // as launched and detach so it outlives this process. + try { + detachedChild.unref(); + } catch { + /* noop */ + } + finish({ ok: true }); + }, 400); + if (typeof grace.unref === "function") grace.unref(); + + child.once("error", (error: Error) => { + finish({ ok: false, error: error.message }); + }); + + // A non-zero exit/close within the grace window means the launch failed + // (the primary win32 shell:true false-success path). A clean (code 0) or + // signal-terminated early exit is unusual for a dev server but not an error + // we can attribute, so we let the grace timer resolve optimistically. + const onEarlyExit = (code: number | null): void => { + if (typeof code === "number" && code !== 0) { + finish({ ok: false, error: `process exited with code ${code} before startup completed` }); + } + }; + child.once("exit", onEarlyExit); + child.once("close", onEarlyExit); + + child.once("spawn", () => { + // Detach early so a successful server is unref'd, but do NOT resolve here: + // wait for the grace window so an early non-zero exit can still win. + try { + detachedChild.unref(); + } catch { + /* noop */ + } + }); + }); +} + +/** + * Run a command to COMPLETION (resolve on exit/close, not after a grace window). + * + * Used for `npm install`: the dependency install MUST finish before the dev + * server starts. Starting `npm run dev` while `npm install` is still writing to + * `node_modules` (e.g. Vite's `node_modules/.vite` dependency-optimization + * cache) makes the dev server re-optimize/stall and never bind its port — a + * fresh scaffold then fails readiness even though the toolchain is fine. Running + * install first eliminates that race. Resolves `ok:false` (non-fatal) on a + * spawn error or non-zero exit so the caller can still attempt the dev server + * (e.g. when dependencies are already present and only the dev launch matters). + */ +function runToCompletion(command: string, args: string[], cwd: string): Promise { + return new Promise((resolveOutcome) => { + let settled = false; + const finish = (outcome: SpawnOutcome): void => { + if (settled) return; + settled = true; + resolveOutcome(outcome); + }; + + let child; + try { + child = spawn(command, args, { + cwd, + stdio: "ignore", + shell: process.platform === "win32", + }); + } catch (error) { + finish({ ok: false, error: error instanceof Error ? error.message : String(error) }); + return; + } + + child.once("error", (error: Error) => finish({ ok: false, error: error.message })); + const onDone = (code: number | null): void => { + finish(typeof code === "number" && code !== 0 + ? { ok: false, error: `process exited with code ${code}` } + : { ok: true }); + }; + child.once("exit", onDone); + child.once("close", onDone); + }); +} + +export const runLocalTool: McpTool = { + name: "project_run_local", + annotations: { localRequired: true }, + description: + "Install dependencies and start the scaffolded SharePoint Embedded app locally, returning the " + + "local URL. Detects Node (npm) or .NET (dotnet) projects and reports the actual dev-server port " + + "(e.g. Vite's 5173). Run after scaffolding and hydrating config.", + inputSchema: { + type: "object" as const, + properties: { + projectDir: { type: "string", description: "The scaffolded project directory. Default: current directory." }, + }, + }, + handler: async (args) => { + const { projectDir = process.cwd() } = args as RunLocalArgs; + const dir = resolve(projectDir); + + const project = detectProject(dir); + if (!project) { + return { + content: [{ type: "text" as const, text: `Error: no runnable project found in \`${dir}\` (expected package.json or Program.cs). Scaffold first.` }], + isError: true, + }; + } + + try { + let outcome: SpawnOutcome; + if (project.kind === "node") { + // Install dependencies to COMPLETION first, THEN start the dev server. + // Running them concurrently lets `npm install` mutate node_modules while + // the dev server is optimizing dependencies, which stalls the server so + // it never binds its port (a fresh scaffold then fails readiness). The + // install outcome is non-fatal — if deps are already present a failed/ + // partial install still lets the dev server launch — so we proceed and + // let the dev server's own launch + readiness drive success/failure. + await runToCompletion("npm", ["install"], dir); + outcome = await startDetached("npm", ["run", "dev"], dir); + } else { + outcome = await startDetached("dotnet", ["run"], dir); + } + + if (!outcome.ok) { + const cmd = project.kind === "node" ? "npm run dev" : "dotnet run"; + return { + content: [{ + type: "text" as const, + text: + `Error: failed to start the ${project.kind === "node" ? "Node" : ".NET"} app in \`${dir}\`.\n\n` + + `\`${cmd}\` could not be launched: ${outcome.error ?? "unknown error"}.\n\n` + + `> Ensure the required toolchain (${project.kind === "node" ? "Node.js / npm" : ".NET SDK"}) is installed and on PATH, then retry.`, + }], + isError: true, + }; + } + + // The process launched and survived the spawn grace window, but that does + // NOT mean it is accepting connections yet (or that it won't crash during + // startup). Probe the derived port for readiness before handing back a URL + // so we never report a URL that refuses connections. + const ready = await waitForServerReady(project.port); + if (!ready) { + const cmd = project.kind === "node" ? "npm run dev" : "dotnet run"; + return { + content: [{ + type: "text" as const, + text: + `Error: the ${project.kind === "node" ? "Node" : ".NET"} app in \`${dir}\` launched but never became ready on ${project.url}.\n\n` + + `\`${cmd}\` did not start accepting connections in time.\n\n` + + `> Check the dev server output for a startup error (e.g. a missing dependency or a port conflict), then retry.`, + }], + isError: true, + }; + } + + const output = + "## App Running Locally 🚀\n\n" + + `The ${project.kind === "node" ? "Node" : ".NET"} app in \`${dir}\` is up and accepting connections.\n\n` + + `→ ${project.url}\n\n` + + "> The dev server is running in the background. Open the URL above.\n\n" + + "> Sign-in note: if sign-in fails with `AADSTS9002326` (cross-origin SPA token " + + "redemption) or `AADSTS50011` (redirect URI mismatch), that is a **server-side " + + "Entra app-registration** change — the owning app must list this origin as a " + + "Single-page application (SPA) redirect URI. Re-provision/redeploy (`project_deploy`) " + + "to apply it; app-registration changes are **not** picked up by client hot-reload."; + return { content: [{ type: "text" as const, text: output }] }; + } catch (error) { + const msg = error instanceof Error ? error.message : "Unknown error"; + return { content: [{ type: "text" as const, text: `Error starting app: ${msg}` }], isError: true }; + } + }, +}; diff --git a/src/tools/scaffold.ts b/src/tools/scaffold.ts new file mode 100644 index 0000000..4ff9119 --- /dev/null +++ b/src/tools/scaffold.ts @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tool: project_scaffold + * + * Materializes a chosen reference architecture into a workspace directory, + * ready to run locally and deploy to Azure. Called with no `architecture` it + * lists the available options (agent-guided elicitation). Ports EVAL.md + * `scaffold-project`. + */ + +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { REFERENCE_ARCHITECTURES, findArchitecture } from "../reference-architectures.js"; +import { writeState } from "../state.js"; +import type { McpTool } from "../types.js"; +interface ScaffoldArgs { + architecture?: string; + targetDir?: string; + projectName?: string; +} + +export const scaffoldTool: McpTool = { + name: "project_scaffold", + annotations: { localRequired: true }, + description: + "Scaffold a SharePoint Embedded reference architecture into a project directory (runnable " + + "locally, deployable to Azure unchanged). Call with no 'architecture' to list available " + + "options, then call again with the chosen architecture id and a target directory.", + inputSchema: { + type: "object" as const, + properties: { + architecture: { + type: "string", + description: "Reference architecture id (e.g., 'react-spa-functions', 'csharp-web'). Omit to list options.", + }, + targetDir: { type: "string", description: "Directory to scaffold into. Default: ./." }, + projectName: { type: "string", description: "Project name. Default: 'spe-app'." }, + }, + }, + handler: async (args) => { + const { architecture, targetDir, projectName = "spe-app" } = args as ScaffoldArgs; + + // No architecture → list options (agent-guided elicitation). + if (!architecture) { + let text = "### Which reference architecture?\n\n"; + for (const a of REFERENCE_ARCHITECTURES) { + text += `- **${a.name}** — \`architecture=${a.id}\` · ${a.description}\n`; + } + text += "\n> Re-run `project_scaffold` with the chosen `architecture` and a `targetDir`."; + return { content: [{ type: "text" as const, text }] }; + } + + const arch = findArchitecture(architecture); + if (!arch) { + return { + content: [{ type: "text" as const, text: `Error: unknown architecture '${architecture}'. Run project_scaffold with no architecture to list options.` }], + isError: true, + }; + } + + try { + const dir = resolve(targetDir ?? join(process.cwd(), projectName)); + const files = arch.files(projectName); + for (const [relPath, contents] of Object.entries(files)) { + const full = join(dir, relPath); + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, contents, "utf-8"); + } + writeState({ scaffoldArchitecture: arch.id, projectName }); + + const fileList = Object.keys(files).map((f) => `- \`${f}\``).join("\n"); + const output = + `## Scaffolded: ${arch.name}\n\n` + + `Created project in \`${dir}\`:\n\n${fileList}\n\n` + + "> Next: `project_hydrate_config` to inject your SPE settings, then `project_run_local` to start it."; + return { content: [{ type: "text" as const, text: output }] }; + } catch (error) { + const msg = error instanceof Error ? error.message : "Unknown error"; + return { content: [{ type: "text" as const, text: `Error scaffolding project: ${msg}` }], isError: true }; + } + }, +}; diff --git a/src/tools/search-content.ts b/src/tools/search-content.ts new file mode 100644 index 0000000..e1db4e3 --- /dev/null +++ b/src/tools/search-content.ts @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tool: content_search + * + * Search for content across SPE containers using Microsoft Search API. + * + * `query` is validated as a non-empty string via the shared field builders / + * `defineTool`. Pagination is intentionally NOT strictly typed here: the schema + * uses `.passthrough()` so back-compat aliases (`maxResults`→`top`, + * `continuationToken`/`nextToken`→`skip`) survive validation and are sanitized by + * `parsePageArgs`, which is the single source of truth for clamping page args. + */ + +import { searchContent } from "../graph-client.js"; +import { defineTool } from "../tooling/define-tool.js"; +import { nonEmptyString, z } from "../tooling/fields.js"; +import { requireContentAccess } from "./content-access.js"; +import { ok, fail } from "../responses.js"; +import { clientSafeMessage } from "../errors.js"; +import { pageFromServerWindow, pageFooter, parsePageArgs } from "./pagination.js"; +import type { SearchResponse } from "../types.js"; + +// Page-size / offset args accept a number OR a numeric string: `nextToken`/`skip` +// cursors are surfaced to callers as strings (see pageFooter), so we must not +// reject a string offset. `parsePageArgs` coerces + clamps whatever arrives. +const pageArg = (description: string) => z.union([z.number(), z.string()]).optional().describe(description); + +const schema = z + .object({ + query: nonEmptyString("query", "The search query string."), + top: pageArg("Maximum results to return in this page (default 25, max 200). Alias: maxResults."), + skip: pageArg("Number of results to skip (offset). Use the nextToken/skip from a prior page to continue."), + maxResults: pageArg("Deprecated alias for `top`. Maximum results to return. Default: 25."), + }) + // Preserve undeclared back-compat aliases (`continuationToken`, `nextToken`, + // `limit`) so parsePageArgs can read them. + .passthrough(); + +export const searchContentTool = defineTool({ + name: "content_search", + annotations: { readOnly: true, plane: "content", requiresConsent: true }, + description: + "Search for files and content across SharePoint Embedded containers " + + "using the Microsoft Search API with includeHiddenContent. " + + "Use this when you need to find files by keyword across a tenant's SPE content. " + + "Content-gated: requires content access consent. " + + "Supports pagination via `top` (page size, max 200) and `skip` (offset). " + + "Note: newly uploaded files may take 1-5 minutes to appear in search results.", + schema, + handler: async (args) => { + const gate = requireContentAccess(); + if (gate) return gate; + + const query = args.query; + const { top, skip } = parsePageArgs(args, { defaultTop: 25 }); + + let response: SearchResponse; + try { + response = await searchContent(query, top, skip); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + const safeMsg = clientSafeMessage(error); + // Microsoft Search needs a broader Graph scope than SPE's container scopes + // (FileStorageContainer.*). Surface an actionable hint rather than a raw 403. + if (/access denied|Files\.Read|Sites\.Read|forbidden|\b403\b/i.test(msg)) { + return fail( + "FORBIDDEN", + "content_search uses the Microsoft Search API, which requires the owning app to have " + + "Files.Read.All (or Sites.Read.All) delegated Microsoft Graph permission granted and admin-consented — " + + "the SharePoint Embedded container scopes (FileStorageContainer.*) alone are not sufficient.", + "Add Files.Read.All to the owning app's API permissions in Entra (admin consent required), then retry. " + + `Underlying error: ${safeMsg}`, + ); + } + return fail("UPSTREAM", `searching content: ${safeMsg}`); + } + + const hits = response.value?.[0]?.hitsContainers?.[0]?.hits ?? []; + const total = response.value?.[0]?.hitsContainers?.[0]?.total ?? 0; + + if (hits.length === 0) { + return ok( + { items: [], totalCount: total, hasMore: false, query }, + `No results found for "${query}". Note: newly uploaded content may take 1-5 minutes to be indexed.`, + ); + } + + const page = pageFromServerWindow(hits, { top, skip }, total); + + let output = `## Search Results (${hits.length} of ${total})\n\n`; + output += `| Name | Size | Modified | URL |\n|------|------|----------|-----|\n`; + for (const hit of hits) { + const r = hit.resource; + const size = r.size ? `${(r.size / 1024).toFixed(1)} KB` : "—"; + output += `| ${r.name} | ${size} | ${r.lastModifiedDateTime ?? "—"} | ${r.webUrl ?? "—"} |\n`; + } + output += pageFooter(page, skip); + + return ok({ ...page, query }, output); + }, +}); diff --git a/src/tools/search-docs.ts b/src/tools/search-docs.ts new file mode 100644 index 0000000..6035b97 --- /dev/null +++ b/src/tools/search-docs.ts @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tools: docs_search, docs_fetch + * + * Proxy SharePoint Embedded / Microsoft Graph documentation questions to the + * official Microsoft Learn MCP server. These make the SPE MCP server a grounded + * "SPE knowledge expert" without shipping our own doc index — answers come from + * current first-party documentation. + * + * The SPE server REQUIRES the Microsoft Learn MCP (https://learn.microsoft.com/api/mcp) + * as an upstream dependency; see docs-client.ts. + */ + +import { searchDocs, fetchDoc } from "../docs-client.js"; +import { defineTool, z } from "../tooling/define-tool.js"; +import { fail, ok } from "../responses.js"; + +/** Bias queries toward SharePoint Embedded so generic terms resolve in-context. */ +function scopeToSpe(query: string): string { + const q = query.trim(); + return /sharepoint embedded|\bspe\b/i.test(q) ? q : `${q} (SharePoint Embedded)`; +} + +const searchDocsSchema = z.object({ + query: z.string().trim().min(1, "query is required").describe( + "The developer's documentation question or keywords (e.g., 'how many trial container types per tenant', 'register container type on consuming tenant').", + ), +}); + +const fetchDocSchema = z.object({ + url: z.string().trim().min(1, "url is required").describe("The Microsoft Learn documentation page URL to fetch in full."), +}); + +function firstValidationMessage(error: z.ZodError): string { + const issue = error.issues[0]; + if (issue?.path[0] === "query") return "query is required"; + if (issue?.path[0] === "url") return "url is required"; + return issue?.message ?? "Invalid documentation tool arguments"; +} + +export const searchDocsTool = defineTool({ + name: "docs_search", + description: + "Search official Microsoft Learn documentation for SharePoint Embedded and Microsoft Graph. " + + "Use this to answer developer questions about container types, containers, registration, " + + "permissions, billing, Graph API endpoints, and SPE concepts — instead of relying on prior " + + "knowledge, which may be outdated. Returns ranked excerpts with titles and doc URLs. " + + "Follow up with docs_fetch to read a full page when an excerpt is insufficient.", + annotations: { + readOnly: true, + idempotent: true, + plane: "control", + }, + schema: searchDocsSchema, + validationErrorMessage: firstValidationMessage, + handler: async (args) => { + const query = args.query; + try { + const text = await searchDocs(scopeToSpe(query)); + return ok({ query, text }, `## Microsoft Learn results for: ${query}\n\n${text}`); + } catch (error) { + const msg = error instanceof Error ? error.message : "Unknown error"; + return fail("UPSTREAM", `searching Microsoft Learn: ${msg}`); + } + }, +}); + +export const fetchDocTool = defineTool({ + name: "docs_fetch", + description: + "Fetch the full markdown content of a Microsoft Learn documentation page by URL " + + "(typically a 'learn.microsoft.com' URL returned by docs_search). " + + "Use when a search excerpt is not enough to answer accurately.", + annotations: { + readOnly: true, + idempotent: true, + plane: "control", + }, + schema: fetchDocSchema, + validationErrorMessage: firstValidationMessage, + handler: async (args) => { + const url = args.url; + try { + const text = await fetchDoc(url); + return ok({ url, text }, text); + } catch (error) { + const msg = error instanceof Error ? error.message : "Unknown error"; + return fail("UPSTREAM", `fetching Microsoft Learn page: ${msg}`); + } + }, +}); diff --git a/src/tools/seed-sample-data.ts b/src/tools/seed-sample-data.ts new file mode 100644 index 0000000..a745535 --- /dev/null +++ b/src/tools/seed-sample-data.ts @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tool: project_seed_sample_data + * + * Creates sample containers and uploads sample documents so the developer (and + * the agent's closed loop) has data to explore immediately. Ports EVAL.md + * `seed-sample-data` (e.g., Blueprints / Permits / Site Photos). + * + * Requires content-plane access (the owning-app token used for container ops + * also covers drive writes here). Uses the registered container type from state. + */ + +import { + activateContainer, + createContainer, + getContainerDrive, + uploadSmallFile, +} from "../graph-client.js"; +import { readState } from "../state.js"; +import { requireContentAccess } from "./content-access.js"; +import type { McpTool } from "../types.js"; + +interface SeedArgs { + containerTypeId?: string; +} + +// A small, realistic sample set (the EVAL.md construction-docs example). +const SAMPLE_CONTAINERS: Array<{ name: string; docs: string[] }> = [ + { name: "Blueprints", docs: ["Floor-Plan-L1.txt", "Floor-Plan-L2.txt", "Elevations.txt", "Site-Layout.txt"] }, + { name: "Permits", docs: ["City-Permit.txt", "Electrical-Inspection.txt", "Plumbing-Inspection.txt", "Occupancy.txt", "Fire-Safety.txt"] }, + { name: "Site Photos", docs: ["Progress-Week1.txt", "Progress-Week2.txt", "Foundation.txt"] }, +]; + +export const seedSampleDataTool: McpTool = { + name: "project_seed_sample_data", + annotations: { plane: "content", requiresConsent: true }, + description: + "Seed sample containers and documents into a SharePoint Embedded setup so you can start " + + "exploring immediately (e.g., Blueprints, Permits, Site Photos with sample files). Uses the " + + "registered container type from the current provisioning state.", + inputSchema: { + type: "object" as const, + properties: { + containerTypeId: { type: "string", description: "Container type ID. Defaults from provisioning state." }, + }, + }, + handler: async (args) => { + const gate = requireContentAccess(); + if (gate) return gate; + + const state = readState(); + const { containerTypeId = state.containerTypeId } = args as SeedArgs; + + if (!containerTypeId) { + return { + content: [{ type: "text" as const, text: "Error: no container type to seed (none in state). Provision an SPE app first." }], + isError: true, + }; + } + + try { + let totalDocs = 0; + const rows: string[] = []; + for (const sample of SAMPLE_CONTAINERS) { + const container = await createContainer(containerTypeId, sample.name); + if (container.status !== "active") { + await activateContainer(container.id).catch(() => undefined); + } + const drive = await getContainerDrive(container.id); + for (const doc of sample.docs) { + await uploadSmallFile(drive.id, `/${doc}`, `Sample document: ${doc}\nContainer: ${sample.name}\n`); + totalDocs++; + } + rows.push(`| 📁 ${sample.name} | ${sample.docs.length} docs |`); + } + + const output = + "## Sample Data Seeded\n\n" + + "| Container | Documents |\n|-----------|-----------|\n" + + rows.join("\n") + + `\n\nCreated ${SAMPLE_CONTAINERS.length} containers with ${totalDocs} sample documents.`; + + return { content: [{ type: "text" as const, text: output }] }; + } catch (error) { + const msg = error instanceof Error ? error.message : "Unknown error"; + return { content: [{ type: "text" as const, text: `Error seeding sample data: ${msg}` }], isError: true }; + } + }, +}; diff --git a/src/tools/setup-billing.ts b/src/tools/setup-billing.ts new file mode 100644 index 0000000..1fd8c72 --- /dev/null +++ b/src/tools/setup-billing.ts @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tool: billing_setup + * + * Attach Standard (Azure) billing to a container type that was ALREADY created + * as standard (Graph `billingClassification=standard` is set at CT-create time): + * 1. Register the `Microsoft.Syntex` resource provider on the chosen Azure + * subscription and WAIT until `Registered`. + * 2. Create the `Microsoft.Syntex/accounts` (RaaS) ARM billing account via the + * Azure CLI (`az rest` PUT, api-version 2023-01-04-preview) and assert + * `provisioningState === "Succeeded"`. Matches the VS Code extension's + * ARMProvider exactly. + * + * Root cause this replaces: the old Graph PATCH of billing fields onto the + * container type returns `400 One of the provided arguments is not acceptable` + * — the v1.0 Update fileStorageContainerType API accepts only name/settings/etag. + * + * Decision (owner, non-negotiable): Graph + `az` only. No SharePoint-Admin + * (`_api/SPO.Tenant`) plane, so there is NO way to convert an existing trial CT + * to standard — standard must be chosen when the CT is created. This tool only + * ATTACHES the Azure billing link to an already-standard CT. + * + * Rollback: operates on a pre-existing CT and therefore NEVER + * deletes it. `createSyntexAccount` cleans up only a partially-created ARM + * account. Standard billing is a one-way transition (cannot revert to trial). + */ + +import { createSyntexAccount, ensureSyntexProviderRegistered, getSyntexAccounts } from "../azure-cli.js"; +import { getContainerType } from "../graph-client.js"; +import { readState, writeState } from "../state.js"; +import type { McpTool } from "../types.js"; + +interface SetupBillingArgs { + containerTypeId?: string; + azureSubscriptionId?: string; + resourceGroup?: string; + region?: string; + confirm?: boolean; +} + +export const setupBillingTool: McpTool = { + name: "billing_setup", + annotations: { plane: "control", localRequired: true }, + description: + "Attach Standard (Azure) billing to a SharePoint Embedded container type that was created as " + + "standard: registers the Microsoft.Syntex resource provider on the chosen Azure subscription " + + "(Azure CLI), then creates the Microsoft.Syntex/accounts (RaaS) ARM billing account linking the " + + "container type to that subscription, resource group, and region (Azure CLI / ARM). Defaults the " + + "container type and subscription/resource group from the current provisioning state. NOTE: standard " + + "billing must be selected when the container type is created — a trial container type cannot be " + + "converted. WARNING: Standard billing cannot be reverted to trial.", + inputSchema: { + type: "object" as const, + properties: { + containerTypeId: { type: "string", description: "Container type ID (must already be standard). Defaults from state." }, + azureSubscriptionId: { type: "string", description: "Azure subscription ID for billing. Defaults from state." }, + resourceGroup: { type: "string", description: "Azure resource group name. Defaults from state." }, + region: { type: "string", description: "Azure region (e.g., 'eastus', 'westus2', 'westeurope'). Default: eastus." }, + confirm: { + type: "boolean", + description: + "Must be true to actually create the IRREVERSIBLE standard Azure billing account. " + + "Without it the tool returns a preview/warning and makes no change.", + }, + }, + }, + handler: async (args) => { + const state = readState(); + const { + containerTypeId = state.containerTypeId, + azureSubscriptionId = state.azureSubscriptionId, + resourceGroup = state.resourceGroup, + region = "eastus", + confirm = false, + } = args as SetupBillingArgs; + + if (!containerTypeId || !azureSubscriptionId || !resourceGroup) { + const missing = [ + !containerTypeId && "containerTypeId", + !azureSubscriptionId && "azureSubscriptionId", + !resourceGroup && "resourceGroup", + ].filter(Boolean) as string[]; + return { + content: [{ + type: "text" as const, + text: + `Error: missing required ${missing.length === 1 ? "argument" : "arguments"}: ${missing.join(", ")} ` + + "(not provided and not found in provisioning state). Run azure_subscriptions_list / " + + "azure_resource_groups_list to choose a subscription and resource group, then pass them in.", + }], + isError: true, + }; + } + + try { + const current = await getContainerType(containerTypeId); + + // Guard: standard billing classification is set at CT-CREATE time via Graph. + // With no SharePoint-admin plane there is no supported conversion path, so a + // non-standard CT cannot be billed by this tool — fail clearly, never 400. + if (current.billingClassification !== "standard") { + const classification = current.billingClassification ?? "unknown"; + return { + content: [{ + type: "text" as const, + text: + `Cannot attach standard billing: container type \`${containerTypeId}\` is **${classification}**.\n\n` + + "Standard billing **must be selected when the container type is CREATED** " + + "(Microsoft Graph `billingClassification=standard`). There is no supported path to convert an " + + `existing ${classification} container type to standard (that would require a SharePoint-admin write, ` + + "which is intentionally excluded).\n\n" + + "Create a standard container type instead — `container_type_create` with " + + "`billingClassification=standard` (or `project_provision` with `billingClassification=standard`) — " + + "then re-run **billing_setup** to attach the Azure billing account.", + }], + isError: true, + }; + } + + // Idempotency: an already-Succeeded Microsoft.Syntex account for THIS CT means + // billing is already attached → no-op success (no confirm required). + const accounts = await getSyntexAccounts(azureSubscriptionId, resourceGroup); + const existing = accounts.find( + (a) => a.properties?.identityId === containerTypeId && a.properties?.provisioningState === "Succeeded", + ); + if (existing) { + writeState({ billingClassification: "standard", azureSubscriptionId, resourceGroup, syntexAccountResourceId: existing.id }); + return { + content: [{ + type: "text" as const, + text: `Standard billing is already attached for container type \`${containerTypeId}\` (Microsoft.Syntex account \`${existing.id}\`, provisioningState=Succeeded).`, + }], + }; + } + + // Confirm gate: creating the billing account links a billable + // subscription and is part of the IRREVERSIBLE standard setup. + if (confirm !== true) { + const preview = + "### ⚠️ Confirm standard billing setup\n\n" + + "This creates a **Microsoft.Syntex (RaaS) Azure billing account** for your **standard** container " + + "type, linking it to a billable Azure subscription. Standard billing is a **ONE-WAY** configuration and " + + "**CANNOT be reverted to trial**.\n\n" + + "| Property | Value |\n|----------|-------|\n" + + `| **Container Type** | \`${containerTypeId}\` |\n` + + `| **Current billing** | ${current.billingClassification} |\n` + + `| **Subscription** | \`${azureSubscriptionId}\` |\n` + + `| **Resource group** | ${resourceGroup} |\n` + + `| **Region** | ${region} |\n\n` + + "> Re-run **billing_setup** with `confirm=true` to proceed. No change has been made."; + return { content: [{ type: "text" as const, text: preview }] }; + } + + // 1. Azure-side prerequisite: register the Syntex RP and WAIT. + const provider = await ensureSyntexProviderRegistered(azureSubscriptionId); + + // 2. Create the RaaS ARM billing account and assert Succeeded. On failure it + // cleans up its own partial account and throws; the caller's CT is untouched. + const syntexAccountResourceId = await createSyntexAccount( + azureSubscriptionId, resourceGroup, region, containerTypeId, + ); + + writeState({ billingClassification: "standard", azureSubscriptionId, resourceGroup, syntexAccountResourceId }); + + const output = + "## Standard Billing Configured\n\n" + + "| Property | Value |\n|----------|-------|\n" + + `| **Container Type** | \`${containerTypeId}\` |\n` + + `| **Billing** | standard |\n` + + `| **Subscription** | \`${azureSubscriptionId}\` |\n` + + `| **Resource group** | ${resourceGroup} |\n` + + `| **Region** | ${region} |\n` + + `| **Microsoft.Syntex RP** | ${provider.registrationState} |\n` + + `| **Microsoft.Syntex account** | \`${syntexAccountResourceId}\` |\n\n` + + "> ⚠️ Standard billing is **irreversible** — this container type can no longer be reverted to trial.\n" + + "> Billing policy may take a few minutes to propagate before billable operations succeed."; + + return { content: [{ type: "text" as const, text: output }] }; + } catch (error) { + const msg = error instanceof Error ? error.message : "Unknown error"; + return { content: [{ type: "text" as const, text: `Error configuring billing: ${msg}` }], isError: true }; + } + }, +}; + diff --git a/src/tools/standard-billing-target.test.ts b/src/tools/standard-billing-target.test.ts new file mode 100644 index 0000000..12daa56 --- /dev/null +++ b/src/tools/standard-billing-target.test.ts @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Resource-group existence verification for guided standard billing (PR #3 review). + * + * When a subscription has no resource groups, the guided target helper prompts + * the user for a NEW resource-group name (the server cannot create the group + * itself). Previously it proceeded with whatever name was entered; a typo / + * non-existent group only failed LATER at `createSyntexAccount` — AFTER the + * container type had been created, stranding it. The fix probes the entered name + * with `resourceGroupExists` and, on a definitive "missing", returns actionable + * guidance and fails COST-FREE (before any CT/billing resource is created). An + * indeterminate probe (az missing / auth / transient) degrades to the prior + * behavior (proceed with the name). The auto-select-singleton and multi-RG + * elicit paths are unchanged — they never probe. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../azure-cli.js", () => ({ + listSubscriptions: vi.fn(), + listResourceGroups: vi.fn(), + resourceGroupExists: vi.fn(), +})); +vi.mock("../elicitation.js", () => ({ + elicitChoice: vi.fn(), + elicitText: vi.fn(), +})); + +import * as azureCli from "../azure-cli.js"; +import * as elicitation from "../elicitation.js"; +import { resolveStandardBillingTarget } from "../tools/standard-billing-target.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("resolveStandardBillingTarget — resource-group existence check (PR #3 review)", () => { + it("0 RGs + entered name that does NOT exist → cost-free guidance, does not proceed", async () => { + vi.mocked(azureCli.listResourceGroups).mockResolvedValue([]); + vi.mocked(elicitation.elicitText).mockResolvedValue({ resolved: true, value: "typo-rg" }); + vi.mocked(azureCli.resourceGroupExists).mockResolvedValue(false); + + const r = await resolveStandardBillingTarget({ azureSubscriptionId: "sub-1" }); + + // The entered name was probed against the chosen subscription. + expect(azureCli.resourceGroupExists).toHaveBeenCalledWith("typo-rg", "sub-1"); + // Fail cost-free: unresolved, with actionable create-then-re-run guidance and + // NOT an error envelope (agent-guided, non-blocking). + expect(r.resolved).toBe(false); + if (r.resolved) throw new Error("expected unresolved"); + expect(r.result.isError).toBeFalsy(); + expect(r.result.content[0].text).toContain("does not exist"); + expect(r.result.content[0].text).toContain("az group create"); + expect(r.result.content[0].text).toContain("typo-rg"); + }); + + it("0 RGs + entered name that EXISTS → proceeds (verified)", async () => { + vi.mocked(azureCli.listResourceGroups).mockResolvedValue([]); + vi.mocked(elicitation.elicitText).mockResolvedValue({ resolved: true, value: "real-rg" }); + vi.mocked(azureCli.resourceGroupExists).mockResolvedValue(true); + + const r = await resolveStandardBillingTarget({ azureSubscriptionId: "sub-1" }); + + expect(azureCli.resourceGroupExists).toHaveBeenCalledWith("real-rg", "sub-1"); + expect(r.resolved).toBe(true); + if (!r.resolved) throw new Error("expected resolved"); + expect(r.resourceGroup).toBe("real-rg"); + expect(r.azureSubscriptionId).toBe("sub-1"); + expect(r.notes.join(" ")).toContain("verified"); + }); + + it("0 RGs + entered name but probe is INDETERMINATE (undefined) → proceeds (unverified, prior behavior)", async () => { + vi.mocked(azureCli.listResourceGroups).mockResolvedValue([]); + vi.mocked(elicitation.elicitText).mockResolvedValue({ resolved: true, value: "maybe-rg" }); + vi.mocked(azureCli.resourceGroupExists).mockResolvedValue(undefined); + + const r = await resolveStandardBillingTarget({ azureSubscriptionId: "sub-1" }); + + expect(azureCli.resourceGroupExists).toHaveBeenCalledWith("maybe-rg", "sub-1"); + expect(r.resolved).toBe(true); + if (!r.resolved) throw new Error("expected resolved"); + expect(r.resourceGroup).toBe("maybe-rg"); + expect(r.notes.join(" ")).toContain("could not verify"); + }); + + it("preserves the auto-select-singleton path — a lone listed RG is used WITHOUT probing", async () => { + vi.mocked(azureCli.listResourceGroups).mockResolvedValue([ + { name: "solo-rg", location: "eastus", id: "/subscriptions/sub-1/resourceGroups/solo-rg" }, + ]); + + const r = await resolveStandardBillingTarget({ azureSubscriptionId: "sub-1" }); + + expect(elicitation.elicitText).not.toHaveBeenCalled(); + expect(azureCli.resourceGroupExists).not.toHaveBeenCalled(); // listed RGs are not re-probed + expect(r.resolved).toBe(true); + if (!r.resolved) throw new Error("expected resolved"); + expect(r.resourceGroup).toBe("solo-rg"); + }); + + it("preserves the multi-RG elicit path — chosen listed RG is used WITHOUT probing", async () => { + vi.mocked(azureCli.listResourceGroups).mockResolvedValue([ + { name: "rg-a", location: "eastus", id: "/subscriptions/sub-1/resourceGroups/rg-a" }, + { name: "rg-b", location: "eastus", id: "/subscriptions/sub-1/resourceGroups/rg-b" }, + ]); + vi.mocked(elicitation.elicitChoice).mockResolvedValue({ resolved: true, value: "rg-b" }); + + const r = await resolveStandardBillingTarget({ azureSubscriptionId: "sub-1" }); + + expect(elicitation.elicitChoice).toHaveBeenCalled(); + expect(azureCli.resourceGroupExists).not.toHaveBeenCalled(); + expect(r.resolved).toBe(true); + if (!r.resolved) throw new Error("expected resolved"); + expect(r.resourceGroup).toBe("rg-b"); + }); +}); diff --git a/src/tools/standard-billing-target.ts b/src/tools/standard-billing-target.ts new file mode 100644 index 0000000..0272d98 --- /dev/null +++ b/src/tools/standard-billing-target.ts @@ -0,0 +1,184 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Guided standard-billing target resolution (PR #3 review). + * + * When a caller selects STANDARD billing but has not supplied an Azure + * subscription and/or resource group, this runs the Azure CLI listings INLINE + * and prompts the user to pick — via native MCP elicitation when the client + * supports it, or the agent-guided fallback otherwise — so the user never has to + * break out of the provisioning flow to call `azure_subscriptions_list` / + * `azure_resource_groups_list` and re-invoke by hand. The reviewer's ask: "if + * they pick standard, figure out their subs, and once they've picked a sub, + * figure out their RGs within that sub" and "run these tools during this step so + * the user doesn't break out of it during creation." + * + * Behavior: + * - Subscriptions: zero → a clear, non-crashing error (sign in with `az login`); + * exactly one → auto-selected (no needless prompt), recorded as a note; many → + * an `elicitChoice` pick. + * - Resource groups (only after a subscription is known): zero → prompt for a + * NEW name via `elicitText` (the server cannot create the group itself), or a + * clear "create one with `az group create`" message on the fallback path; + * exactly one → auto-selected; many → an `elicitChoice` pick. + * + * The resolved subscription + resource group are returned to the caller, which + * threads them through the EXISTING downstream gates (region check, the + * `confirmBilling` financial-safety gate, and standard-billing rollback) entirely + * unchanged — this only fills the target BEFORE those gates run. On the fallback + * path each unresolved step returns the agent-guided `needChoice`/no-op result + * (via the elicitation helpers) that the orchestrator re-invokes with the chosen + * arg; the resolved arg is threaded on re-invoke, so there is no loop. + */ + +import { listResourceGroups, listSubscriptions, resourceGroupExists } from "../azure-cli.js"; +import { elicitChoice, elicitText } from "../elicitation.js"; +import type { McpToolResult } from "../types.js"; + +/** + * Outcome of guided resolution. `resolved` carries the chosen subscription + + * resource group and any human-readable `notes` (e.g. an auto-selected + * singleton) for the caller to surface. `!resolved` carries an `McpToolResult` + * to return verbatim — a native elicitation prompt, the agent-guided fallback + * ask, or a clear error — so the caller does not have to know which. + */ +export type BillingTargetResolution = + | { resolved: true; azureSubscriptionId: string; resourceGroup: string; notes: string[] } + | { resolved: false; result: McpToolResult }; + +function textResult(text: string, isError = false): McpToolResult { + return { content: [{ type: "text", text }], isError }; +} + +/** + * Resolve the Azure subscription + resource group for STANDARD billing, guiding + * the user through any missing piece. Call ONLY when billing is standard. + * Already-supplied values are passed through untouched (so an explicit + * subscription still gets its resource groups listed). + */ +export async function resolveStandardBillingTarget(input: { + azureSubscriptionId?: string; + resourceGroup?: string; +}): Promise { + const notes: string[] = []; + // Treat empty/whitespace as missing so a blank arg triggers guidance rather + // than flowing an invalid value into ARM. + let azureSubscriptionId = input.azureSubscriptionId?.trim() || undefined; + let resourceGroup = input.resourceGroup?.trim() || undefined; + + // ── Subscription ────────────────────────────────────────────────────────── + if (!azureSubscriptionId) { + let subs; + try { + subs = await listSubscriptions(); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return { resolved: false, result: textResult(`Could not list Azure subscriptions: ${msg}`, true) }; + } + + if (subs.length === 0) { + return { + resolved: false, + result: textResult( + "No enabled Azure subscriptions were found for the signed-in user, so standard billing " + + "cannot be set up. Sign in with `az login` (or `az login --allow-no-subscriptions` if your " + + "account has none), then re-run.", + true, + ), + }; + } + + if (subs.length === 1) { + // Trivial single choice — auto-select instead of prompting. + azureSubscriptionId = subs[0].id; + notes.push(`Using the only Azure subscription "${subs[0].name}" (\`${subs[0].id}\`).`); + } else { + const choice = await elicitChoice( + "Which Azure subscription should bill standard storage?", + subs.map((s) => ({ label: s.name, value: s.id, description: s.id })), + "azureSubscriptionId", + ); + if (!choice.resolved) return { resolved: false, result: choice.result }; + azureSubscriptionId = choice.value; + } + } + + // ── Resource group (only once a subscription is known) ──────────────────── + if (!resourceGroup) { + let rgs; + try { + rgs = await listResourceGroups(azureSubscriptionId); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return { + resolved: false, + result: textResult( + `Could not list resource groups for subscription \`${azureSubscriptionId}\`: ${msg}`, + true, + ), + }; + } + + if (rgs.length === 0) { + // Nothing to select. The server cannot create a resource group itself + // (billing account PUT requires it to already exist), so ask for a name — + // natively when possible — and otherwise return actionable guidance. + const named = await elicitText( + `No resource groups exist in subscription \`${azureSubscriptionId}\`. Enter a name for the resource ` + + "group to use for standard billing — create it first with `az group create` if it does not already exist.", + "resourceGroup", + { title: "Resource group name" }, + ); + if (named.resolved) { + const candidate = named.value.trim(); + // Verify the user-entered name exists BEFORE proceeding. The server cannot + // create a resource group, and an unverified typo would otherwise only + // surface much later at `createSyntexAccount` — AFTER the container type is + // created. Probe cost-free: on a definitive "missing" return actionable + // guidance (fail before any billing/CT resource exists); an indeterminate + // probe (az missing / auth / transient) degrades to proceeding with the + // name, preserving the prior behavior. (PR #3 review.) + const exists = await resourceGroupExists(candidate, azureSubscriptionId); + if (exists === false) { + return { + resolved: false, + result: textResult( + `Resource group \`${candidate}\` does not exist in subscription \`${azureSubscriptionId}\`, and ` + + "the server cannot create it. Create it first, e.g. `az group create --name " + + `${candidate} --location \`, then re-run with \`resourceGroup\` set to its name.`, + ), + }; + } + resourceGroup = candidate; + notes.push( + exists === true + ? `Using resource group "${resourceGroup}" (verified in subscription \`${azureSubscriptionId}\`).` + : `Using resource group "${resourceGroup}" — could not verify it exists; ensure it does ` + + "(create it with `az group create` if needed), or standard billing will fail later.", + ); + } else { + return { + resolved: false, + result: textResult( + `No resource groups exist in subscription \`${azureSubscriptionId}\`. Create one first, e.g. ` + + "`az group create --name --location `, then re-run with `resourceGroup` set to its name.", + ), + }; + } + } else if (rgs.length === 1) { + resourceGroup = rgs[0].name; + notes.push(`Using the only resource group "${rgs[0].name}" (${rgs[0].location}).`); + } else { + const choice = await elicitChoice( + "Which resource group should hold the standard billing account?", + rgs.map((g) => ({ label: g.name, value: g.name, description: g.location })), + "resourceGroup", + ); + if (!choice.resolved) return { resolved: false, result: choice.result }; + resourceGroup = choice.value; + } + } + + return { resolved: true, azureSubscriptionId, resourceGroup, notes }; +} diff --git a/src/tools/status.test.ts b/src/tools/status.test.ts new file mode 100644 index 0000000..af4feca --- /dev/null +++ b/src/tools/status.test.ts @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for the status_get tool. Bootstrap and state are mocked. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../bootstrap.js", () => ({ + assertAzCli: vi.fn(), + getSignedInIdentity: vi.fn(), +})); +// Mock provisioning state so the test is deterministic regardless of any real +// ~/.spe-mcp/state.json on the dev machine. +vi.mock("../state.js", () => ({ readState: vi.fn(() => ({})) })); + +import * as bootstrap from "../bootstrap.js"; +import { statusTool } from "../tools/status.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("status_get", () => { + it("has correct metadata and no required params", () => { + expect(statusTool.name).toBe("status_get"); + expect(statusTool.inputSchema.required ?? []).toHaveLength(0); + }); + + it("reports signed-in identity when az is ready", async () => { + vi.mocked(bootstrap.assertAzCli).mockResolvedValue(undefined); + vi.mocked(bootstrap.getSignedInIdentity).mockResolvedValue({ + tenantId: "tenant-123", + username: "dev@contoso.com", + }); + + const result = await statusTool.handler({}); + + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain("dev@contoso.com"); + expect(result.content[0].text).toContain("tenant-123"); + // No owning app in state → status guides the user to create one first. + expect(result.content[0].text).toContain("project_app_create"); + expect(result.content[0].text).toMatch(/require an owning app first/i); + }); + + it("confirms readiness once an owning app is provisioned", async () => { + vi.mocked(bootstrap.assertAzCli).mockResolvedValue(undefined); + vi.mocked(bootstrap.getSignedInIdentity).mockResolvedValue({ + tenantId: "tenant-123", + username: "dev@contoso.com", + }); + const state = await import("../state.js"); + vi.mocked(state.readState).mockReturnValueOnce({ + appId: "app-abc", + appDisplayName: "My SPE App", + tenantId: "tenant-123", + }); + + const result = await statusTool.handler({}); + + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain("app-abc"); + expect(result.content[0].text).toMatch(/Owning app ready/i); + }); + + it("prompts for login when az is installed but not signed in", async () => { + vi.mocked(bootstrap.assertAzCli).mockResolvedValue(undefined); + vi.mocked(bootstrap.getSignedInIdentity).mockResolvedValue(null); + + const result = await statusTool.handler({}); + + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain("not signed in"); + expect(result.content[0].text).toContain("az login"); + }); + + it("errors with guidance when az is not installed", async () => { + vi.mocked(bootstrap.assertAzCli).mockRejectedValue( + new Error("Azure CLI ('az') is not installed. Install it from https://aka.ms/install-azure-cli"), + ); + + const result = await statusTool.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("not installed"); + }); +}); diff --git a/src/tools/status.ts b/src/tools/status.ts new file mode 100644 index 0000000..3763164 --- /dev/null +++ b/src/tools/status.ts @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tool: status_get + * + * Reports the SPE Builder server's current state: the signed-in Azure CLI + * (bootstrap) identity and provisioning readiness. This is the developer's + * "where am I?" check and the first consumer of the bootstrap auth plane. + * + * Phase 0: reports az identity + readiness. Phase 1+ enriches this with the + * provisioned owning app, container type, registration, and containers. + */ + +import { assertAzCli, getSignedInIdentity } from "../bootstrap.js"; +import { readState } from "../state.js"; +import type { McpTool } from "../types.js"; + +export const statusTool: McpTool = { + name: "status_get", + annotations: { readOnly: true }, + description: + "Report SharePoint Embedded Builder status: the signed-in Azure CLI identity " + + "(tenant and user) used for control-plane provisioning, and whether the environment " + + "is ready to provision. Use this first to confirm sign-in before creating apps, " + + "container types, or containers.", + inputSchema: { + type: "object" as const, + properties: {}, + }, + handler: async () => { + try { + await assertAzCli(); + } catch (error) { + const msg = error instanceof Error ? error.message : "Unknown error"; + return { + content: [{ type: "text" as const, text: `## SPE Status\n\n⛔ ${msg}` }], + isError: true, + }; + } + + const identity = await getSignedInIdentity(); + + if (!identity) { + return { + content: [ + { + type: "text" as const, + text: + "## SPE Status\n\n" + + "| Property | Value |\n|----------|-------|\n" + + "| **Azure CLI** | ✅ installed |\n" + + "| **Signed in** | ❌ not signed in |\n\n" + + "> Run `az login --allow-no-subscriptions` to sign in, then try again.", + }, + ], + }; + } + + const state = readState(); + const hasOwningApp = !!state.appId; + const text = + "## SPE Status\n\n" + + "| Property | Value |\n|----------|-------|\n" + + "| **Azure CLI** | ✅ installed |\n" + + `| **Signed in as** | ${identity.username} |\n` + + `| **Tenant** | \`${identity.tenantId}\` |\n` + + `| **Owning app** | ${state.appId ? `\`${state.appId}\`${state.appDisplayName ? ` (${state.appDisplayName})` : ""}` : "— not provisioned yet"} |\n` + + `| **Container type** | ${state.containerTypeId ? `\`${state.containerTypeId}\`${state.containerTypeName ? ` (${state.containerTypeName})` : ""}` : "— not provisioned yet"} |\n` + + `| **Container** | ${state.containerId ? `\`${state.containerId}\`${state.containerName ? ` (${state.containerName})` : ""}` : "— not created yet"} |\n\n` + + (state.containerTypeId + ? "> Provisioning in progress — resources above are saved and reused on re-runs." + : hasOwningApp + ? "> Owning app ready. Next: create a container type, then containers." + : "> **Container types and containers require an owning app first.** Run `project_app_create` to " + + "create (or reuse) one — the server then signs in as that app automatically (a browser opens " + + "for one-time consent; no restart). Then you can list/create container types and containers."); + + return { content: [{ type: "text" as const, text }] }; + }, +}; diff --git a/src/tools/update-container.ts b/src/tools/update-container.ts new file mode 100644 index 0000000..1bee5af --- /dev/null +++ b/src/tools/update-container.ts @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tool: container_update + * + * Update (rename / edit) a SharePoint Embedded container's editable properties + * (displayName, description) via Microsoft Graph: PATCH + * /storage/fileStorage/containers/{id}. Completes container CRUDL (the Update + * verb) alongside container_create / container_get / container_list / + * container_delete. + */ + +import { setAuthConfig } from "../auth.js"; +import { updateContainer } from "../graph-client.js"; +import { ok, fail } from "../responses.js"; +import { clientSafeMessage } from "../errors.js"; +import { readState, writeState } from "../state.js"; +import type { McpTool } from "../types.js"; + +export const updateContainerTool: McpTool = { + name: "container_update", + annotations: { plane: "control", idempotent: true }, + description: + "Update (rename or edit the description of) a SharePoint Embedded container. " + + "Use this to change a container's display name or description after creation. " + + "Provide containerId and at least one of displayName / description; defaults the container to the " + + "most recently provisioned one.", + inputSchema: { + type: "object" as const, + properties: { + containerId: { + type: "string", + description: "The container ID to update. Defaults to the most recently created container.", + }, + displayName: { type: "string", description: "New display name for the container." }, + description: { type: "string", description: "New description for the container." }, + }, + }, + handler: async (args) => { + const state = readState(); + if (state.appId && state.tenantId) { + setAuthConfig({ clientId: state.appId, tenantId: state.tenantId }); + } + + const containerId = (args.containerId as string) || state.containerId; + if (!containerId) { + return fail("INVALID_ARGS", "containerId is required (none in provisioning state)."); + } + + const displayName = typeof args.displayName === "string" ? args.displayName : undefined; + const description = typeof args.description === "string" ? args.description : undefined; + if (displayName === undefined && description === undefined) { + return fail( + "INVALID_ARGS", + "nothing to update: provide displayName and/or description.", + "Pass at least one editable field.", + ); + } + + try { + const updated = await updateContainer(containerId, { displayName, description }); + + // Keep persisted container name in sync when the provisioned container is + // renamed, so status_get reflects it. + if (displayName !== undefined && containerId === state.containerId) { + writeState({ containerName: displayName }); + } + + const output = + "## Container Updated\n\n" + + "| Property | Value |\n|----------|-------|\n" + + `| **Container ID** | \`${containerId}\` |\n` + + `| **Display name** | ${updated.displayName ?? displayName ?? "—"} |\n` + + (description !== undefined ? `| **Description** | ${updated.description ?? description} |\n` : ""); + return ok({ container: updated, containerId }, output); + } catch (e) { + return fail("UPSTREAM", `updating container: ${clientSafeMessage(e)}`); + } + }, +}; diff --git a/src/tools/upload-file.ts b/src/tools/upload-file.ts new file mode 100644 index 0000000..2599891 --- /dev/null +++ b/src/tools/upload-file.ts @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tool: content_file_upload + * + * Upload content to a container. Small content goes via simple PUT. + * + * Argument validation is declared once as a Zod schema (see `defineTool` and the + * shared builders in `../tooling/fields.ts`). Note `content` is a plain + * `z.string()` — an EMPTY string is a valid (empty) file, so only presence and + * string type are enforced, not non-emptiness. `folderPath` is optional and + * normalized (defaults to the container root). + */ + +import { getContainerDrive, uploadSmallFile } from "../graph-client.js"; +import { defineTool } from "../tooling/define-tool.js"; +import { nonEmptyString, folderPath, z } from "../tooling/fields.js"; +import { requireContentAccess } from "./content-access.js"; +import { ok } from "../responses.js"; + +const schema = z.object({ + containerId: nonEmptyString("containerId", "The container ID."), + fileName: nonEmptyString("fileName", "Target file name (e.g., 'report.txt')."), + // Empty content is a valid empty file: enforce string type + presence only. + content: z + .string({ required_error: "content is required", invalid_type_error: "content must be a string" }) + .describe("The text content to upload."), + folderPath: folderPath("folderPath", { + description: "Optional folder path (e.g., 'Documents/Reports'). Defaults to root.", + }), +}); + +export const uploadFileTool = defineTool({ + name: "content_file_upload", + annotations: { plane: "content", requiresConsent: true }, + description: + "Upload text content to a file in a SharePoint Embedded container. " + + "Provide the content as a string. For binary/large files, use the resumable upload pattern.", + schema, + handler: async (args) => { + const gate = requireContentAccess(); + if (gate) return gate; + + const { containerId, fileName, content } = args; + // `folderPath` arrives normalized (no leading/trailing or empty segments), or + // undefined/empty for the container root. + const normalizedFolder = args.folderPath; + + const drive = await getContainerDrive(containerId); + const targetPath = normalizedFolder ? `/${normalizedFolder}/${fileName}` : `/${fileName}`; + const item = await uploadSmallFile(drive.id, targetPath, content); + + let output = `## File Uploaded\n\n`; + output += `| Property | Value |\n|----------|-------|\n`; + output += `| **File** | ${item.name} |\n`; + output += `| **Size** | ${item.size ?? 0} bytes |\n`; + output += `| **Path** | ${targetPath} |\n`; + output += `| **Item ID** | \`${item.id}\` |\n`; + if (item.webUrl) output += `| **URL** | ${item.webUrl} |\n`; + + return ok( + { name: item.name, id: item.id, size: item.size ?? 0, path: targetPath, webUrl: item.webUrl }, + output, + ); + }, +}); + diff --git a/src/tools/wi07-validation.test.ts b/src/tools/wi07-validation.test.ts new file mode 100644 index 0000000..13de825 --- /dev/null +++ b/src/tools/wi07-validation.test.ts @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * WI-07: argument-validation coverage for the tools migrated onto `defineTool` + * (content_file_upload, content_folder_create, content_search). + * + * Focus areas: + * - table-driven rejection of non-string / missing / empty / whitespace-only + * arguments with the standard INVALID_ARGS envelope (no `as string` TypeError); + * - the create-folder empty-segment fix (`"/"`, `"///"`, `"a//b"`, `" "`), and + * the guarantee that `createFolder` is never called with a blank name; + * - preservation of the search pagination aliases (`maxResults`, `limit`, + * `skip`, `continuationToken`, `nextToken`); + * - acceptance of an empty-string upload (an empty file is valid). + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../graph-client.js", () => ({ + getContainerDrive: vi.fn(), + uploadSmallFile: vi.fn(), + createFolder: vi.fn(), + listDriveChildren: vi.fn(), + searchContent: vi.fn(), +})); + +// Content-plane tools are gated by the content-access opt-in; grant it so +// validation runs to completion and valid args reach the (mocked) Graph layer. +const stateStore: Record = { contentAccessGranted: true }; +vi.mock("../state.js", () => ({ + readState: vi.fn(() => ({ ...stateStore })), + writeState: vi.fn((p: Record) => { Object.assign(stateStore, p); return { ...stateStore }; }), + clearState: vi.fn(() => { for (const k of Object.keys(stateStore)) delete stateStore[k]; }), +})); + +import * as graph from "../graph-client.js"; +import { uploadFileTool } from "./upload-file.js"; +import { createFolderTool } from "./create-folder.js"; +import { searchContentTool } from "./search-content.js"; + +type Result = { isError?: boolean; content: Array<{ text: string }>; structuredContent?: unknown }; + +function errorCode(r: Result): string | undefined { + const sc = r.structuredContent as { error?: { code?: string } } | undefined; + return sc?.error?.code; +} + +/** Assert an INVALID_ARGS-class validation envelope (isError + code). */ +function expectInvalidArgs(r: Result): void { + expect(r.isError).toBe(true); + expect(r.content[0].text.startsWith("Error:")).toBe(true); + expect(errorCode(r)).toBe("INVALID_ARGS"); +} + +beforeEach(() => { + vi.clearAllMocks(); + for (const k of Object.keys(stateStore)) delete stateStore[k]; + stateStore.contentAccessGranted = true; +}); + +// ─── content_file_upload ────────────────────────────────────────────────────── + +describe("content_file_upload argument validation", () => { + const invalid: Array<[string, Record]> = [ + ["containerId non-string (number)", { containerId: 123, fileName: "f.txt", content: "x" }], + ["containerId missing", { fileName: "f.txt", content: "x" }], + ["containerId empty", { containerId: "", fileName: "f.txt", content: "x" }], + ["containerId whitespace-only", { containerId: " ", fileName: "f.txt", content: "x" }], + ["fileName non-string (object)", { containerId: "c1", fileName: {}, content: "x" }], + ["fileName non-string (array)", { containerId: "c1", fileName: [], content: "x" }], + ["fileName missing", { containerId: "c1", content: "x" }], + ["fileName whitespace-only", { containerId: "c1", fileName: " ", content: "x" }], + ["content non-string (number)", { containerId: "c1", fileName: "f.txt", content: 123 }], + ["content non-string (null)", { containerId: "c1", fileName: "f.txt", content: null }], + ["content missing", { containerId: "c1", fileName: "f.txt" }], + ["folderPath non-string", { containerId: "c1", fileName: "f.txt", content: "x", folderPath: 5 }], + ]; + + it.each(invalid)("rejects %s", async (_label, args) => { + const r = await uploadFileTool.handler(args); + expectInvalidArgs(r); + expect(graph.getContainerDrive).not.toHaveBeenCalled(); + }); + + it("accepts empty-string content (an empty file is valid)", async () => { + vi.mocked(graph.getContainerDrive).mockResolvedValue({ id: "d1" }); + vi.mocked(graph.uploadSmallFile).mockResolvedValue({ id: "i1", name: "empty.txt", size: 0 }); + + const r = await uploadFileTool.handler({ containerId: "c1", fileName: "empty.txt", content: "" }); + expect(r.isError).toBeFalsy(); + expect(graph.uploadSmallFile).toHaveBeenCalledWith("d1", "/empty.txt", ""); + }); +}); + +// ─── content_folder_create ──────────────────────────────────────────────────── + +describe("content_folder_create argument validation", () => { + const invalid: Array<[string, Record]> = [ + ["containerId non-string (number)", { containerId: 1, folderPath: "Docs" }], + ["containerId missing", { folderPath: "Docs" }], + ["containerId empty", { containerId: "", folderPath: "Docs" }], + ["containerId whitespace-only", { containerId: " ", folderPath: "Docs" }], + ["folderPath non-string (number)", { containerId: "c1", folderPath: 5 }], + ["folderPath non-string (object)", { containerId: "c1", folderPath: {} }], + ["folderPath non-string (array)", { containerId: "c1", folderPath: [] }], + ["folderPath non-string (null)", { containerId: "c1", folderPath: null }], + ["folderPath missing", { containerId: "c1" }], + ["folderPath empty string", { containerId: "c1", folderPath: "" }], + ["folderPath slash-only", { containerId: "c1", folderPath: "/" }], + ["folderPath multi-slash", { containerId: "c1", folderPath: "///" }], + ["folderPath whitespace-only", { containerId: "c1", folderPath: " " }], + ]; + + it.each(invalid)("rejects %s", async (_label, args) => { + const r = await createFolderTool.handler(args); + expectInvalidArgs(r); + // The empty-segment bug fix: a blank / zero-segment path must never reach Graph. + expect(graph.createFolder).not.toHaveBeenCalled(); + }); + + it("drops empty segments from 'a//b' and never calls createFolder with a blank name", async () => { + vi.mocked(graph.getContainerDrive).mockResolvedValue({ id: "d1" }); + vi.mocked(graph.createFolder) + .mockResolvedValueOnce({ id: "f1", name: "a" }) + .mockResolvedValueOnce({ id: "f2", name: "b" }); + + const r = await createFolderTool.handler({ containerId: "c1", folderPath: "a//b" }); + expect(r.isError).toBeFalsy(); + expect(graph.createFolder).toHaveBeenCalledTimes(2); + expect(graph.createFolder).toHaveBeenNthCalledWith(1, "d1", "root", "a"); + expect(graph.createFolder).toHaveBeenNthCalledWith(2, "d1", "f1", "b"); + // No invocation ever passed a blank folder name. + for (const call of vi.mocked(graph.createFolder).mock.calls) { + expect(call[2]).not.toBe(""); + } + }); + + it("creates each non-empty segment of 'Docs/Reports/Q1'", async () => { + vi.mocked(graph.getContainerDrive).mockResolvedValue({ id: "d1" }); + vi.mocked(graph.createFolder) + .mockResolvedValueOnce({ id: "f1", name: "Docs" }) + .mockResolvedValueOnce({ id: "f2", name: "Reports" }) + .mockResolvedValueOnce({ id: "f3", name: "Q1" }); + + const r = await createFolderTool.handler({ containerId: "c1", folderPath: "Docs/Reports/Q1" }); + expect(r.isError).toBeFalsy(); + expect(graph.createFolder).toHaveBeenNthCalledWith(1, "d1", "root", "Docs"); + expect(graph.createFolder).toHaveBeenNthCalledWith(2, "d1", "f1", "Reports"); + expect(graph.createFolder).toHaveBeenNthCalledWith(3, "d1", "f2", "Q1"); + }); +}); + +// ─── content_search ──────────────────────────────────────────────────────────── + +describe("content_search argument validation", () => { + const invalid: Array<[string, Record]> = [ + ["query non-string (number)", { query: 123 }], + ["query non-string (object)", { query: {} }], + ["query non-string (array)", { query: [] }], + ["query non-string (null)", { query: null }], + ["query missing", {}], + ["query empty", { query: "" }], + ["query whitespace-only", { query: " " }], + ]; + + it.each(invalid)("rejects %s", async (_label, args) => { + const r = await searchContentTool.handler(args); + expectInvalidArgs(r); + expect(graph.searchContent).not.toHaveBeenCalled(); + }); +}); + +describe("content_search preserves pagination aliases", () => { + beforeEach(() => { + vi.mocked(graph.searchContent).mockResolvedValue({ + value: [{ hitsContainers: [{ total: 0, hits: [] }] }], + }); + }); + + // defaultTop for content_search is 25; MAX_TOP is 200. + const cases: Array<[string, Record, number, number]> = [ + ["top", { query: "q", top: 3 }, 3, 0], + ["limit alias -> top", { query: "q", limit: 7 }, 7, 0], + ["maxResults alias -> top", { query: "q", maxResults: 5 }, 5, 0], + ["skip", { query: "q", skip: 10 }, 25, 10], + ["continuationToken alias -> skip", { query: "q", continuationToken: "15" }, 25, 15], + ["nextToken alias -> skip", { query: "q", nextToken: "20" }, 25, 20], + ]; + + it.each(cases)("honors %s", async (_label, args, expectedTop, expectedSkip) => { + const r = await searchContentTool.handler(args); + expect(r.isError).toBeFalsy(); + expect(graph.searchContent).toHaveBeenCalledWith("q", expectedTop, expectedSkip); + }); +}); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..a0c3898 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,368 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Shared type definitions for the SPE MCP Server. + */ + +// Types-only import (zero runtime cost). `@microsoft/microsoft-graph-types` is +// a pure `.d.ts` package pinned in devDependencies — nothing here emits JS. +import type { + DriveItem as GraphDriveItem, + FileStorageContainer, + Permission as GraphPermission, +} from "@microsoft/microsoft-graph-types"; +// The SPE container-type CONTROL-PLANE contracts are Microsoft Graph **beta** +// APIs, so their official types come from `@microsoft/microsoft-graph-types-beta` +// — also a types-only `.d.ts` package pinned in devDependencies (zero runtime). +import type { + FileStorageContainerTypeAppPermissionGrant, + FileStorageContainerTypeRegistration, + Permission as GraphBetaPermission, +} from "@microsoft/microsoft-graph-types-beta"; + +// ─── Primitives ────────────────────────────────────────────────────────────── + +/** + * A globally-unique identifier (UUID) rendered as a string, e.g. an Entra + * app/object id, a Microsoft Graph permission id, or an Azure subscription id. + * This is a readability alias only — it is structurally identical to `string` + * (no runtime validation), and simply documents that a value is expected to be + * a GUID rather than free-form text. + */ +export type Guid = string; + +// ─── MCP Tool ─────────────────────────────────────────────────────────────── + +export interface McpToolResult { + content: Array<{ type: string; text: string }>; + isError?: boolean; + structuredContent?: unknown; +} + +// ─── Graph OData envelopes ──────────────────────────────────────────────────── + +/** + * A Microsoft Graph OData collection envelope: the `value` array of results plus + * the optional `@odata.nextLink` continuation URL. Replaces the ad-hoc + * `{ value: T[] }` inline shapes previously spread across the Graph client + * (per PR #3 review feedback). + */ +export interface GraphCollection { + value: T[]; + "@odata.nextLink"?: string; +} + +export interface McpToolAnnotations { + readOnly?: boolean; + destructive?: boolean; + idempotent?: boolean; + plane?: "control" | "content"; + requiresConsent?: boolean; + localRequired?: boolean; +} + +export interface McpTool { + name: string; + description: string; + inputSchema: { + type: "object"; + properties: Record; + required?: string[]; + }; + annotations?: McpToolAnnotations; + validateArgs?: (args: Record) => Record; + handler: (args: Record) => Promise; +} + +// ─── Server Config ────────────────────────────────────────────────────────── + +export interface ServerConfig { + /** + * Owning Entra app client ID. OPTIONAL. When provided, the server runs in + * pre-provisioned-app mode and initializes MSAL for that app. When omitted, + * the server runs in bootstrap mode (Azure CLI control plane) and provisions + * the owning app on demand. + */ + clientId?: string; + /** Entra tenant ID. Optional; discovered from the Azure CLI when omitted. */ + tenantId?: string; + /** + * Read-only mode (SAFE-003). When true, only tools annotated `readOnly` are + * advertised and any non-readOnly tool call is rejected. Also settable via + * the `SPE_READ_ONLY` env var. + */ + readOnly?: boolean; + /** + * Tool allowlist (SAFE-004): a built-in profile name (`readOnly`, `docsOnly`, + * `provisioning`, `content`, `admin`) or a comma-separated list of tool names. + * Tools outside the allowlist are hidden from ListTools and rejected at call + * time. Also settable via the `SPE_TOOLS` env var. + * + * Surfaced to end users as the `--tools ` flag on the CLI — + * run `spe-mcp start --help` (or `npx @microsoft/spe-mcp start --help`) + * to see the profile list and description. + */ + tools?: string; +} + +// ─── Auth Config ───────────────────────────────────────────────────────────── + +/** + * Resolved authentication configuration for MSAL: the specific owning-app + * client and tenant the token cache and Graph acquisition are bound to. Distinct + * from {@link ServerConfig}, whose `clientId`/`tenantId` are optional startup + * inputs — by the time an {@link AuthConfig} exists, both are known. + */ +export interface AuthConfig { + clientId: string; + tenantId: string; + /** Override default Graph scopes */ + scopes?: string[]; +} + +// ─── Graph API Types: Container Types ──────────────────────────────────────── + +/** + * The billing model a container type is created under. This is the single + * source of truth for the classification across the codebase — the tool input + * enum, persisted state, and Graph response mapping all reference this union so + * the allowed values can't drift apart. + */ +export type BillingClassification = "trial" | "standard" | "directToCustomer"; + +/** + * The owning app's intended container-type authority, captured up front so the + * requested Graph scopes / app-permission grants can be least-privilege by + * default (PR #3 review). Two intents: + * - "manage-all": an admin/console app that manages ALL container types in the + * tenant (broad `.Manage.All` scopes). + * - "selected": a standard ISV/LOB app that only needs its own container + * type (the least-privilege `.Selected` scopes). This is the default. + * The single source of truth for the union — the tool input enum and persisted + * state both reference it so the allowed values can't drift apart. + */ +export type OwnerScope = "manage-all" | "selected"; + +export interface ContainerType { + containerTypeId: string; + owningAppId: string; + displayName: string; + description?: string; + azureSubscriptionId?: string; + createdDateTime?: string; + expirationDateTime?: string; + billingClassification?: BillingClassification; + /** + * Optimistic-concurrency tag. Read from a Create/Get response and **required** + * in the body of an Update (PATCH) call — omitting it returns HTTP 400. + */ + etag?: string; +} + +/** + * A permission entry in a fileStorageContainerType's `permissions` collection + * (Microsoft Graph **beta**). Granting a USER the `owner` role lets them create + * containers using a public client (PCA) — the v1.0 container endpoint rejects + * container creation by public clients. Only the `owner` role and a user + * identity are supported. + * + * Derived from the official beta `permission` resource ({@link GraphBetaPermission}): + * `id` is `Pick`ed as-is; `roles` is `NonNullable` because we always read/join it + * (e.g. `roles.join(...)`) whereas the official `permission.roles` is + * `NullableOption`. `grantedToV2` keeps a narrowed local shape rather + * than the official `sharePointIdentitySet` — the official `identity` type omits + * `userPrincipalName`, which we render for container-type owners + * (per PR #3 review feedback). + */ +export type ContainerTypePermission = Pick & { + roles: NonNullable; + grantedToV2?: { + user?: { id?: string; displayName?: string; userPrincipalName?: string }; + }; +}; + +/** + * The minimal container-type registration shape whose `applicationPermissionGrants` + * collection we always populate. Mirrors the official beta + * {@link FileStorageContainerTypeRegistration}'s `applicationPermissionGrants` + * (typed there as `NullableOption`), + * narrowed to our required, non-null {@link ApplicationPermissionGrant}[] — whose + * element type is itself derived from the official grant type (per PR #3 review). + * + * NOTE: retained intentionally though currently unused in a type position — it + * documents the narrowed request/registration shape and keeps the contract close + * to the official type for future callers. + */ +export interface ContainerTypeRegistration { + applicationPermissionGrants: ApplicationPermissionGrant[]; +} + +/** + * A container type **registration record** (the tenant↔containerType binding), + * as returned by GET/List on `…/containerTypeRegistrations`. Distinct from a + * single app's {@link ApplicationPermissionGrant}. The v1.0 schema exposes + * `owningAppId` (the SPE app the type is owned by) and `billingClassification`; + * fields vary by API version, so this is kept permissive. + * + * Derived from the official beta {@link FileStorageContainerTypeRegistration}: + * `id`/`owningAppId`/`registeredDateTime` are `Pick`ed as-is; `billingClassification` + * stays the local {@link BillingClassification} union (the official field also + * allows `unknownFutureValue`), and `applicationPermissionGrants` stays our + * non-null-element {@link ApplicationPermissionGrant}[] (per PR #3 review). + */ +export type ContainerTypeRegistrationRecord = Pick< + FileStorageContainerTypeRegistration, + "id" | "owningAppId" | "registeredDateTime" +> & { + billingClassification?: BillingClassification; + applicationPermissionGrants?: ApplicationPermissionGrant[]; +}; + +/** + * A single application's permission grant on a container type registration. + * `appId` derives from the official beta + * {@link FileStorageContainerTypeAppPermissionGrant} (optional there); we always + * set it, so it is `Required`. The two permission arrays intentionally stay + * `string[]` rather than the official + * `NullableOption` enum: the server + * builds and sends them as `string[]` — including via + * `satisfies ApplicationPermissionGrant` on the registration PUT body — and reads + * them non-null (`.length`, `.join`), so all three are kept required and non-null + * (per PR #3 review). + */ +export type ApplicationPermissionGrant = Required< + Pick +> & { + delegatedPermissions: string[]; + applicationPermissions: string[]; +}; + +// ─── Graph API Types: Containers ───────────────────────────────────────────── + +/** + * A SharePoint Embedded container (Microsoft Graph `fileStorageContainer`). + * + * **WI-22 Phase 0 POC** — this alias is derived from the official + * `@microsoft/microsoft-graph-types` {@link FileStorageContainer} shape via a + * `Pick` + curated-JSDoc wrapper, replacing the former hand-maintained + * interface. The upstream package is **types-only** (lives in + * `devDependencies`) and contributes **zero runtime JavaScript** — it compiles + * away entirely. Field names already match the Graph resource 1:1, so the only + * curation needed is (a) selecting the subset this server consumes and + * (b) tightening always-present fields to non-optional for null-safety. + * + * Field selection rationale: + * - `Required>` — `id`, `displayName`, `containerTypeId`, and `status` + * are returned by Graph for every live container and are dereferenced by + * call sites without a null guard (e.g. `activateContainer(container.id)`). + * Marking them non-optional preserves the previous interface's *optionality* + * guarantees. Caveat: `Required` strips `?` but NOT `| null`, so the + * Graph `NullableOption` fields keep their `null` — here `status` widens to + * `"inactive" | "active" | "unknownFutureValue" | null` (the old field was a + * bare `string`). This is safe only because every consumer *compares* these + * fields (e.g. `=== "active"`) rather than passing them into a non-null + * `string` param. For richer types whose call sites dereference/pass + * `NullableOption` fields, wrap with `NonNullable<>` (or a guard) instead of + * a bare `Required>`. `id`/`displayName`/`containerTypeId` are plain + * `string` upstream (non-nullable), so `Required` yields clean `string`. + * - `Pick<…>` — `createdDateTime`, `description`, and `lockState` are + * genuinely optional and are always accessed defensively (`?? …`). + * + * Semantics preserved from the original curated interface: + * - `displayName` is the human-visible name and is **not** the `id`. Address + * containers by `id` in Graph calls; surface `displayName` to users. + * - `status` is `inactive` at creation and must be activated before use + * (see `activateContainer`). Official union: `inactive | active`. + * - `lockState` drives archive/restore: `lockedReadOnly` == archived, + * `unlocked` == writable. An absent value is treated as `unlocked`. + */ +export type Container = Required< + Pick +> & + Pick; + +/** + * A permission on an SPE container — a Microsoft Graph `permission` resource. + * `id`/`roles` derive from the official Graph `Permission` type; `roles` is + * `NonNullable` because we always read it (e.g. `roles.join(...)`). + * + * `grantedToV2` is kept as a narrowed local shape rather than the official + * `sharePointIdentitySet`: the official `identity` type does not surface + * `userPrincipalName`, which we render for container members + * (per PR #3 review feedback). + */ +export type ContainerPermission = Pick & { + roles: NonNullable; + // official `Identity` omits `userPrincipalName`; retain a + // narrowed local shape for just the member fields we actually read/render. + grantedToV2?: { + user?: { userPrincipalName: string; displayName?: string }; + }; +}; + +// ─── Graph API Types: Drive / Content ──────────────────────────────────────── + +export interface Drive { + id: string; + webUrl?: string; + quota?: { + used: number; + total: number; + }; +} + +/** + * A file or folder in a container's drive — a Microsoft Graph `driveItem`. + * Derived from the official `DriveItem`, keeping only the subset of fields we + * consume. `id`/`name` stay non-null (our code always reads them); the remaining + * fields keep the official optional/nullable shape and are only ever read + * null-tolerantly (per PR #3 review feedback). + */ +export type DriveItem = Required> & { + name: NonNullable; +} & Pick; + +export interface UploadSession { + uploadUrl: string; + expirationDateTime: string; +} + +/** + * A sharing link on a drive item. NOTE: in Microsoft Graph this is a + * `permission` resource that *carries* a `link` (the official `sharingLink` + * sub-object); our reads use the permission `id` plus `link.{type,scope,webUrl}`, + * so we derive from the official `Permission` type — the nested `link` is then + * the official `sharingLink` automatically (per PR #3 review feedback). + */ +export type SharingLink = Required> & Pick; + +export interface PreviewResult { + getUrl: string; +} + +export interface SearchHit { + resource: { + name: string; + webUrl: string; + size?: number; + lastModifiedDateTime?: string; + }; + summary?: string; +} + +export interface SearchResponse { + value: Array<{ + hitsContainers: Array<{ + total: number; + hits: SearchHit[]; + }>; + }>; +} + +export interface CustomProperties { + [key: string]: { + value: string; + isSearchable?: boolean; + }; +} diff --git a/src/user-agent.ts b/src/user-agent.ts new file mode 100644 index 0000000..29ba213 --- /dev/null +++ b/src/user-agent.ts @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Static product identifier stamped on outbound Microsoft Graph and Azure CLI + * (`az` / `azd`) requests for aggregate traffic attribution. + * + * This is a constant product/version token. It carries NO per-user, per-tenant, + * or personal data, opens NO separate telemetry channel, and rides only on the + * Graph/ARM calls the tool already makes on the user's behalf (e.g. creating a + * container type). The SharePoint Embedded service can filter request logs on + * this token to measure how much traffic this tool drives. + * + * The version segment is derived from package.json (the single source of truth) + * via {@link PACKAGE_VERSION}, so it can never drift out of sync on release. + */ +import { PACKAGE_VERSION } from "./version.js"; + +export const USER_AGENT = `spe-mcp-server/${PACKAGE_VERSION}`; diff --git a/src/validation.test.ts b/src/validation.test.ts new file mode 100644 index 0000000..9cbe72a --- /dev/null +++ b/src/validation.test.ts @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for the shared input-validation helpers. + */ + +import { describe, it, expect } from "vitest"; +import { requireString, validationError } from "./validation.js"; +import type { McpToolResult } from "./types.js"; + +describe("requireString", () => { + it("accepts and trims a non-empty string", () => { + const r = requireString(" hello ", "query"); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe("hello"); + }); + + it.each([undefined, null, 123, {}, [], true])( + "rejects non-string / missing value (%p) with a clean envelope", + (value) => { + const r = requireString(value, "query"); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error.isError).toBe(true); + expect(r.error.content[0].text).toBe("Error: query is required"); + } + }, + ); + + it("rejects an empty / whitespace-only string", () => { + for (const v of ["", " "]) { + const r = requireString(v, "url"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.content[0].text).toBe("Error: url is required"); + } + }); +}); + +describe("validationError", () => { + it("builds the standard error envelope", () => { + const e = validationError("formats must be a non-empty array"); + expect(e.isError).toBe(true); + expect(e.content[0].text).toBe("Error: formats must be a non-empty array"); + }); +}); + +describe("documented usage example (module JSDoc)", () => { + // Mirrors the `@example` in validation.ts: guard a handler argument, return the + // envelope on failure, use the trimmed value on success — no `as string` cast. + function handlerGuard(args: Record): string | McpToolResult { + const parsed = requireString(args.containerId, "containerId"); + if (!parsed.ok) return parsed.error; + return parsed.value; + } + + it("returns the trimmed value for a valid argument", () => { + expect(handlerGuard({ containerId: " c1 " })).toBe("c1"); + }); + + it("returns the standard error envelope for a missing / non-string argument", () => { + for (const bad of [{}, { containerId: 123 }, { containerId: "" }]) { + const r = handlerGuard(bad); + expect(typeof r).not.toBe("string"); + if (typeof r !== "string") { + expect(r.isError).toBe(true); + expect(r.content[0].text).toBe("Error: containerId is required"); + } + } + }); +}); diff --git a/src/validation.ts b/src/validation.ts new file mode 100644 index 0000000..cdaaddf --- /dev/null +++ b/src/validation.ts @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Shared, reusable input-validation helpers for MCP tool handlers. + * + * MCP clients can send arbitrary JSON, so a tool's declared inputSchema is not + * enforced at the transport boundary — handlers must defend against missing or + * wrong-typed arguments themselves. These helpers return the standard MCP error + * envelope (`{ content, isError: true }`) so handlers fail with a clean, + * actionable validation message instead of leaking an internal TypeError. + * + * NEW TOOLS should prefer the Zod-based {@link defineTool} factory + the shared + * field builders in `./tooling/fields.ts`, which derive the advertised + * `inputSchema` and the runtime check from one schema. These imperative helpers + * remain for tools that have not yet migrated and for one-off guards. + * + * @example + * // Guard a handler argument before use — no `as string` cast, no TypeError on + * // a numeric/object/missing value: + * import { requireString } from "../validation.js"; + * + * const parsed = requireString(args.containerId, "containerId"); + * if (!parsed.ok) return parsed.error; // standard { isError: true } envelope + * const containerId = parsed.value; // trimmed, guaranteed non-empty string + */ + +import type { McpToolResult } from "./types.js"; + +/** Build the standard validation-error envelope used across tools. */ +export function validationError(message: string): McpToolResult { + return { + content: [{ type: "text" as const, text: `Error: ${message}` }], + isError: true, + }; +} + +/** + * Require that `value` is a non-empty (after trim) string. + * + * Returns the trimmed string when valid. When `value` is missing, not a string, + * or only whitespace, returns the standard error envelope with the message + * `" is required"` — identical for the missing and wrong-typed cases so a + * non-string argument never throws an uncaught TypeError. + */ +export function requireString( + value: unknown, + name: string, +): { ok: true; value: string } | { ok: false; error: McpToolResult } { + if (typeof value !== "string" || value.trim() === "") { + return { ok: false, error: validationError(`${name} is required`) }; + } + return { ok: true, value: value.trim() }; +} diff --git a/src/version.test.ts b/src/version.test.ts new file mode 100644 index 0000000..706e3fa --- /dev/null +++ b/src/version.test.ts @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Version single-source-of-truth regression tests (WI-04). + * + * package.json's `version` field is the ONE place the version is declared; + * `PACKAGE_VERSION`, `USER_AGENT`, and the server `version` must all DERIVE + * from it. These assertions encode that acceptance criterion so a future + * hand-edited literal (the drift that motivated this fix) fails CI. + * + * Note the assertions compare against the value read from package.json at + * runtime — they do NOT re-introduce a hard-coded version literal. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { PACKAGE_VERSION } from "./version.js"; +import { USER_AGENT } from "./user-agent.js"; + +const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +const pkgVersion = ( + JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8")) as { version: string } +).version; + +describe("version: single source of truth", () => { + it("sources PACKAGE_VERSION from package.json", () => { + expect(PACKAGE_VERSION).toBe(pkgVersion); + }); + + it("derives USER_AGENT from package.json in the spe-mcp-server/ format", () => { + expect(USER_AGENT).toBe(`spe-mcp-server/${pkgVersion}`); + }); +}); diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..3811025 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Single source of truth for the product version. + * + * `package.json`'s `version` field is the ONE place the version is declared. + * It is read here at runtime and re-exported as {@link PACKAGE_VERSION} so that + * every other version consumer — `SERVER_VERSION` (index.ts) and `USER_AGENT` + * (user-agent.ts) — derives from it and cannot drift out of sync. + * + * Reading package.json at runtime (rather than importing it) mirrors the + * mechanism already used by cli.ts and keeps the file outside `rootDir`, so the + * compiled `dist/` layout is unaffected. At runtime `dist/version.js` resolves + * `../package.json` to the package root, both from a source checkout and from + * the published npm package. + */ + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"); +const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { version: string }; + +/** The product version, sourced from `package.json`. */ +export const PACKAGE_VERSION: string = packageJson.version; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a950bd8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "resolveJsonModule": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..dec2ec7 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/**/*.test.ts"], + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts", "src/cli.ts"], + reporter: ["text", "lcov"], + // TEST-004: gate against coverage regressions. Measured baseline (pre-this + // change, 33 files / 424 tests): lines 65.94, statements 65.94, + // functions 63.97, branches 77.58. Thresholds are set a few points BELOW + // the baseline so CI catches real drops without flaking on minor churn. + // NOTE: the protocol-e2e harness spawns the server in a child process, so + // that server-side execution is intentionally NOT counted toward in-process + // v8 coverage — these thresholds reflect the in-process suite. + thresholds: { + lines: 62, + statements: 62, + functions: 60, + branches: 73, + }, + }, + testTimeout: 10000, + }, +});