From 2ab36cfcaaee5cb90c624d1948ece9b29a065f88 Mon Sep 17 00:00:00 2001 From: "lorenzo.padovani@padosoft.com" Date: Mon, 18 May 2026 04:47:16 +0200 Subject: [PATCH 1/3] feat(v1.1/W2): TanStack Query foundation + typed API client + Sanctum auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave W2 of the v1.1 cycle establishes the data layer that backs every read + mutation in v1.1. The SPA stays fully functional on fixture data after this PR merges; W3 swaps pages page-by-page. Files added - resources/js/lib/api/types.ts — 19 hand-written types mirroring OpenAPI 3.1 v1.5 - resources/js/lib/api/errors.ts — typed error hierarchy (ApiError + 5 subclasses) - resources/js/lib/api/client.ts — axios singleton + Sanctum XSRF interceptor + response error mapper - resources/js/lib/api/endpoints.ts — 22 typed endpoint helpers - resources/js/lib/queries/queryClient.ts — admin-tuned shared QueryClient - resources/js/lib/queries/keys.ts — centralised query-key factory - resources/js/lib/queries/hooks.ts — 13 read hooks - resources/js/lib/mutations/hooks.ts — 10 mutation hooks (incl. R21 confirm-token protocol) - resources/js/env.d.ts — VITE_API_BASE type declaration - tests/js/lib/api/{client,endpoints}.test.ts — 35 specs - tests/js/lib/queries/hooks.test.tsx — 9 specs - tests/js/lib/mutations/hooks.test.tsx — 9 specs - tests/js/lib/queries/wrapper.tsx — shared test factory - tests/js/lib/api/server.ts — shared MSW v2 setupServer Files modified - resources/js/main.tsx — QueryClientProvider + ReactQueryDevtools wrapping - resources/js/App.tsx — auth:expired event listener + dedicated toast (R11) - resources/js/lib/ui.tsx — toast root now passes through data-testid (R11) - tests/js/setup.ts — MSW lifecycle wiring - vite.config.ts — axios + TanStack Query in optimizeDeps.include - CHANGELOG.md — [Unreleased] → v1.1.0 entry - package.json + package-lock.json — added @tanstack/react-query, axios, devtools, msw Test delta - Vitest 7 → 64 (+57) - PHPUnit 8 → 8 (unchanged — no PHP touched) - Build: 346 KB / 99 KB gzipped (~+56 KB raw / ~+14 KB gzipped) R-rules honoured - R11 toast testid passthrough + auth-expired-toast testid - R14 typed errors thrown, never silent success - R19 every dynamic path segment via encodeURIComponent - R21 two-call confirm-token protocol on invokeTool / replayAudit / resetBreaker - R30 client never sets X-Tenant-Id; host middleware owns tenant resolution - Standalone-agnostic invariant preserved (zero AskMyDocs host refs) --- CHANGELOG.md | 119 ++++ package-lock.json | 758 ++++++++++++++++++++++-- package.json | 4 + resources/js/App.tsx | 15 +- resources/js/env.d.ts | 16 + resources/js/lib/api/client.ts | 197 ++++++ resources/js/lib/api/endpoints.ts | 339 +++++++++++ resources/js/lib/api/errors.ts | 132 +++++ resources/js/lib/api/types.ts | 255 ++++++++ resources/js/lib/mutations/hooks.ts | 189 ++++++ resources/js/lib/queries/hooks.ts | 163 +++++ resources/js/lib/queries/keys.ts | 42 ++ resources/js/lib/queries/queryClient.ts | 37 ++ resources/js/lib/ui.tsx | 2 +- resources/js/main.tsx | 12 +- tests/js/lib/api/client.test.ts | 188 ++++++ tests/js/lib/api/endpoints.test.ts | 343 +++++++++++ tests/js/lib/api/server.ts | 7 + tests/js/lib/mutations/hooks.test.tsx | 194 ++++++ tests/js/lib/queries/hooks.test.tsx | 122 ++++ tests/js/lib/queries/wrapper.tsx | 22 + tests/js/setup.ts | 21 +- vite.config.ts | 6 + 23 files changed, 3130 insertions(+), 53 deletions(-) create mode 100644 resources/js/env.d.ts create mode 100644 resources/js/lib/api/client.ts create mode 100644 resources/js/lib/api/endpoints.ts create mode 100644 resources/js/lib/api/errors.ts create mode 100644 resources/js/lib/api/types.ts create mode 100644 resources/js/lib/mutations/hooks.ts create mode 100644 resources/js/lib/queries/hooks.ts create mode 100644 resources/js/lib/queries/keys.ts create mode 100644 resources/js/lib/queries/queryClient.ts create mode 100644 tests/js/lib/api/client.test.ts create mode 100644 tests/js/lib/api/endpoints.test.ts create mode 100644 tests/js/lib/api/server.ts create mode 100644 tests/js/lib/mutations/hooks.test.tsx create mode 100644 tests/js/lib/queries/hooks.test.tsx create mode 100644 tests/js/lib/queries/wrapper.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 0597bef..fd1fc33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,125 @@ All notable changes to `padosoft/askmydocs-mcp-pack-admin` are documented here. The project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] → v1.1.0 + +### Added — TanStack Query foundation (W2) + +The data layer that backs every read + mutation in v1.1. The SPA stays +fully functional on fixture data after this PR merges; subsequent W3 +sub-PRs swap pages over to the live hooks one route at a time so the +fixture-vs-real-data delta is reviewable in isolation. + +- **`resources/js/lib/api/types.ts`** — hand-written TypeScript types + mirroring all 19 schemas in `padosoft/askmydocs-mcp-pack` OpenAPI 3.1 + spec v1.5 (HostUser / HostTenant / HostApiKey / McpServer / Tool / + AuditRow / AuditDetail / BreakerState / Resource / Prompt / AuditEvent + / ConfirmTokenMint / ApiErrorPayload / ValidationErrorPayload + filters + and envelopes). 1:1 with the wire shape — when the spec moves, this + file moves with it. +- **`resources/js/lib/api/errors.ts`** — typed error hierarchy thrown by + the axios client (`ApiError` base + `NetworkError` / + `AuthExpiredError` / `FeatureDisabledError` / `ConfirmTokenError` / + `ValidationError` subclasses). TanStack Query's `onError` distinguishes + by `instanceof`, never by string matching (R14 — surface failures + loudly). +- **`resources/js/lib/api/client.ts`** — singleton axios instance with: + - `baseURL` resolved from `import.meta.env.VITE_API_BASE` → + `window.__MCP_PACK_ADMIN__.api_base` → hard-coded + `/api/admin/mcp-pack`. + - `withCredentials: true` for Sanctum cookie auth. + - Request interceptor: read `XSRF-TOKEN` cookie + echo as + `X-XSRF-TOKEN` header on every non-GET request (URL-decoded; + Laravel convention). + - Response interceptor: 401 → `AuthExpiredError` + fires global + `auth:expired` CustomEvent; 403 `feature_disabled` → + `FeatureDisabledError`; 422 confirmation codes → `ConfirmTokenError`; + 422 with Laravel validation shape → `ValidationError`; network + failures → `NetworkError`. + - `apiBase()` / `getApiClient()` / `setApiClient()` / `request()` + typed helpers. +- **`resources/js/lib/api/endpoints.ts`** — one typed async function per + OpenAPI endpoint (22 functions). Every dynamic path segment goes + through `encodeURIComponent` (R19). `invokeTool` / `replayAudit` / + `resetBreaker` implement the two-call confirm-token protocol (R21) — + 202 responses throw `ConfirmTokenError` carrying the minted token; the + UI prompts the operator and calls again with the token bound in the + body. `subscribeEvents()` wraps `EventSource` with envelope-aware JSON + parsing. +- **`resources/js/lib/queries/queryClient.ts`** — shared `QueryClient` + factory with admin-tuned defaults (`staleTime: 30s`, + `refetchOnWindowFocus: false`, `retry: 1` for queries, `retry: 0` for + mutations; auth-expired + feature-disabled errors short-circuit retry). +- **`resources/js/lib/queries/keys.ts`** — centralised query-key + factory. Every read hook + every mutation invalidation routes through + this surface. +- **`resources/js/lib/queries/hooks.ts`** — 13 read hooks: `useMe`, + `useTenants`, `useApiKeys`, `useServers`, `useServer`, `useServerTools`, + `useTools`, `useResources`, `useResource`, `usePrompts`, `usePrompt`, + `useAudit`, `useAuditDetail`, `useBreakers`. ID-keyed hooks gate on + `enabled: Boolean(id)` to avoid spurious requests on initial render. +- **`resources/js/lib/mutations/hooks.ts`** — 10 mutation hooks: + `useUpdatePreferences`, `useCreateApiKey`, `useRevokeApiKey` + (optimistic), `useCreateServer`, `useUpdateServer`, `useDeleteServer`, + `useHandshake`, `useInvokeTool`, `useReplayAudit`, `useResetBreaker`. + Confirm-token-aware mutations re-throw `ConfirmTokenError` for the UI + layer to handle; on success they invalidate the matching read keys. +- **`resources/js/env.d.ts`** — typed `import.meta.env.VITE_API_BASE` + declaration so `vite build --mode production` enforces the contract. +- **`resources/js/main.tsx`** — wrapped `` in + ``; `` mounted only when + `import.meta.env.DEV`. +- **`resources/js/App.tsx`** — added `auth:expired` event listener + + dedicated toast with `data-testid="auth-expired-toast"` for E2E + assertions (R11). NO read-path swap yet — every page still renders + from `lib/data.ts` fixtures; W3 swaps page-by-page. +- **`vite.config.ts`** — added `axios`, `@tanstack/react-query` and + `@tanstack/react-query-devtools` to `optimizeDeps.include` so the dev + server boots without on-demand cold starts. + +### Tests + +- `tests/js/lib/api/client.test.ts` — 11 specs covering XSRF echo + + every error-mapping branch (401 → AuthExpiredError + CustomEvent / + 403 feature_disabled / 422 confirmation codes / 422 validation shape / + network error / generic 5xx) backed by MSW v2. +- `tests/js/lib/api/endpoints.test.ts` — 24 specs covering one + happy-path per endpoint + the two-call confirm-token protocol + (`invokeTool` / `replayAudit` / `resetBreaker`). +- `tests/js/lib/queries/hooks.test.tsx` — 9 specs exercising + `loading → success → cached` transitions through `renderHook` + + `QueryClientProvider`. +- `tests/js/lib/mutations/hooks.test.tsx` — 9 specs covering create / + update / handshake + the destructive flow (`useInvokeTool` / + `useReplayAudit` / `useResetBreaker`) with explicit confirm-token + round-trips. +- `tests/js/setup.ts` — wires MSW `setupServer` lifecycle + (`listen` / `resetHandlers` / `close`) into Vitest globals. + +Vitest test count: **7 → 64** (+57). + +### Dependencies + +- Added `@tanstack/react-query` ^5 and `axios` ^1 (runtime). +- Added `@tanstack/react-query-devtools` ^5 and `msw` ^2 (dev). + +### Bundle size + +- Production build: + `main-*.js` 346 KB / 99 KB gzipped (was ~290 KB / ~85 KB before; + delta ~+56 KB raw / ~+14 KB gzipped — matches the projected TanStack + Query + axios overhead). +- `main-*.css` unchanged at 46 KB / 8.7 KB gzipped. + +### Notes + +- W3 wires read-paths page-by-page; the SPA continues to render fixture + data after this PR merges. Reviewers can confirm by running + `npm run dev` and inspecting any route — every existing page still + works against the in-memory dataset. +- Standalone-agnostic invariant preserved: zero references to the + AskMyDocs host code from this package. + ## [v1.0.1] — 2026-05-17 ### Changed diff --git a/package-lock.json b/package-lock.json index 46b24c9..f49dc5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,15 @@ "name": "@padosoft/askmydocs-mcp-pack-admin", "version": "1.0.0", "dependencies": { + "@tanstack/react-query": "^5.100.10", + "axios": "^1.16.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.27.0" }, "devDependencies": { "@playwright/test": "^1.49.0", + "@tanstack/react-query-devtools": "^5.100.10", "@testing-library/jest-dom": "^6.6.0", "@testing-library/react": "^16.0.0", "@types/react": "^18.3.12", @@ -21,12 +24,13 @@ "@vitejs/plugin-react": "^6.0.2", "eslint": "^9.15.0", "jsdom": "^25.0.1", + "msw": "^2.14.6", "typescript": "^5.6.3", "vite": "^8.0.13", "vitest": "^4.1.6" }, "engines": { - "node": ">=20.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@adobe/css-tools": { @@ -202,31 +206,6 @@ "node": ">=18" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -448,6 +427,93 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@inquirer/ansi": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.5.tgz", + "integrity": "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.13.tgz", + "integrity": "sha512-wkGPC7yJ5WJk1DJ5SX7fzk+gfj4BM8cf5dDDi71B/551xHrdsZVRJOC0WyikXd0pEsb/9cLniuE4atbsMqmFkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.10", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.1.10", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.10.tgz", + "integrity": "sha512-a4Q5BXHQAHa9eO202sTaFCHFYVB3x5fauDuThEAdZ9gfn76pSxiKU7wWcEH0N1O0XmQvNfQNU6QXpiRxmYQx+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.5", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.5.tgz", + "integrity": "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.5.tgz", + "integrity": "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -455,6 +521,31 @@ "dev": true, "license": "MIT" }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.9", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", + "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -474,6 +565,31 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@oxc-project/types": { "version": "0.130.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", @@ -780,6 +896,62 @@ "dev": true, "license": "MIT" }, + "node_modules/@tanstack/query-core": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz", + "integrity": "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.100.10.tgz", + "integrity": "sha512-3DmJf25hDPus5IpVvp6ujXv6bKV2zPzI9vpbAmpJigsL/H6DPvPjmf7/Q9yVKEke//8fgeQ45abjgnLuyYxAiw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.10.tgz", + "integrity": "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "@tanstack/query-core": "5.100.10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.100.10.tgz", + "integrity": "sha512-zes0+o9ef5rAZXJ9f/SeaLs2nufJaeVkZkl/Or9NGrWVF41kL9Od9ED9nCwtQlgiF2VGtrzhEw5AU/igAO+aAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.100.10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.100.10", + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -906,6 +1078,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", + "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -936,6 +1119,23 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", + "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", @@ -1183,9 +1383,45 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1208,7 +1444,6 @@ "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==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -1255,6 +1490,31 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1279,7 +1539,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -1302,6 +1561,20 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "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", @@ -1370,7 +1643,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1402,7 +1674,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -1439,7 +1710,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -1450,6 +1720,13 @@ "node": ">= 0.4" } }, + "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/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -1467,7 +1744,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -1477,7 +1753,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -1494,7 +1769,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -1507,7 +1781,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -1519,6 +1792,16 @@ "node": ">= 0.4" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1728,6 +2011,33 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", + "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1797,11 +2107,30 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -1833,17 +2162,25 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "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==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -1868,7 +2205,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -1908,7 +2244,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -1917,6 +2252,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graphql": { + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.0.tgz", + "integrity": "sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -1931,7 +2276,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -1944,7 +2288,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -1960,7 +2303,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -1969,6 +2311,17 @@ "node": ">= 0.4" } }, + "node_modules/headers-polyfill": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", + "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/set-cookie-parser": "^2.4.10", + "set-cookie-parser": "^3.0.1" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -2080,6 +2433,16 @@ "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", @@ -2093,6 +2456,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -2540,7 +2910,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2550,7 +2919,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -2560,7 +2928,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -2596,9 +2963,97 @@ "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/msw": { + "version": "2.14.6", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.14.6.tgz", + "integrity": "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@inquirer/confirm": "^6.0.11", + "@mswjs/interceptors": "^0.41.3", + "@open-draft/deferred-promise": "^3.0.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.1.1", + "graphql": "^16.13.2", + "headers-polyfill": "^5.0.1", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.11.11", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.1", + "type-fest": "^5.5.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/tldts": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", + "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.30" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/msw/node_modules/tldts-core": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", + "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", "dev": true, "license": "MIT" }, + "node_modules/msw/node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -2661,6 +3116,13 @@ "node": ">= 0.8.0" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -2739,6 +3201,13 @@ "node": ">=8" } }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2866,6 +3335,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2956,6 +3434,16 @@ "node": ">=8" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "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", @@ -2966,6 +3454,13 @@ "node": ">=4" } }, + "node_modules/rettime": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz", + "integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/rolldown": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", @@ -3036,6 +3531,13 @@ "loose-envify": "^1.1.0" } }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true, + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3066,6 +3568,19 @@ "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", @@ -3083,6 +3598,16 @@ "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", @@ -3090,6 +3615,41 @@ "dev": true, "license": "MIT" }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, + "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/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-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -3136,6 +3696,19 @@ "dev": true, "license": "MIT" }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3247,12 +3820,29 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "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", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3261,6 +3851,23 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -3559,6 +4166,24 @@ "node": ">=0.10.0" } }, + "node_modules/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/ws": { "version": "8.20.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", @@ -3598,6 +4223,45 @@ "dev": true, "license": "MIT" }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index fec0bad..e51b1e4 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,15 @@ "lint": "eslint resources/js --ext .ts,.tsx" }, "dependencies": { + "@tanstack/react-query": "^5.100.10", + "axios": "^1.16.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.27.0" }, "devDependencies": { "@playwright/test": "^1.49.0", + "@tanstack/react-query-devtools": "^5.100.10", "@testing-library/jest-dom": "^6.6.0", "@testing-library/react": "^16.0.0", "@types/react": "^18.3.12", @@ -29,6 +32,7 @@ "@vitejs/plugin-react": "^6.0.2", "eslint": "^9.15.0", "jsdom": "^25.0.1", + "msw": "^2.14.6", "typescript": "^5.6.3", "vite": "^8.0.13", "vitest": "^4.1.6" diff --git a/resources/js/App.tsx b/resources/js/App.tsx index ec19155..6b3f4d9 100644 --- a/resources/js/App.tsx +++ b/resources/js/App.tsx @@ -159,19 +159,32 @@ function Shell() { }; const onNavEvt = (e: any) => nav(e.detail); const onOpenAudit = (e: any) => nav('audit/' + e.detail); + // The API client interceptor fires this event on every 401 so the SPA + // can surface a single, deduplicated session-expired toast (W2 wiring; + // actual sign-out / refresh flow lives further upstream). + const onAuthExpired = () => { + toast.push({ + kind: 'err', + title: 'Session expired', + body: 'Please reload the page to sign in again.', + testId: 'auth-expired-toast', + }); + }; document.addEventListener('app:toggle-theme', onToggleTheme as any); document.addEventListener('app:toggle-paused', onTogglePaused as any); document.addEventListener('app:start-tour', onStartTour as any); document.addEventListener('app:nav', onNavEvt as any); document.addEventListener('app:open-audit', onOpenAudit as any); + document.addEventListener('auth:expired', onAuthExpired as any); return () => { document.removeEventListener('app:toggle-theme', onToggleTheme as any); document.removeEventListener('app:toggle-paused', onTogglePaused as any); document.removeEventListener('app:start-tour', onStartTour as any); document.removeEventListener('app:nav', onNavEvt as any); document.removeEventListener('app:open-audit', onOpenAudit as any); + document.removeEventListener('auth:expired', onAuthExpired as any); }; - }, [nav]); + }, [nav, toast]); const route = routeKeyFromPath(location.pathname); const topRoute = diff --git a/resources/js/env.d.ts b/resources/js/env.d.ts new file mode 100644 index 0000000..8aae154 --- /dev/null +++ b/resources/js/env.d.ts @@ -0,0 +1,16 @@ +/// + +// Vite environment variables exposed at build time. Set `VITE_API_BASE` to +// override the default `/api/admin/mcp-pack` prefix (e.g. when the SPA is +// served from a CDN and the API lives on a different origin). Runtime +// overrides flow through `window.__MCP_PACK_ADMIN__.api_base` instead. +interface ImportMetaEnv { + readonly VITE_API_BASE?: string; + readonly DEV: boolean; + readonly PROD: boolean; + readonly MODE: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/resources/js/lib/api/client.ts b/resources/js/lib/api/client.ts new file mode 100644 index 0000000..e3d727e --- /dev/null +++ b/resources/js/lib/api/client.ts @@ -0,0 +1,197 @@ +// axios instance + interceptors backing every API call from the SPA. +// +// Auth model: Laravel Sanctum cookie auth — the host issues an `XSRF-TOKEN` +// cookie, we mirror it back in the `X-XSRF-TOKEN` header on every mutating +// request. `withCredentials: true` ensures the cookie is sent cross-origin +// when the SPA is mounted on a different origin from the API. +// +// Error model: every non-2xx response is normalised to an `ApiError` +// subclass (AuthExpiredError / FeatureDisabledError / ConfirmTokenError / +// ValidationError / generic ApiError) so TanStack Query's `onError` can +// distinguish without parsing strings. + +import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; +import { + ApiError, + AuthExpiredError, + FeatureDisabledError, + ConfirmTokenError, + NetworkError, + ValidationError, +} from './errors'; +import type { ApiErrorPayload, ValidationErrorPayload } from './types'; + +/** + * Resolve the API base URL. Precedence (most-specific first): + * 1. `import.meta.env.VITE_API_BASE` — build-time override (set in CI when + * bundling against a remote API origin). + * 2. `window.__MCP_PACK_ADMIN__.api_base` — runtime override seeded by the + * Blade shell from `config('mcp-pack-admin.api_base')`. + * 3. Hard-coded `/api/admin/mcp-pack` — matches the host's default prefix. + */ +export function apiBase(): string { + const fromEnv = + typeof import.meta !== 'undefined' && import.meta.env + ? import.meta.env.VITE_API_BASE + : undefined; + if (fromEnv) return stripTrailingSlash(fromEnv); + + const fromWindow = + typeof window !== 'undefined' + ? (window as { __MCP_PACK_ADMIN__?: { api_base?: string } }).__MCP_PACK_ADMIN__?.api_base + : undefined; + if (fromWindow) return stripTrailingSlash(fromWindow); + + return '/api/admin/mcp-pack'; +} + +function stripTrailingSlash(s: string): string { + return s.replace(/\/+$/, ''); +} + +/** + * Read the `XSRF-TOKEN` cookie. Laravel sets this with a URL-encoded value + * which we must decode before echoing back as the `X-XSRF-TOKEN` header + * (raw `%3D` padding in the header is rejected by the middleware). + */ +function readXsrfCookie(): string | null { + if (typeof document === 'undefined') return null; + const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/); + if (!match) return null; + try { + return decodeURIComponent(match[1]); + } catch { + return match[1]; + } +} + +/** + * Fire a global `auth:expired` CustomEvent. App.tsx listens at the shell + * root and surfaces a toast — this keeps mutation hooks free of cross-cutting + * concerns. + */ +function emitAuthExpired(): void { + if (typeof document === 'undefined') return; + try { + document.dispatchEvent(new CustomEvent('auth:expired')); + } catch { + /* jsdom in older Node may not have CustomEvent — swallow */ + } +} + +const MUTATING_METHODS = new Set(['post', 'put', 'patch', 'delete']); + +/** + * Create a configured axios instance. Exposed as a factory so tests can spin + * up an isolated client against MSW handlers without sharing interceptor + * state with the singleton. + */ +export function createApiClient(baseURL?: string): AxiosInstance { + const client = axios.create({ + baseURL: baseURL ?? apiBase(), + withCredentials: true, + timeout: 30_000, + headers: { + Accept: 'application/json', + }, + }); + + client.interceptors.request.use((config) => { + const method = (config.method ?? 'get').toLowerCase(); + if (MUTATING_METHODS.has(method)) { + const xsrf = readXsrfCookie(); + if (xsrf) { + // axios v1 ships AxiosHeaders (class) for config.headers — `set()` is + // the canonical mutation entry-point and works on the legacy object + // shape too via prototype lookup. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const headers = (config.headers ?? {}) as any; + if (typeof headers.set === 'function') { + headers.set('X-XSRF-TOKEN', xsrf); + } else { + headers['X-XSRF-TOKEN'] = xsrf; + } + config.headers = headers; + } + } + return config; + }); + + client.interceptors.response.use( + (response) => response, + (error: AxiosError) => { + // Network-level / no-response failures. + if (!error.response) { + return Promise.reject(new NetworkError(error.message || 'Network error')); + } + + const { status, data } = error.response as AxiosResponse< + ApiErrorPayload | ValidationErrorPayload + >; + + if (status === 401) { + emitAuthExpired(); + return Promise.reject(new AuthExpiredError(data as ApiErrorPayload)); + } + + if (status === 403) { + const payload = data as ApiErrorPayload; + if (payload?.error?.code === 'feature_disabled') { + return Promise.reject(new FeatureDisabledError(payload)); + } + return Promise.reject( + new ApiError(payload?.error?.message ?? 'Forbidden', 403, payload?.error?.code ?? 'forbidden', payload), + ); + } + + if (status === 422) { + const payload = data as ApiErrorPayload & ValidationErrorPayload; + const code = payload?.error?.code; + if (code === 'confirmation_invalid' || code === 'confirmation_required' || code === 'confirmation_expired') { + return Promise.reject(new ConfirmTokenError(payload as ApiErrorPayload)); + } + // Treat any 422 that quacks like Laravel validation as ValidationError. + if (payload && (payload.errors || payload.message)) { + return Promise.reject(new ValidationError(payload as ValidationErrorPayload)); + } + return Promise.reject( + new ApiError(payload?.error?.message ?? 'Unprocessable entity', 422, payload?.error?.code ?? 'unprocessable', payload), + ); + } + + const payload = data as ApiErrorPayload; + return Promise.reject( + new ApiError( + payload?.error?.message ?? `Request failed with status ${status}`, + status, + payload?.error?.code ?? `http_${status}`, + payload, + ), + ); + }, + ); + + return client; +} + +// Default singleton client used by every endpoint helper. Tests can override +// by calling `setApiClient(createApiClient('http://localhost/...'))`. +let _client: AxiosInstance = createApiClient(); + +export function getApiClient(): AxiosInstance { + return _client; +} + +export function setApiClient(client: AxiosInstance): void { + _client = client; +} + +/** + * Thin typed wrapper that returns `response.data` directly. Use this when + * the endpoint helper has nothing custom to do beyond passing through the + * payload. + */ +export async function request(config: AxiosRequestConfig): Promise { + const response = await getApiClient().request(config); + return response.data; +} diff --git a/resources/js/lib/api/endpoints.ts b/resources/js/lib/api/endpoints.ts new file mode 100644 index 0000000..aba948a --- /dev/null +++ b/resources/js/lib/api/endpoints.ts @@ -0,0 +1,339 @@ +// One typed async function per OpenAPI endpoint. Every function: +// - returns the wire payload `data` (envelope-unwrapped where the OpenAPI +// spec wraps the resource in `{ data: ... }`) +// - URL-encodes every dynamic path segment via `encodeURIComponent` +// (R19 — never string-concatenate user input) +// - never sets `X-Tenant-Id` (R30 — the host middleware owns tenant +// resolution) +// +// Callers consume these via the TanStack Query hooks in +// `resources/js/lib/queries/` and `resources/js/lib/mutations/`. Direct +// imports are fine for unit tests but not for components. + +import { request, getApiClient } from './client'; +import { ConfirmTokenError } from './errors'; +import type { + AuditDetail, + AuditEvent, + AuditListFilters, + AuditRow, + BreakerState, + CreateApiKeyRequest, + HostApiKey, + HostApiKeyCreateEnvelope, + HostTenant, + HostUser, + HostUserEnvelope, + ListEnvelope, + McpServer, + McpServerEnvelope, + McpServerPage, + McpServerWrite, + Prompt, + Resource, + ResourceContent, + ResourceContentEnvelope, + ServerListFilters, + Tool, + ToolInvokeResult, + UpdatePreferencesRequest, +} from './types'; + +// --------------------------------------------------------------------- // +// Identity +// --------------------------------------------------------------------- // + +export async function fetchMe(): Promise { + return request({ method: 'get', url: '/me' }); +} + +export async function updatePreferences(payload: UpdatePreferencesRequest): Promise { + await request({ method: 'post', url: '/me/preferences', data: payload }); +} + +export async function listTenants(): Promise { + const res = await request>({ method: 'get', url: '/tenants' }); + return res.data ?? []; +} + +export async function listApiKeys(): Promise { + const res = await request>({ method: 'get', url: '/api-keys' }); + return res.data ?? []; +} + +export async function createApiKey(payload: CreateApiKeyRequest): Promise { + return request({ method: 'post', url: '/api-keys', data: payload }); +} + +export async function revokeApiKey(id: string): Promise { + await request({ method: 'delete', url: `/api-keys/${encodeURIComponent(id)}` }); +} + +// --------------------------------------------------------------------- // +// Servers +// --------------------------------------------------------------------- // + +export async function listServers(filters: ServerListFilters = {}): Promise { + return request({ method: 'get', url: '/servers', params: filters }); +} + +export async function getServer(id: string): Promise { + const res = await request({ + method: 'get', + url: `/servers/${encodeURIComponent(id)}`, + }); + return res.data; +} + +export async function createServer(body: McpServerWrite): Promise { + const res = await request({ method: 'post', url: '/servers', data: body }); + return res.data; +} + +export async function updateServer(id: string, patch: Partial): Promise { + await request({ + method: 'patch', + url: `/servers/${encodeURIComponent(id)}`, + data: patch, + }); +} + +export async function deleteServer(id: string): Promise { + await request({ method: 'delete', url: `/servers/${encodeURIComponent(id)}` }); +} + +export async function handshakeServer(id: string): Promise { + await request({ method: 'post', url: `/servers/${encodeURIComponent(id)}/handshake` }); +} + +export async function listServerTools(id: string): Promise { + const res = await request>({ + method: 'get', + url: `/servers/${encodeURIComponent(id)}/tools`, + }); + return res.data ?? []; +} + +// --------------------------------------------------------------------- // +// Tools +// --------------------------------------------------------------------- // + +export async function listTools(): Promise { + const res = await request>({ method: 'get', url: '/tools' }); + return res.data ?? []; +} + +/** + * Two-call confirm-token protocol: + * 1. First POST with no `confirm_token` — if the tool is destructive the + * server replies `202` with `{ confirm_token, expires_in }`. The client + * throws a `ConfirmTokenError` so the UI can prompt the operator. + * 2. Second POST with the minted token — server replies `200` with the + * actual invocation result. + * + * Non-destructive tools skip step 1 and reply `200` immediately. + */ +export async function invokeTool( + serverId: string, + toolName: string, + args: Record = {}, + confirmToken?: string, +): Promise { + const url = `/servers/${encodeURIComponent(serverId)}/tools/${encodeURIComponent(toolName)}/invoke`; + const body = confirmToken ? { ...args, confirm_token: confirmToken } : args; + + // We need the raw response to detect 202 vs 200 — the request() helper + // strips the envelope. + const response = await getApiClient().request({ + method: 'post', + url, + data: body, + }); + + if (response.status === 202) { + const mintData = response.data as { confirm_token?: string; expires_in?: number } | undefined; + throw new ConfirmTokenError( + { error: { code: 'confirmation_required', message: 'Confirm-token required for destructive invocation.' } }, + { confirm_token: mintData?.confirm_token, expires_in: mintData?.expires_in }, + ); + } + + return response.data as ToolInvokeResult; +} + +// --------------------------------------------------------------------- // +// Resources +// --------------------------------------------------------------------- // + +export async function listResources(serverId: string): Promise { + const res = await request>({ + method: 'get', + url: `/servers/${encodeURIComponent(serverId)}/resources`, + }); + return res.data ?? []; +} + +export async function getResource(serverId: string, uri: string): Promise { + const res = await request({ + method: 'get', + url: `/servers/${encodeURIComponent(serverId)}/resources/${encodeURIComponent(uri)}`, + }); + return res.data; +} + +// --------------------------------------------------------------------- // +// Prompts +// --------------------------------------------------------------------- // + +export async function listPrompts(serverId: string): Promise { + const res = await request>({ + method: 'get', + url: `/servers/${encodeURIComponent(serverId)}/prompts`, + }); + return res.data ?? []; +} + +export async function getPrompt(serverId: string, name: string): Promise { + const res = await request<{ data: Prompt }>({ + method: 'get', + url: `/servers/${encodeURIComponent(serverId)}/prompts/${encodeURIComponent(name)}`, + }); + return res.data; +} + +// --------------------------------------------------------------------- // +// Audit +// --------------------------------------------------------------------- // + +export async function listAudit(filters: AuditListFilters = {}): Promise { + const res = await request>({ + method: 'get', + url: '/audit', + params: filters, + }); + return res.data ?? []; +} + +export async function getAudit(id: string): Promise { + const res = await request<{ data: AuditDetail }>({ + method: 'get', + url: `/audit/${encodeURIComponent(id)}`, + }); + return res.data; +} + +/** + * Replay an audited tool call. Same two-call confirm-token protocol as + * {@link invokeTool}. + */ +export async function replayAudit(id: string, confirmToken?: string): Promise { + const url = `/audit/${encodeURIComponent(id)}/replay`; + const body = confirmToken ? { confirm_token: confirmToken } : undefined; + + const response = await getApiClient().request<{ confirm_token?: string; expires_in?: number }>({ + method: 'post', + url, + data: body, + }); + + if (response.status === 202) { + const mint = response.data; + throw new ConfirmTokenError( + { error: { code: 'confirmation_required', message: 'Confirm-token required to replay this audit.' } }, + { confirm_token: mint?.confirm_token, expires_in: mint?.expires_in }, + ); + } + + return response.data; +} + +// --------------------------------------------------------------------- // +// Resilience +// --------------------------------------------------------------------- // + +export async function listBreakers(): Promise { + const res = await request>({ method: 'get', url: '/circuit-breaker' }); + return res.data ?? []; +} + +/** + * Reset a circuit breaker. Two-call confirm-token protocol. + */ +export async function resetBreaker(key: string, confirmToken?: string): Promise { + const url = `/circuit-breaker/${encodeURIComponent(key)}/reset`; + const body = confirmToken ? { confirm_token: confirmToken } : undefined; + + const response = await getApiClient().request<{ confirm_token?: string; expires_in?: number }>({ + method: 'post', + url, + data: body, + }); + + if (response.status === 202) { + const mint = response.data; + throw new ConfirmTokenError( + { error: { code: 'confirmation_required', message: 'Confirm-token required to reset this breaker.' } }, + { confirm_token: mint?.confirm_token, expires_in: mint?.expires_in }, + ); + } +} + +// --------------------------------------------------------------------- // +// Events (SSE) +// --------------------------------------------------------------------- // + +/** + * Subscribe to the audit-invocation event stream. Returns a cleanup function + * that closes the underlying `EventSource`. Errors are surfaced via the + * `onError` callback; the EventSource auto-reconnects per spec until closed. + */ +export function subscribeEvents( + onMessage: (event: AuditEvent) => void, + onError?: (err: Event) => void, +): () => void { + if (typeof EventSource === 'undefined') { + // jsdom + node — no-op subscription useful for tests. + return () => undefined; + } + + // SSE doesn't carry the XSRF cookie reliably through cross-origin EventSource; + // we rely on the same-origin cookie session in production. The URL is the + // configured base + `/events`. + const url = `${stripTrailingSlash(resolveBase())}/events`; + const es = new EventSource(url, { withCredentials: true }); + + const handler = (ev: MessageEvent) => { + try { + const parsed = JSON.parse(ev.data) as AuditEvent; + onMessage(parsed); + } catch { + /* swallow malformed frame — server frame mis-format is a server bug */ + } + }; + + es.addEventListener('invocation', handler as EventListener); + es.addEventListener('message', handler as EventListener); + if (onError) { + es.addEventListener('error', onError); + } + + return () => { + es.removeEventListener('invocation', handler as EventListener); + es.removeEventListener('message', handler as EventListener); + if (onError) { + es.removeEventListener('error', onError); + } + es.close(); + }; +} + +function resolveBase(): string { + // Use the axios client's baseURL so VITE_API_BASE + window.__MCP_PACK_ADMIN__ + // overrides apply to SSE too. + const base = (getApiClient().defaults.baseURL ?? '/api/admin/mcp-pack') as string; + return base; +} + +function stripTrailingSlash(s: string): string { + return s.replace(/\/+$/, ''); +} diff --git a/resources/js/lib/api/errors.ts b/resources/js/lib/api/errors.ts new file mode 100644 index 0000000..f868861 --- /dev/null +++ b/resources/js/lib/api/errors.ts @@ -0,0 +1,132 @@ +// Typed error hierarchy thrown by the axios client. TanStack Query's onError +// receives one of these whenever an endpoint call fails; the UI distinguishes +// the error variants by `instanceof` (not by string matching). +// +// R14 — surface failures loudly. Never return success-shaped objects from +// the client when the server reported a failure. + +import type { ApiErrorPayload, ValidationErrorPayload } from './types'; + +/** + * Base class for every error originating from the API client. Carries the + * HTTP status and the original wire payload (if any) so the UI can inspect + * `error.payload?.error?.code` etc. without parsing strings. + */ +export class ApiError extends Error { + public readonly status: number; + public readonly code: string; + public readonly payload: ApiErrorPayload | ValidationErrorPayload | null; + + constructor( + message: string, + status: number, + code: string, + payload: ApiErrorPayload | ValidationErrorPayload | null = null, + ) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.code = code; + this.payload = payload; + } +} + +/** + * Network-level error (no HTTP status — DNS failure, connection refused, + * CORS preflight rejection). status is 0 by convention. + */ +export class NetworkError extends ApiError { + constructor(message: string) { + super(message, 0, 'network_error', null); + this.name = 'NetworkError'; + } +} + +/** + * 401 — the host session expired. The interceptor ALSO fires a global + * `auth:expired` CustomEvent so App.tsx can surface a "session expired" toast + * without every mutation having to wire its own onError handler. + */ +export class AuthExpiredError extends ApiError { + constructor(payload: ApiErrorPayload | null = null) { + super( + payload?.error?.message ?? 'Your session has expired. Please reload.', + 401, + payload?.error?.code ?? 'unauthenticated', + payload, + ); + this.name = 'AuthExpiredError'; + } +} + +/** + * 403 with `error.code === 'feature_disabled'` — the host has switched off + * the SPA route (`mcp-pack.admin.enabled = false` or a per-route feature + * toggle). Rendered as an empty-state, not a flash error. + */ +export class FeatureDisabledError extends ApiError { + constructor(payload: ApiErrorPayload | null = null) { + super( + payload?.error?.message ?? 'This feature has been disabled by the host operator.', + 403, + payload?.error?.code ?? 'feature_disabled', + payload, + ); + this.name = 'FeatureDisabledError'; + } +} + +/** + * 422 with `error.code === 'confirmation_invalid'` or `'confirmation_required'` + * — the second leg of the two-call confirm-token protocol failed (expired, + * mismatched payload, or never minted in the first place). + * + * `confirmTokenMint` is populated when the 202 first-leg response carried a + * fresh token the UI should prompt the operator to confirm. + */ +export class ConfirmTokenError extends ApiError { + public readonly reason: 'required' | 'invalid' | 'expired'; + public readonly confirmTokenMint: { + confirm_token?: string; + expires_in?: number; + } | null; + + constructor( + payload: ApiErrorPayload | null = null, + confirmTokenMint: { confirm_token?: string; expires_in?: number } | null = null, + ) { + const code = payload?.error?.code ?? 'confirmation_required'; + const reason = + code === 'confirmation_invalid' + ? 'invalid' + : code === 'confirmation_expired' + ? 'expired' + : 'required'; + + super( + payload?.error?.message ?? 'A confirmation token is required for this destructive action.', + 422, + code, + payload, + ); + this.name = 'ConfirmTokenError'; + this.reason = reason; + this.confirmTokenMint = confirmTokenMint; + } +} + +/** + * 422 with a Laravel validation envelope (`{ message, errors: { field: [...] } }`). + * Form components inspect `error.fieldErrors` to pin the message next to + * each input. + */ +export class ValidationError extends ApiError { + public readonly fieldErrors: Record; + + constructor(payload: ValidationErrorPayload | null) { + const fieldErrors = payload?.errors ?? {}; + super(payload?.message ?? 'Validation failed.', 422, 'validation_failed', payload); + this.name = 'ValidationError'; + this.fieldErrors = fieldErrors; + } +} diff --git a/resources/js/lib/api/types.ts b/resources/js/lib/api/types.ts new file mode 100644 index 0000000..fc4215c --- /dev/null +++ b/resources/js/lib/api/types.ts @@ -0,0 +1,255 @@ +// Hand-written TypeScript types mirroring the OpenAPI 3.1 spec shipped at +// padosoft/askmydocs-mcp-pack/resources/openapi/v1.5.json. Generated by hand +// (not via openapi-typescript codegen) because the surface is small enough +// that explicit, readable types are more maintainable than machine output. +// +// 1:1 with the OpenAPI components/schemas block. When the spec moves, this +// file moves with it. + +// ============================== Common ============================== // + +/** + * Standard error envelope: `{ error: { code, message } }`. Surfaced by every + * non-2xx response except 422 validation errors which use {@link ValidationError}. + */ +export interface ApiErrorPayload { + error: { + code: string; + message: string; + }; +} + +/** + * Laravel-style validation error envelope on 422. `errors` is a map of + * field-name → array of human-readable error strings. + */ +export interface ValidationErrorPayload { + message?: string; + errors?: Record; +} + +// ============================== Identity ============================== // + +export interface HostUser { + id: number | string; + email?: string; + name?: string; + initials?: string; + tenant_id?: string | null; + permissions?: string[]; +} + +export interface HostUserEnvelope { + data: HostUser; + meta?: { + tenant_id?: string | null; + }; +} + +export interface HostTenant { + id: string; + name: string; + primary?: boolean; +} + +export interface HostApiKey { + id: string; + name: string; + scopes: string[]; + last_used_at?: number | string | null; + created_at: number | string; + created_by?: string | null; +} + +/** + * Mint-time envelope — the `plaintext` field is surfaced exactly once. + */ +export interface HostApiKeyCreateEnvelope { + data: HostApiKey & { plaintext: string }; +} + +export interface CreateApiKeyRequest { + name: string; + scopes: string[]; +} + +export interface UpdatePreferencesRequest { + preferences: Record; +} + +// ============================== Servers ============================== // + +export type McpTransport = 'http' | 'sse' | 'stdio'; +export type McpServerStatus = 'ok' | 'warn' | 'err'; + +export interface McpServer { + id: string; + name: string; + transport: McpTransport; + url?: string; + status?: McpServerStatus; + enabled?: boolean; + tenant?: string; +} + +export interface McpServerWrite { + name: string; + transport: McpTransport; + url?: string; + enabled?: boolean; +} + +export interface McpServerEnvelope { + data: McpServer; +} + +export interface McpServerPage { + data: McpServer[]; + meta?: { + current_page?: number; + last_page?: number; + per_page?: number; + total?: number; + }; +} + +export interface ServerListFilters { + tenant?: string; + q?: string; + status?: string; + transport?: string; + enabled?: boolean; + page?: number; + per_page?: number; +} + +// ============================== Tools ============================== // + +export interface Tool { + server_id: string; + name: string; + description?: string; + /** + * JSON-schema fragment describing the tool's input parameters. Arbitrary + * shape — the wire schema is `additionalProperties: true`. + */ + input_schema?: Record; +} + +/** + * Tool invocation result. The body is fully arbitrary by spec — callers + * should narrow per tool. + */ +export type ToolInvokeResult = unknown; + +/** + * Confirm-token mint response shape on 202 — the FE must POST again with + * `{ confirm_token }` in the body to actually execute. + */ +export interface ConfirmTokenMint { + confirm_token: string; + /** The original request — replayed verbatim on the second call. */ + request_hash?: string; + /** Server-set TTL in seconds — best-effort hint to the UI. */ + expires_in?: number; + message?: string; +} + +// ============================== Resources / Prompts ============================== // + +export type ResourceKind = 'dir' | 'file'; + +export interface Resource { + uri: string; + name: string; + type: ResourceKind; + mime?: string; + size?: number; + parent?: string; +} + +export interface ResourceContent { + uri: string; + name?: string; + mime?: string; + size?: number; + /** Plain string or base64 for binary payloads. */ + content: string; +} + +export interface ResourceContentEnvelope { + data: ResourceContent; +} + +export interface Prompt { + name: string; + desc?: string; + args: Array>; + preview: Array>; +} + +// ============================== Audit ============================== // + +export interface AuditRow { + id: number | string; + tenant_id?: string | null; + actor?: string | null; + mcp_server_id: string; + mcp_server_name?: string; + tool_name: string; + duration_ms?: number; + status: string; + error_excerpt?: string | null; + created_at?: string | null; +} + +export interface AuditDetail extends AuditRow { + request?: Record; + response?: Record; + headers?: Record; + timeline?: Array>; + meta?: Record; +} + +export interface AuditListFilters { + server_id?: string; + tool_name?: string; + status?: string; + from?: string; + to?: string; + per_page?: number; +} + +// ============================== Resilience ============================== // + +export type BreakerStateName = 'closed' | 'open' | 'half_open'; + +export interface BreakerState { + key: string; + server_id?: string; + tool_name?: string; + state: BreakerStateName; + failures?: number; + opened_at?: number | string | null; +} + +// ============================== Events (SSE) ============================== // + +export interface AuditEvent { + id: number | string; + ts?: number | string; + server_id?: string; + tool?: string; + status?: number | string; + dur?: number; + actor?: string; +} + +// ============================== Wrapped responses ============================== // + +/** Generic `{ data: T[] }` envelope used by every list endpoint. */ +export interface DataEnvelope { + data: T; +} + +export type ListEnvelope = DataEnvelope; diff --git a/resources/js/lib/mutations/hooks.ts b/resources/js/lib/mutations/hooks.ts new file mode 100644 index 0000000..bd85646 --- /dev/null +++ b/resources/js/lib/mutations/hooks.ts @@ -0,0 +1,189 @@ +// TanStack Query MUTATION hooks — one per non-GET endpoint. Every mutation +// invalidates the related cache slice on success so subsequent reads observe +// the new state without manual refetch wiring in components. +// +// Confirm-token protocol (R21): destructive mutations (`useInvokeTool`, +// `useReplayAudit`, `useResetBreaker`) throw `ConfirmTokenError` on the +// first call when the server demands confirmation. The UI catches it, +// prompts the operator, then calls the mutation again with the minted +// token. We do NOT collapse this into a single hook with internal state — +// keeping it explicit makes the destructive UX legible at the call site. + +import { useMutation, useQueryClient, UseMutationResult } from '@tanstack/react-query'; +import * as api from '../api/endpoints'; +import { keys } from '../queries/keys'; +import type { + CreateApiKeyRequest, + HostApiKeyCreateEnvelope, + McpServer, + McpServerWrite, + ToolInvokeResult, + UpdatePreferencesRequest, +} from '../api/types'; + +// --------------------------------------------------------------------- // +// Identity +// --------------------------------------------------------------------- // + +export function useUpdatePreferences(): UseMutationResult { + const qc = useQueryClient(); + return useMutation({ + mutationFn: api.updatePreferences, + onSuccess: () => { + qc.invalidateQueries({ queryKey: keys.me.user() }); + }, + }); +} + +export function useCreateApiKey(): UseMutationResult< + HostApiKeyCreateEnvelope, + Error, + CreateApiKeyRequest +> { + const qc = useQueryClient(); + return useMutation({ + mutationFn: api.createApiKey, + onSuccess: () => { + qc.invalidateQueries({ queryKey: keys.apiKeys.all() }); + }, + }); +} + +export function useRevokeApiKey(): UseMutationResult { + const qc = useQueryClient(); + return useMutation({ + mutationFn: api.revokeApiKey, + onMutate: async (id) => { + // Optimistic — drop the key from the cache before the server confirms. + await qc.cancelQueries({ queryKey: keys.apiKeys.all() }); + const previous = qc.getQueryData(keys.apiKeys.all()); + qc.setQueryData(keys.apiKeys.all(), (old: unknown) => { + if (!Array.isArray(old)) return old; + return old.filter((k: { id: string }) => k.id !== id); + }); + return { previous }; + }, + onError: (_err, _id, context) => { + if (context?.previous) { + qc.setQueryData(keys.apiKeys.all(), context.previous); + } + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: keys.apiKeys.all() }); + }, + }); +} + +// --------------------------------------------------------------------- // +// Servers +// --------------------------------------------------------------------- // + +export function useCreateServer(): UseMutationResult { + const qc = useQueryClient(); + return useMutation({ + mutationFn: api.createServer, + onSuccess: () => { + qc.invalidateQueries({ queryKey: keys.servers.all() }); + }, + }); +} + +export interface UpdateServerVars { + id: string; + patch: Partial; +} + +export function useUpdateServer(): UseMutationResult { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, patch }: UpdateServerVars) => api.updateServer(id, patch), + onSuccess: (_data, vars) => { + qc.invalidateQueries({ queryKey: keys.servers.detail(vars.id) }); + qc.invalidateQueries({ queryKey: keys.servers.all() }); + }, + }); +} + +export function useDeleteServer(): UseMutationResult { + const qc = useQueryClient(); + return useMutation({ + mutationFn: api.deleteServer, + onSuccess: () => { + qc.invalidateQueries({ queryKey: keys.servers.all() }); + qc.invalidateQueries({ queryKey: keys.tools.all() }); + }, + }); +} + +export function useHandshake(): UseMutationResult { + const qc = useQueryClient(); + return useMutation({ + mutationFn: api.handshakeServer, + onSuccess: (_data, id) => { + qc.invalidateQueries({ queryKey: keys.servers.detail(id) }); + qc.invalidateQueries({ queryKey: keys.servers.tools(id) }); + }, + }); +} + +// --------------------------------------------------------------------- // +// Tool invocation — two-call confirm-token protocol (R21) +// --------------------------------------------------------------------- // + +export interface InvokeToolVars { + serverId: string; + toolName: string; + args?: Record; + confirmToken?: string; +} + +export function useInvokeTool(): UseMutationResult { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ serverId, toolName, args, confirmToken }: InvokeToolVars) => + api.invokeTool(serverId, toolName, args ?? {}, confirmToken), + onSuccess: () => { + // Successful invocation creates a new audit row — invalidate the list. + qc.invalidateQueries({ queryKey: keys.audit.all() }); + }, + }); +} + +// --------------------------------------------------------------------- // +// Audit replay +// --------------------------------------------------------------------- // + +export interface ReplayAuditVars { + id: string; + confirmToken?: string; +} + +export function useReplayAudit(): UseMutationResult { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, confirmToken }: ReplayAuditVars) => api.replayAudit(id, confirmToken), + onSuccess: () => { + qc.invalidateQueries({ queryKey: keys.audit.all() }); + }, + }); +} + +// --------------------------------------------------------------------- // +// Circuit-breaker reset +// --------------------------------------------------------------------- // + +export interface ResetBreakerVars { + key: string; + confirmToken?: string; +} + +export function useResetBreaker(): UseMutationResult { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ key, confirmToken }: ResetBreakerVars) => + api.resetBreaker(key, confirmToken), + onSuccess: () => { + qc.invalidateQueries({ queryKey: keys.breakers.all() }); + }, + }); +} diff --git a/resources/js/lib/queries/hooks.ts b/resources/js/lib/queries/hooks.ts new file mode 100644 index 0000000..d912a94 --- /dev/null +++ b/resources/js/lib/queries/hooks.ts @@ -0,0 +1,163 @@ +// TanStack Query READ hooks — one per GET endpoint. Mutations live in +// `lib/mutations/hooks.ts`. Every hook delegates to a typed endpoint helper +// in `lib/api/endpoints.ts`; keys come from `lib/queries/keys.ts` so the +// invalidation surface in mutations stays grep-able. + +import { useQuery, UseQueryResult } from '@tanstack/react-query'; +import * as api from '../api/endpoints'; +import { keys } from './keys'; +import type { + AuditDetail, + AuditListFilters, + AuditRow, + BreakerState, + HostApiKey, + HostTenant, + HostUserEnvelope, + McpServer, + McpServerPage, + Prompt, + Resource, + ResourceContent, + ServerListFilters, + Tool, +} from '../api/types'; + +// --------------------------------------------------------------------- // +// Identity +// --------------------------------------------------------------------- // + +export function useMe(): UseQueryResult { + return useQuery({ + queryKey: keys.me.user(), + queryFn: api.fetchMe, + }); +} + +export function useTenants(): UseQueryResult { + return useQuery({ + queryKey: keys.tenants.all(), + queryFn: api.listTenants, + }); +} + +export function useApiKeys(): UseQueryResult { + return useQuery({ + queryKey: keys.apiKeys.all(), + queryFn: api.listApiKeys, + }); +} + +// --------------------------------------------------------------------- // +// Servers +// --------------------------------------------------------------------- // + +export function useServers(filters?: ServerListFilters): UseQueryResult { + return useQuery({ + queryKey: keys.servers.list(filters), + queryFn: () => api.listServers(filters ?? {}), + }); +} + +export function useServer(id: string | undefined | null): UseQueryResult { + return useQuery({ + queryKey: keys.servers.detail(id ?? ''), + queryFn: () => api.getServer(id as string), + enabled: Boolean(id), + }); +} + +export function useServerTools(id: string | undefined | null): UseQueryResult { + return useQuery({ + queryKey: keys.servers.tools(id ?? ''), + queryFn: () => api.listServerTools(id as string), + enabled: Boolean(id), + }); +} + +// --------------------------------------------------------------------- // +// Tools (flat aggregator) +// --------------------------------------------------------------------- // + +export function useTools(): UseQueryResult { + return useQuery({ + queryKey: keys.tools.all(), + queryFn: api.listTools, + }); +} + +// --------------------------------------------------------------------- // +// Resources +// --------------------------------------------------------------------- // + +export function useResources(serverId: string | undefined | null): UseQueryResult { + return useQuery({ + queryKey: keys.servers.resources(serverId ?? ''), + queryFn: () => api.listResources(serverId as string), + enabled: Boolean(serverId), + }); +} + +export function useResource( + serverId: string | undefined | null, + uri: string | undefined | null, +): UseQueryResult { + return useQuery({ + queryKey: keys.servers.resourceContent(serverId ?? '', uri ?? ''), + queryFn: () => api.getResource(serverId as string, uri as string), + enabled: Boolean(serverId && uri), + }); +} + +// --------------------------------------------------------------------- // +// Prompts +// --------------------------------------------------------------------- // + +export function usePrompts(serverId: string | undefined | null): UseQueryResult { + return useQuery({ + queryKey: keys.servers.prompts(serverId ?? ''), + queryFn: () => api.listPrompts(serverId as string), + enabled: Boolean(serverId), + }); +} + +export function usePrompt( + serverId: string | undefined | null, + name: string | undefined | null, +): UseQueryResult { + return useQuery({ + queryKey: keys.servers.prompt(serverId ?? '', name ?? ''), + queryFn: () => api.getPrompt(serverId as string, name as string), + enabled: Boolean(serverId && name), + }); +} + +// --------------------------------------------------------------------- // +// Audit +// --------------------------------------------------------------------- // + +export function useAudit(filters?: AuditListFilters): UseQueryResult { + return useQuery({ + queryKey: keys.audit.list(filters), + queryFn: () => api.listAudit(filters ?? {}), + }); +} + +export function useAuditDetail(id: string | undefined | null): UseQueryResult { + return useQuery({ + queryKey: keys.audit.detail(id ?? ''), + queryFn: () => api.getAudit(id as string), + enabled: Boolean(id), + }); +} + +// --------------------------------------------------------------------- // +// Resilience +// --------------------------------------------------------------------- // + +export function useBreakers(): UseQueryResult { + return useQuery({ + queryKey: keys.breakers.all(), + queryFn: api.listBreakers, + }); +} diff --git a/resources/js/lib/queries/keys.ts b/resources/js/lib/queries/keys.ts new file mode 100644 index 0000000..55ef2a2 --- /dev/null +++ b/resources/js/lib/queries/keys.ts @@ -0,0 +1,42 @@ +// Centralised query-key factory. Every hook + every mutation invalidation +// goes through this surface so we have ONE place to refactor when the +// cache topology shifts. The key shape is `['', ...args]` — flat +// tuples are the TanStack recommendation for partial invalidation +// (`invalidateQueries({ queryKey: keys.servers.all() })` matches every +// `['servers', ...]` entry). + +import type { AuditListFilters, ServerListFilters } from '../api/types'; + +export const keys = { + me: { + root: ['me'] as const, + user: () => ['me', 'user'] as const, + }, + tenants: { + all: () => ['tenants'] as const, + }, + apiKeys: { + all: () => ['api-keys'] as const, + }, + servers: { + all: () => ['servers'] as const, + list: (filters: ServerListFilters | undefined) => ['servers', 'list', filters ?? {}] as const, + detail: (id: string) => ['servers', 'detail', id] as const, + tools: (id: string) => ['servers', id, 'tools'] as const, + resources: (id: string) => ['servers', id, 'resources'] as const, + resourceContent: (id: string, uri: string) => ['servers', id, 'resources', uri] as const, + prompts: (id: string) => ['servers', id, 'prompts'] as const, + prompt: (id: string, name: string) => ['servers', id, 'prompts', name] as const, + }, + tools: { + all: () => ['tools'] as const, + }, + audit: { + all: () => ['audit'] as const, + list: (filters: AuditListFilters | undefined) => ['audit', 'list', filters ?? {}] as const, + detail: (id: string) => ['audit', 'detail', id] as const, + }, + breakers: { + all: () => ['breakers'] as const, + }, +} as const; diff --git a/resources/js/lib/queries/queryClient.ts b/resources/js/lib/queries/queryClient.ts new file mode 100644 index 0000000..db16cbe --- /dev/null +++ b/resources/js/lib/queries/queryClient.ts @@ -0,0 +1,37 @@ +// Shared TanStack Query client. Defaults tuned for an admin SPA: +// - `staleTime: 30s` — admin data isn't real-time-critical; tabbing back +// into the window shouldn't blast the API. +// - `refetchOnWindowFocus: false` — same reason. +// - `retry: 1` for queries (network blips) but `retry: 0` for mutations +// (destructive ops should never auto-replay). +// +// Tests build their own client via `queries/testWrapper.tsx` with retries +// disabled and `gcTime: 0` so cache state doesn't leak between specs. + +import { QueryClient } from '@tanstack/react-query'; +import { AuthExpiredError, FeatureDisabledError } from '../api/errors'; + +export function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + gcTime: 5 * 60_000, + refetchOnWindowFocus: false, + retry: (failureCount, error) => { + // Don't retry auth / feature-disabled — they won't fix themselves. + if (error instanceof AuthExpiredError) return false; + if (error instanceof FeatureDisabledError) return false; + return failureCount < 1; + }, + }, + mutations: { + retry: 0, + }, + }, + }); +} + +// Module-singleton query client used by `main.tsx`. Component tests build +// their own client via the test wrapper. +export const queryClient = createQueryClient(); diff --git a/resources/js/lib/ui.tsx b/resources/js/lib/ui.tsx index dc611dd..1b13902 100644 --- a/resources/js/lib/ui.tsx +++ b/resources/js/lib/ui.tsx @@ -216,7 +216,7 @@ function ToastProvider({ children }) { {children}
{toasts.map(t => ( -
+
{icons[t.kind || 'ok']}
{t.title} diff --git a/resources/js/main.tsx b/resources/js/main.tsx index b606ff1..c57b4b7 100644 --- a/resources/js/main.tsx +++ b/resources/js/main.tsx @@ -1,7 +1,10 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import App from './App'; +import { queryClient } from './lib/queries/queryClient'; import '../css/panel.css'; // The server-side Blade injects this global with the mount prefix + API base. @@ -23,9 +26,12 @@ const container = document.getElementById('mcp-pack-admin-root'); if (container) { ReactDOM.createRoot(container).render( - - - + + + + + {import.meta.env.DEV ? : null} + , ); } diff --git a/tests/js/lib/api/client.test.ts b/tests/js/lib/api/client.test.ts new file mode 100644 index 0000000..24c38f2 --- /dev/null +++ b/tests/js/lib/api/client.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { server } from './server'; +import { + apiBase, + createApiClient, + getApiClient, + setApiClient, +} from '../../../../resources/js/lib/api/client'; +import { + ApiError, + AuthExpiredError, + ConfirmTokenError, + FeatureDisabledError, + NetworkError, + ValidationError, +} from '../../../../resources/js/lib/api/errors'; + +const BASE = 'http://127.0.0.1/api/admin/mcp-pack'; + +beforeEach(() => { + // Reset the singleton client between tests so interceptor mutations don't + // leak across files. Re-create against the canonical test base URL. + setApiClient(createApiClient(BASE)); + document.cookie = ''; +}); + +describe('apiBase()', () => { + it('resolves from window.__MCP_PACK_ADMIN__.api_base', () => { + expect(apiBase()).toBe(BASE); + }); + + it('strips trailing slashes', () => { + (window as any).__MCP_PACK_ADMIN__ = { api_base: 'http://example.test/api/' }; + expect(apiBase()).toBe('http://example.test/api'); + (window as any).__MCP_PACK_ADMIN__ = { api_base: BASE }; + }); +}); + +describe('request interceptor — X-XSRF-TOKEN', () => { + it('echoes XSRF-TOKEN cookie back as X-XSRF-TOKEN on POST', async () => { + document.cookie = 'XSRF-TOKEN=test-csrf-value'; + let received: string | null = null; + server.use( + http.post(`${BASE}/api-keys`, ({ request }) => { + received = request.headers.get('X-XSRF-TOKEN'); + return HttpResponse.json({ data: { id: 'k1', name: 'n', scopes: [], created_at: 0 } }, { status: 201 }); + }), + ); + + await getApiClient().post('/api-keys', { name: 'n', scopes: ['x'] }); + expect(received).toBe('test-csrf-value'); + }); + + it('does NOT set X-XSRF-TOKEN on GET', async () => { + document.cookie = 'XSRF-TOKEN=should-not-echo'; + let received: string | null | undefined = undefined; + server.use( + http.get(`${BASE}/me`, ({ request }) => { + received = request.headers.get('X-XSRF-TOKEN'); + return HttpResponse.json({ data: { id: 1 } }); + }), + ); + + await getApiClient().get('/me'); + expect(received).toBeNull(); + }); + + it('URL-decodes the XSRF-TOKEN cookie before echoing', async () => { + document.cookie = `XSRF-TOKEN=${encodeURIComponent('value+with/slash==')}`; + let received: string | null = null; + server.use( + http.delete(`${BASE}/api-keys/abc`, ({ request }) => { + received = request.headers.get('X-XSRF-TOKEN'); + return HttpResponse.json({}, { status: 200 }); + }), + ); + + await getApiClient().delete('/api-keys/abc'); + expect(received).toBe('value+with/slash=='); + }); +}); + +describe('response interceptor — error mapping', () => { + it('401 → AuthExpiredError + fires auth:expired CustomEvent', async () => { + server.use( + http.get(`${BASE}/me`, () => + HttpResponse.json({ error: { code: 'unauthenticated', message: 'session expired' } }, { status: 401 }), + ), + ); + + const listener = vi.fn(); + document.addEventListener('auth:expired', listener); + + await expect(getApiClient().get('/me')).rejects.toBeInstanceOf(AuthExpiredError); + expect(listener).toHaveBeenCalledTimes(1); + + document.removeEventListener('auth:expired', listener); + }); + + it('403 with code=feature_disabled → FeatureDisabledError', async () => { + server.use( + http.get(`${BASE}/servers`, () => + HttpResponse.json( + { error: { code: 'feature_disabled', message: 'admin SPA disabled' } }, + { status: 403 }, + ), + ), + ); + await expect(getApiClient().get('/servers')).rejects.toBeInstanceOf(FeatureDisabledError); + }); + + it('403 with a generic code → plain ApiError (not FeatureDisabledError)', async () => { + server.use( + http.get(`${BASE}/servers`, () => + HttpResponse.json({ error: { code: 'forbidden', message: 'nope' } }, { status: 403 }), + ), + ); + + const err = await getApiClient() + .get('/servers') + .catch((e) => e); + expect(err).toBeInstanceOf(ApiError); + expect(err).not.toBeInstanceOf(FeatureDisabledError); + expect(err.status).toBe(403); + }); + + it('422 with code=confirmation_invalid → ConfirmTokenError', async () => { + server.use( + http.post(`${BASE}/audit/aud_1/replay`, () => + HttpResponse.json( + { error: { code: 'confirmation_invalid', message: 'bad token' } }, + { status: 422 }, + ), + ), + ); + + const err = await getApiClient() + .post('/audit/aud_1/replay', { confirm_token: 'bad' }) + .catch((e) => e); + expect(err).toBeInstanceOf(ConfirmTokenError); + expect((err as ConfirmTokenError).reason).toBe('invalid'); + }); + + it('422 with Laravel validation envelope → ValidationError', async () => { + server.use( + http.post(`${BASE}/api-keys`, () => + HttpResponse.json( + { message: 'The given data was invalid.', errors: { name: ['required'] } }, + { status: 422 }, + ), + ), + ); + + const err = await getApiClient() + .post('/api-keys', {}) + .catch((e) => e); + expect(err).toBeInstanceOf(ValidationError); + expect((err as ValidationError).fieldErrors).toEqual({ name: ['required'] }); + }); + + it('network error (no response) → NetworkError', async () => { + server.use( + http.get(`${BASE}/me`, () => HttpResponse.error()), + ); + + const err = await getApiClient() + .get('/me') + .catch((e) => e); + expect(err).toBeInstanceOf(NetworkError); + expect(err.status).toBe(0); + }); + + it('generic 5xx → ApiError with carried status', async () => { + server.use( + http.get(`${BASE}/me`, () => + HttpResponse.json({ error: { code: 'server_error', message: 'boom' } }, { status: 503 }), + ), + ); + + const err = await getApiClient() + .get('/me') + .catch((e) => e); + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).status).toBe(503); + expect((err as ApiError).code).toBe('server_error'); + }); +}); diff --git a/tests/js/lib/api/endpoints.test.ts b/tests/js/lib/api/endpoints.test.ts new file mode 100644 index 0000000..a557251 --- /dev/null +++ b/tests/js/lib/api/endpoints.test.ts @@ -0,0 +1,343 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { server } from './server'; +import { createApiClient, setApiClient } from '../../../../resources/js/lib/api/client'; +import * as api from '../../../../resources/js/lib/api/endpoints'; +import { ConfirmTokenError } from '../../../../resources/js/lib/api/errors'; + +const BASE = 'http://127.0.0.1/api/admin/mcp-pack'; + +beforeEach(() => { + setApiClient(createApiClient(BASE)); +}); + +describe('Identity endpoints', () => { + it('fetchMe → GET /me', async () => { + server.use( + http.get(`${BASE}/me`, () => + HttpResponse.json({ data: { id: 1, email: 'a@b.c' }, meta: { tenant_id: 't1' } }), + ), + ); + const res = await api.fetchMe(); + expect(res.data.email).toBe('a@b.c'); + expect(res.meta?.tenant_id).toBe('t1'); + }); + + it('updatePreferences → POST /me/preferences with body', async () => { + let body: unknown = null; + server.use( + http.post(`${BASE}/me/preferences`, async ({ request }) => { + body = await request.json(); + return HttpResponse.json({}, { status: 200 }); + }), + ); + await api.updatePreferences({ preferences: { theme: 'dark' } }); + expect(body).toEqual({ preferences: { theme: 'dark' } }); + }); + + it('listTenants → GET /tenants', async () => { + server.use( + http.get(`${BASE}/tenants`, () => + HttpResponse.json({ data: [{ id: 't1', name: 'Tenant 1' }] }), + ), + ); + const tenants = await api.listTenants(); + expect(tenants).toHaveLength(1); + expect(tenants[0].id).toBe('t1'); + }); + + it('createApiKey → POST /api-keys', async () => { + server.use( + http.post(`${BASE}/api-keys`, async ({ request }) => { + const body = (await request.json()) as { name: string; scopes: string[] }; + return HttpResponse.json( + { + data: { + id: 'k1', + name: body.name, + scopes: body.scopes, + created_at: 0, + plaintext: 'pk_test_xxx', + }, + }, + { status: 201 }, + ); + }), + ); + const env = await api.createApiKey({ name: 'CI key', scopes: ['mcp.invoke'] }); + expect(env.data.plaintext).toBe('pk_test_xxx'); + expect(env.data.name).toBe('CI key'); + }); + + it('revokeApiKey → DELETE /api-keys/{id} with encoded segment', async () => { + let url: string | null = null; + server.use( + http.delete(`${BASE}/api-keys/:id`, ({ request }) => { + url = new URL(request.url).pathname; + return HttpResponse.json({}, { status: 200 }); + }), + ); + await api.revokeApiKey('key with spaces/and-slash'); + expect(url).toContain(encodeURIComponent('key with spaces/and-slash')); + }); +}); + +describe('Servers endpoints', () => { + it('listServers → GET /servers with query params', async () => { + let params: URLSearchParams | null = null; + server.use( + http.get(`${BASE}/servers`, ({ request }) => { + params = new URL(request.url).searchParams; + return HttpResponse.json({ data: [], meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 } }); + }), + ); + + await api.listServers({ q: 'gpt', status: 'ok', page: 2, per_page: 50 }); + expect(params!.get('q')).toBe('gpt'); + expect(params!.get('status')).toBe('ok'); + expect(params!.get('page')).toBe('2'); + expect(params!.get('per_page')).toBe('50'); + }); + + it('getServer → GET /servers/{id}', async () => { + server.use( + http.get(`${BASE}/servers/srv_01`, () => + HttpResponse.json({ data: { id: 'srv_01', name: 'openai', transport: 'http' } }), + ), + ); + const s = await api.getServer('srv_01'); + expect(s.id).toBe('srv_01'); + }); + + it('createServer → POST /servers', async () => { + server.use( + http.post(`${BASE}/servers`, async ({ request }) => { + const body = (await request.json()) as { name: string; transport: string }; + return HttpResponse.json( + { data: { id: 'srv_99', name: body.name, transport: body.transport } }, + { status: 201 }, + ); + }), + ); + const s = await api.createServer({ name: 'new', transport: 'http' }); + expect(s.id).toBe('srv_99'); + }); + + it('updateServer → PATCH /servers/{id} carries the patch body', async () => { + let body: unknown = null; + server.use( + http.patch(`${BASE}/servers/srv_01`, async ({ request }) => { + body = await request.json(); + return HttpResponse.json({}, { status: 200 }); + }), + ); + await api.updateServer('srv_01', { enabled: false }); + expect(body).toEqual({ enabled: false }); + }); + + it('deleteServer → DELETE /servers/{id}', async () => { + let called = false; + server.use( + http.delete(`${BASE}/servers/srv_01`, () => { + called = true; + return HttpResponse.json({}, { status: 200 }); + }), + ); + await api.deleteServer('srv_01'); + expect(called).toBe(true); + }); + + it('handshakeServer → POST /servers/{id}/handshake', async () => { + let called = false; + server.use( + http.post(`${BASE}/servers/srv_01/handshake`, () => { + called = true; + return HttpResponse.json({}, { status: 200 }); + }), + ); + await api.handshakeServer('srv_01'); + expect(called).toBe(true); + }); + + it('listServerTools → GET /servers/{id}/tools', async () => { + server.use( + http.get(`${BASE}/servers/srv_01/tools`, () => + HttpResponse.json({ data: [{ server_id: 'srv_01', name: 'chat' }] }), + ), + ); + const tools = await api.listServerTools('srv_01'); + expect(tools[0].name).toBe('chat'); + }); +}); + +describe('Tools endpoints', () => { + it('listTools → GET /tools (flat)', async () => { + server.use( + http.get(`${BASE}/tools`, () => + HttpResponse.json({ data: [{ server_id: 'a', name: 'x' }, { server_id: 'b', name: 'y' }] }), + ), + ); + expect(await api.listTools()).toHaveLength(2); + }); + + it('invokeTool — 200 returns body', async () => { + server.use( + http.post(`${BASE}/servers/srv_01/tools/chat/invoke`, () => + HttpResponse.json({ result: 'ok' }), + ), + ); + const res = await api.invokeTool('srv_01', 'chat', { msg: 'hi' }); + expect(res).toEqual({ result: 'ok' }); + }); + + it('invokeTool — 202 throws ConfirmTokenError carrying the minted token', async () => { + server.use( + http.post(`${BASE}/servers/srv_01/tools/delete-all/invoke`, () => + HttpResponse.json({ confirm_token: 'xt_abc', expires_in: 60 }, { status: 202 }), + ), + ); + const err = await api.invokeTool('srv_01', 'delete-all').catch((e) => e); + expect(err).toBeInstanceOf(ConfirmTokenError); + expect((err as ConfirmTokenError).confirmTokenMint?.confirm_token).toBe('xt_abc'); + }); + + it('invokeTool — second call with token returns 200', async () => { + let receivedBody: unknown = null; + server.use( + http.post(`${BASE}/servers/srv_01/tools/delete-all/invoke`, async ({ request }) => { + receivedBody = await request.json(); + return HttpResponse.json({ result: 'deleted' }); + }), + ); + const res = await api.invokeTool('srv_01', 'delete-all', { force: true }, 'xt_abc'); + expect(receivedBody).toEqual({ force: true, confirm_token: 'xt_abc' }); + expect(res).toEqual({ result: 'deleted' }); + }); +}); + +describe('Resources + Prompts', () => { + it('listResources → GET /servers/{id}/resources', async () => { + server.use( + http.get(`${BASE}/servers/srv_01/resources`, () => + HttpResponse.json({ + data: [{ uri: 'mcp://a', name: 'a', type: 'file' }], + }), + ), + ); + const r = await api.listResources('srv_01'); + expect(r[0].uri).toBe('mcp://a'); + }); + + it('getResource — URI is URL-encoded into the path', async () => { + let path: string | null = null; + server.use( + http.get(`${BASE}/servers/srv_01/resources/:uri`, ({ request }) => { + path = new URL(request.url).pathname; + return HttpResponse.json({ data: { uri: 'mcp://docs/readme.md', name: 'readme', content: 'hello' } }); + }), + ); + await api.getResource('srv_01', 'mcp://docs/readme.md'); + expect(path).toContain(encodeURIComponent('mcp://docs/readme.md')); + }); + + it('listPrompts → GET /servers/{id}/prompts', async () => { + server.use( + http.get(`${BASE}/servers/srv_01/prompts`, () => + HttpResponse.json({ data: [{ name: 'greet', args: [], preview: [] }] }), + ), + ); + const p = await api.listPrompts('srv_01'); + expect(p[0].name).toBe('greet'); + }); + + it('getPrompt → GET /servers/{id}/prompts/{name}', async () => { + server.use( + http.get(`${BASE}/servers/srv_01/prompts/greet`, () => + HttpResponse.json({ data: { name: 'greet', args: [], preview: [] } }), + ), + ); + const p = await api.getPrompt('srv_01', 'greet'); + expect(p.name).toBe('greet'); + }); +}); + +describe('Audit + Resilience', () => { + it('listAudit → GET /audit with filters', async () => { + let qs: URLSearchParams | null = null; + server.use( + http.get(`${BASE}/audit`, ({ request }) => { + qs = new URL(request.url).searchParams; + return HttpResponse.json({ data: [] }); + }), + ); + await api.listAudit({ server_id: 'srv_01', status: 'err' }); + expect(qs!.get('server_id')).toBe('srv_01'); + expect(qs!.get('status')).toBe('err'); + }); + + it('getAudit → GET /audit/{id}', async () => { + server.use( + http.get(`${BASE}/audit/aud_1`, () => + HttpResponse.json({ data: { id: 'aud_1', mcp_server_id: 's', tool_name: 't', status: 'ok' } }), + ), + ); + const d = await api.getAudit('aud_1'); + expect(d.id).toBe('aud_1'); + }); + + it('replayAudit — 202 throws ConfirmTokenError', async () => { + server.use( + http.post(`${BASE}/audit/aud_1/replay`, () => + HttpResponse.json({ confirm_token: 'rt_1' }, { status: 202 }), + ), + ); + const err = await api.replayAudit('aud_1').catch((e) => e); + expect(err).toBeInstanceOf(ConfirmTokenError); + }); + + it('replayAudit — second call with token resolves', async () => { + let body: unknown = null; + server.use( + http.post(`${BASE}/audit/aud_1/replay`, async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ ok: true }); + }), + ); + const res = await api.replayAudit('aud_1', 'rt_1'); + expect(body).toEqual({ confirm_token: 'rt_1' }); + expect(res).toEqual({ ok: true }); + }); + + it('listBreakers → GET /circuit-breaker', async () => { + server.use( + http.get(`${BASE}/circuit-breaker`, () => + HttpResponse.json({ data: [{ key: 'srv_01/x', state: 'closed' }] }), + ), + ); + const b = await api.listBreakers(); + expect(b[0].key).toBe('srv_01/x'); + }); + + it('resetBreaker — 202 throws ConfirmTokenError', async () => { + server.use( + http.post(`${BASE}/circuit-breaker/srv_01:tool_x/reset`, () => + HttpResponse.json({ confirm_token: 'br_1', expires_in: 30 }, { status: 202 }), + ), + ); + const err = await api.resetBreaker('srv_01:tool_x').catch((e) => e); + expect(err).toBeInstanceOf(ConfirmTokenError); + expect((err as ConfirmTokenError).confirmTokenMint?.confirm_token).toBe('br_1'); + }); + + it('resetBreaker — second call with token resolves to void', async () => { + let body: unknown = null; + server.use( + http.post(`${BASE}/circuit-breaker/srv_01:tool_x/reset`, async ({ request }) => { + body = await request.json(); + return HttpResponse.json({}, { status: 200 }); + }), + ); + await api.resetBreaker('srv_01:tool_x', 'br_1'); + expect(body).toEqual({ confirm_token: 'br_1' }); + }); +}); diff --git a/tests/js/lib/api/server.ts b/tests/js/lib/api/server.ts new file mode 100644 index 0000000..f79f48a --- /dev/null +++ b/tests/js/lib/api/server.ts @@ -0,0 +1,7 @@ +// Shared MSW server for endpoint-level tests. Each test file imports `server` +// and calls `server.use(...)` to register per-test handlers; the global +// `beforeAll/afterEach/afterAll` lifecycle is wired in `tests/js/setup.ts`. + +import { setupServer } from 'msw/node'; + +export const server = setupServer(); diff --git a/tests/js/lib/mutations/hooks.test.tsx b/tests/js/lib/mutations/hooks.test.tsx new file mode 100644 index 0000000..5a8526f --- /dev/null +++ b/tests/js/lib/mutations/hooks.test.tsx @@ -0,0 +1,194 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { server } from '../api/server'; +import { createApiClient, setApiClient } from '../../../../resources/js/lib/api/client'; +import { ConfirmTokenError } from '../../../../resources/js/lib/api/errors'; +import { + useCreateApiKey, + useRevokeApiKey, + useCreateServer, + useInvokeTool, + useReplayAudit, + useResetBreaker, + useUpdateServer, + useHandshake, +} from '../../../../resources/js/lib/mutations/hooks'; +import { withQueryClient } from '../queries/wrapper'; + +const BASE = 'http://127.0.0.1/api/admin/mcp-pack'; + +beforeEach(() => { + setApiClient(createApiClient(BASE)); +}); + +describe('Identity mutations', () => { + it('useCreateApiKey — returns the plaintext envelope', async () => { + server.use( + http.post(`${BASE}/api-keys`, () => + HttpResponse.json( + { data: { id: 'k1', name: 'n', scopes: ['s'], created_at: 0, plaintext: 'pk_x' } }, + { status: 201 }, + ), + ), + ); + + const { result } = renderHook(() => useCreateApiKey(), { wrapper: withQueryClient() }); + let envelope; + await act(async () => { + envelope = await result.current.mutateAsync({ name: 'n', scopes: ['s'] }); + }); + expect((envelope as any).data.plaintext).toBe('pk_x'); + }); + + it('useRevokeApiKey — fires DELETE', async () => { + let called = false; + server.use( + http.delete(`${BASE}/api-keys/k1`, () => { + called = true; + return HttpResponse.json({}, { status: 200 }); + }), + ); + + const { result } = renderHook(() => useRevokeApiKey(), { wrapper: withQueryClient() }); + await act(async () => { + await result.current.mutateAsync('k1'); + }); + expect(called).toBe(true); + }); +}); + +describe('Server mutations', () => { + it('useCreateServer — POST /servers', async () => { + server.use( + http.post(`${BASE}/servers`, () => + HttpResponse.json({ data: { id: 'srv_x', name: 'new', transport: 'http' } }, { status: 201 }), + ), + ); + const { result } = renderHook(() => useCreateServer(), { wrapper: withQueryClient() }); + let created; + await act(async () => { + created = await result.current.mutateAsync({ name: 'new', transport: 'http' }); + }); + expect((created as any).id).toBe('srv_x'); + }); + + it('useUpdateServer — PATCH /servers/{id}', async () => { + let body: unknown = null; + server.use( + http.patch(`${BASE}/servers/srv_x`, async ({ request }) => { + body = await request.json(); + return HttpResponse.json({}, { status: 200 }); + }), + ); + const { result } = renderHook(() => useUpdateServer(), { wrapper: withQueryClient() }); + await act(async () => { + await result.current.mutateAsync({ id: 'srv_x', patch: { enabled: true } }); + }); + expect(body).toEqual({ enabled: true }); + }); + + it('useHandshake — POST /servers/{id}/handshake', async () => { + let called = false; + server.use( + http.post(`${BASE}/servers/srv_x/handshake`, () => { + called = true; + return HttpResponse.json({}, { status: 200 }); + }), + ); + const { result } = renderHook(() => useHandshake(), { wrapper: withQueryClient() }); + await act(async () => { + await result.current.mutateAsync('srv_x'); + }); + expect(called).toBe(true); + }); +}); + +describe('Destructive mutations — confirm-token protocol', () => { + it('useInvokeTool — first call surfaces a ConfirmTokenError carrying the minted token', async () => { + server.use( + http.post(`${BASE}/servers/srv_x/tools/delete-all/invoke`, () => + HttpResponse.json({ confirm_token: 'xt_abc', expires_in: 60 }, { status: 202 }), + ), + ); + + const { result } = renderHook(() => useInvokeTool(), { wrapper: withQueryClient() }); + let caught: unknown = null; + await act(async () => { + try { + await result.current.mutateAsync({ serverId: 'srv_x', toolName: 'delete-all' }); + } catch (e) { + caught = e; + } + }); + expect(caught).toBeInstanceOf(ConfirmTokenError); + expect((caught as ConfirmTokenError).confirmTokenMint?.confirm_token).toBe('xt_abc'); + }); + + it('useInvokeTool — second call with token returns the result', async () => { + server.use( + http.post(`${BASE}/servers/srv_x/tools/delete-all/invoke`, () => + HttpResponse.json({ result: 'deleted' }), + ), + ); + const { result } = renderHook(() => useInvokeTool(), { wrapper: withQueryClient() }); + let res; + await act(async () => { + res = await result.current.mutateAsync({ + serverId: 'srv_x', + toolName: 'delete-all', + confirmToken: 'xt_abc', + }); + }); + expect(res).toEqual({ result: 'deleted' }); + }); + + it('useReplayAudit — first call throws ConfirmTokenError', async () => { + server.use( + http.post(`${BASE}/audit/aud_1/replay`, () => + HttpResponse.json({ confirm_token: 'rt_1' }, { status: 202 }), + ), + ); + const { result } = renderHook(() => useReplayAudit(), { wrapper: withQueryClient() }); + let caught: unknown = null; + await act(async () => { + try { + await result.current.mutateAsync({ id: 'aud_1' }); + } catch (e) { + caught = e; + } + }); + expect(caught).toBeInstanceOf(ConfirmTokenError); + }); + + it('useResetBreaker — first call throws ConfirmTokenError, second resolves', async () => { + let secondCallSeen = false; + server.use( + http.post(`${BASE}/circuit-breaker/srv_x%3Atool/reset`, async ({ request }) => { + const body = (await request.json().catch(() => null)) as { confirm_token?: string } | null; + if (body?.confirm_token === 'br_1') { + secondCallSeen = true; + return HttpResponse.json({}, { status: 200 }); + } + return HttpResponse.json({ confirm_token: 'br_1' }, { status: 202 }); + }), + ); + + const { result } = renderHook(() => useResetBreaker(), { wrapper: withQueryClient() }); + + let firstErr: unknown = null; + await act(async () => { + try { + await result.current.mutateAsync({ key: 'srv_x:tool' }); + } catch (e) { + firstErr = e; + } + }); + expect(firstErr).toBeInstanceOf(ConfirmTokenError); + + await act(async () => { + await result.current.mutateAsync({ key: 'srv_x:tool', confirmToken: 'br_1' }); + }); + expect(secondCallSeen).toBe(true); + }); +}); diff --git a/tests/js/lib/queries/hooks.test.tsx b/tests/js/lib/queries/hooks.test.tsx new file mode 100644 index 0000000..eb9b55b --- /dev/null +++ b/tests/js/lib/queries/hooks.test.tsx @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { server } from '../api/server'; +import { createApiClient, setApiClient } from '../../../../resources/js/lib/api/client'; +import { + useMe, + useTenants, + useApiKeys, + useServers, + useServer, + useTools, + useAudit, + useBreakers, +} from '../../../../resources/js/lib/queries/hooks'; +import { withQueryClient } from './wrapper'; + +const BASE = 'http://127.0.0.1/api/admin/mcp-pack'; + +beforeEach(() => { + setApiClient(createApiClient(BASE)); +}); + +describe('Read hooks — happy path', () => { + it('useMe — loading → success', async () => { + server.use( + http.get(`${BASE}/me`, () => HttpResponse.json({ data: { id: 1, email: 'a@b' } })), + ); + const { result } = renderHook(() => useMe(), { wrapper: withQueryClient() }); + expect(result.current.isLoading).toBe(true); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.data.email).toBe('a@b'); + }); + + it('useTenants — returns the unwrapped array', async () => { + server.use( + http.get(`${BASE}/tenants`, () => + HttpResponse.json({ data: [{ id: 't1', name: 'T' }] }), + ), + ); + const { result } = renderHook(() => useTenants(), { wrapper: withQueryClient() }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([{ id: 't1', name: 'T' }]); + }); + + it('useApiKeys — returns the unwrapped array', async () => { + server.use( + http.get(`${BASE}/api-keys`, () => + HttpResponse.json({ data: [{ id: 'k1', name: 'n', scopes: [], created_at: 0 }] }), + ), + ); + const { result } = renderHook(() => useApiKeys(), { wrapper: withQueryClient() }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toHaveLength(1); + }); + + it('useServers — forwards filters as query params', async () => { + let qs: URLSearchParams | null = null; + server.use( + http.get(`${BASE}/servers`, ({ request }) => { + qs = new URL(request.url).searchParams; + return HttpResponse.json({ data: [], meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 } }); + }), + ); + const { result } = renderHook(() => useServers({ q: 'xx' }), { wrapper: withQueryClient() }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(qs!.get('q')).toBe('xx'); + }); + + it('useServer — disabled when id is empty', () => { + const { result } = renderHook(() => useServer(undefined), { wrapper: withQueryClient() }); + expect(result.current.fetchStatus).toBe('idle'); + }); + + it('useTools — flat list', async () => { + server.use( + http.get(`${BASE}/tools`, () => + HttpResponse.json({ data: [{ server_id: 'a', name: 'x' }] }), + ), + ); + const { result } = renderHook(() => useTools(), { wrapper: withQueryClient() }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toHaveLength(1); + }); + + it('useAudit — passes filters', async () => { + let qs: URLSearchParams | null = null; + server.use( + http.get(`${BASE}/audit`, ({ request }) => { + qs = new URL(request.url).searchParams; + return HttpResponse.json({ data: [] }); + }), + ); + const { result } = renderHook(() => useAudit({ status: 'err' }), { wrapper: withQueryClient() }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(qs!.get('status')).toBe('err'); + }); + + it('useBreakers — list', async () => { + server.use( + http.get(`${BASE}/circuit-breaker`, () => + HttpResponse.json({ data: [{ key: 'k1', state: 'closed' }] }), + ), + ); + const { result } = renderHook(() => useBreakers(), { wrapper: withQueryClient() }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.[0].key).toBe('k1'); + }); +}); + +describe('Read hooks — failure path', () => { + it('useMe — surfaces error when API returns 500', async () => { + server.use( + http.get(`${BASE}/me`, () => + HttpResponse.json({ error: { code: 'server_error', message: 'boom' } }, { status: 500 }), + ), + ); + const { result } = renderHook(() => useMe(), { wrapper: withQueryClient() }); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect((result.current.error as Error).message).toMatch(/boom|server/i); + }); +}); diff --git a/tests/js/lib/queries/wrapper.tsx b/tests/js/lib/queries/wrapper.tsx new file mode 100644 index 0000000..c80c8d6 --- /dev/null +++ b/tests/js/lib/queries/wrapper.tsx @@ -0,0 +1,22 @@ +// Shared `` factory for tests. Every test gets a fresh +// client so cache state never leaks between specs. `retry: false` + +// `gcTime: 0` keep failures fast and prevent stale cache from bleeding. + +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +export function createTestQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: 0 }, + mutations: { retry: false }, + }, + }); +} + +export function withQueryClient(client: QueryClient = createTestQueryClient()) { + // eslint-disable-next-line react/display-name + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +} diff --git a/tests/js/setup.ts b/tests/js/setup.ts index e180797..afb4e60 100644 --- a/tests/js/setup.ts +++ b/tests/js/setup.ts @@ -1,4 +1,5 @@ import '@testing-library/jest-dom/vitest'; +import { afterAll, afterEach, beforeAll } from 'vitest'; // jsdom's localStorage is on window, but React reads the bare `localStorage` // global; explicitly bind both so getItem/setItem work as expected. @@ -17,8 +18,26 @@ if (typeof globalThis.localStorage === 'undefined' || typeof (globalThis.localSt // Vitest global setup. Stub out the global config the SPA expects from Blade. (window as any).__MCP_PACK_ADMIN__ = { - api_base: '/api/admin/mcp-pack', + api_base: 'http://127.0.0.1/api/admin/mcp-pack', mount_prefix: '/admin/mcp-pack', theme_default: 'dark', asset_path: '/vendor/mcp-pack-admin', }; + +// MSW lifecycle — `server` is lazily imported so any test file that doesn't +// touch the API client doesn't pay the polyfill cost. msw/node installs an +// `undici`-backed `fetch` + intercepts the global axios HTTP adapter. +beforeAll(async () => { + const { server } = await import('./lib/api/server'); + server.listen({ onUnhandledRequest: 'error' }); +}); + +afterEach(async () => { + const { server } = await import('./lib/api/server'); + server.resetHandlers(); +}); + +afterAll(async () => { + const { server } = await import('./lib/api/server'); + server.close(); +}); diff --git a/vite.config.ts b/vite.config.ts index 179a4b6..cd317c4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,12 @@ import { resolve } from 'path'; export default defineConfig({ plugins: [react()], base: '/vendor/mcp-pack-admin/', + optimizeDeps: { + // Pre-bundle the data-layer foundation so vite dev-server boots without + // an on-demand cold-start when the first hook fires. axios + TanStack + // Query both ship CJS interop shims that hot-load slowly otherwise. + include: ['axios', '@tanstack/react-query', '@tanstack/react-query-devtools'], + }, // outDir lives inside Laravel's `public/` to make the built bundle // directly servable; publicDir must be disabled (default is `public/`) // because otherwise Vite tries to recursively copy `public/` into the From beb0b1f88ecb84dc31f8afba5ea1734ac0ed99f9 Mon Sep 17 00:00:00 2001 From: "lorenzo.padovani@padosoft.com" Date: Mon, 18 May 2026 04:52:15 +0200 Subject: [PATCH 2/3] fix(v1.1/W2): regenerate package-lock.json to fix `npm ci` CI failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's `npm ci` rejected the previous lockfile with: ``` npm error Missing: @emnapi/core@1.10.0 from lock file npm error Missing: @emnapi/runtime@1.10.0 from lock file ``` These are platform-specific optional transitive deps (from `node-fetch` ecosystem on Linux) that the local Windows `npm install` did not materialise into `package-lock.json`. Result: `package.json` ↔ `package-lock.json` were out-of-sync from CI's perspective. Fix: regenerated the lockfile via `rm -rf node_modules package-lock.json && npm install` so the platform-specific optional deps are properly recorded. Local verification after regen: - `npm test` — 64/64 green (unchanged) - `npm run typecheck` — clean - `npm run build` — 346.13 KB / 98.53 KB gzipped (unchanged) Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 109 ++++++++++++++++++++++++++++++---------------- 1 file changed, 71 insertions(+), 38 deletions(-) diff --git a/package-lock.json b/package-lock.json index f49dc5a..164327e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -206,6 +206,31 @@ "node": ">=18" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1300,13 +1325,15 @@ } }, "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "license": "MIT", + "dependencies": { + "debug": "4" + }, "engines": { - "node": ">= 14" + "node": ">= 6.0.0" } }, "node_modules/ajv": { @@ -1397,31 +1424,6 @@ "proxy-from-env": "^2.1.0" } }, - "node_modules/axios/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/axios/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2349,18 +2351,27 @@ "node": ">= 14" } }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", "dependencies": { - "agent-base": "^7.1.2", + "agent-base": "6", "debug": "4" }, "engines": { - "node": ">= 14" + "node": ">= 6" } }, "node_modules/iconv-lite": { @@ -2502,7 +2513,6 @@ "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.1.0", "data-urls": "^5.0.0", @@ -2538,6 +2548,30 @@ } } }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -2972,7 +3006,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", From ce7893498309dece6ab9db9bb55a7f77cae605d6 Mon Sep 17 00:00:00 2001 From: "lorenzo.padovani@padosoft.com" Date: Mon, 18 May 2026 05:01:25 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(v1.1/W2):=20address=20Copilot=20iter-1?= =?UTF-8?q?=20findings=20(7)=20=E2=80=94=20handshake=20cache=20invalidatio?= =?UTF-8?q?n=20+=20dedupe=20+=20DX=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Findings & fixes ### P2 — useHandshake() did not invalidate flat tools cache (Codex) After a successful handshake the per-server tools cache was invalidated but `keys.tools.all()` (the flat `useTools()` cache) was not. Operators visiting the Tools page after a handshake would see pre-handshake data for up to 30s `staleTime`. **Fix**: `useHandshake.onSuccess` now also invalidates `keys.tools.all()`. ### P2 — useEffect re-registered listeners on every render (Copilot) The effect depended on the whole `toast` object, which the provider returns fresh on every render. Result: every parent re-render re-registered all 6 document listeners (including `auth:expired`), risking listener-storm + duplicate fires during the cleanup race. **Fix**: destructure the stable `push` callback inside `Shell()` and depend on `[nav, push]` only. ### P2 — auth:expired toast was not actually deduplicated (Copilot) The comment claimed dedupe but every 401 pushed a fresh toast. A stale Sanctum cookie can fire dozens of 401s as TanStack Query retries queued requests in parallel. **Fix**: module-scoped `authExpiredToastShown` flag. First 401 shows the toast, subsequent ones are silently swallowed until the user reloads. ### P2 — queryClient.ts docblock pointed at a non-existent file (Copilot) Comment referenced `queries/testWrapper.tsx`; the actual test wrapper is at `tests/js/lib/queries/wrapper.tsx`. **Fix**: docblock path corrected. ### P2 — unused HostUser import in endpoints.ts (Copilot) **Fix**: removed. ### P2 — subscribeEvents() lacked test coverage (Copilot) The new EventSource wiring (with JSON parsing + fallback for environments where `EventSource === undefined`) had no test pinning the no-op contract. **Fix**: 2 new tests — verify (a) `subscribeEvents()` returns a function in jsdom (where EventSource is undefined); (b) the function doesn't throw on invocation. Pin the contract against future regressions that might throw instead of returning a no-op. ### P2 — document.cookie='' unreliable in jsdom (Copilot) `document.cookie = ''` is silently ignored by jsdom (it attempts to set a cookie with empty name + empty value). The previously-set XSRF-TOKEN cookie persisted into subsequent tests, making the suite order-dependent. **Fix**: `document.cookie = 'XSRF-TOKEN=; Max-Age=0; path=/'` — the explicit expiry actually clears the cookie. ## Test impact - Vitest: 64 → 66 tests (+2 new for subscribeEvents EventSource- missing path). - Typecheck: clean. - npm run build: unchanged bundle size (no source code growth on the bundle path). Co-Authored-By: Claude Opus 4.7 (1M context) --- resources/js/App.tsx | 38 +++++++++++++++++++++---- resources/js/lib/api/endpoints.ts | 1 - resources/js/lib/mutations/hooks.ts | 6 ++++ resources/js/lib/queries/queryClient.ts | 5 ++-- tests/js/lib/api/client.test.ts | 7 ++++- tests/js/lib/api/endpoints.test.ts | 36 +++++++++++++++++++++++ 6 files changed, 84 insertions(+), 9 deletions(-) diff --git a/resources/js/App.tsx b/resources/js/App.tsx index 6b3f4d9..defff0f 100644 --- a/resources/js/App.tsx +++ b/resources/js/App.tsx @@ -48,10 +48,23 @@ function NavBridge({ children }: { children: (nav: (p: string) => void) => any } return children(nav); } +// Iter-2 fix (W2 Copilot iter-1): module-scoped flag for +// `auth:expired` toast dedupe. A stale Sanctum cookie can fire +// dozens of 401s in seconds as TanStack Query retries queued +// requests; without this flag every retry would stack a fresh +// toast. The flag resets on page reload (the user's recovery path). +let authExpiredToastShown = false; + function Shell() { const navigate = useNavigate(); const location = useLocation(); const toast = useToast(); + // Destructure the stable `push` callback so the `useEffect` below + // can depend on it WITHOUT re-running on every Toast-provider + // re-render (the provider returns a fresh `{ push, dismiss }` + // object on every render — depending on `toast` made the effect + // re-register every document listener too aggressively). + const { push } = toast; const [theme, setTheme] = React.useState(() => { // Precedence: per-user override (localStorage) > host operator default @@ -159,11 +172,19 @@ function Shell() { }; const onNavEvt = (e: any) => nav(e.detail); const onOpenAudit = (e: any) => nav('audit/' + e.detail); - // The API client interceptor fires this event on every 401 so the SPA - // can surface a single, deduplicated session-expired toast (W2 wiring; - // actual sign-out / refresh flow lives further upstream). + // Iter-2 fix: actually deduplicate session-expired toasts. + // Previously this comment claimed dedupe but the body just + // pushed a new toast on every 401; a stale Sanctum cookie can + // fire dozens of 401s in seconds as TanStack Query retries + // queued requests, stacking dozens of identical toasts. The + // module-scoped flag ensures at most one toast per event burst — + // user reloads to recover, the next page-load resets the flag. const onAuthExpired = () => { - toast.push({ + if (authExpiredToastShown) { + return; + } + authExpiredToastShown = true; + push({ kind: 'err', title: 'Session expired', body: 'Please reload the page to sign in again.', @@ -184,7 +205,14 @@ function Shell() { document.removeEventListener('app:open-audit', onOpenAudit as any); document.removeEventListener('auth:expired', onAuthExpired as any); }; - }, [nav, toast]); + // Iter-2 fix: depend on `push` (the stable callback inside the + // `toast` object) NOT the `toast` object itself. The Toast + // provider returns a fresh `{ push, dismiss }` instance on + // every render — depending on `toast` would re-register every + // listener on every parent render, hurting perf + risking + // duplicate listener fires during the brief window before the + // cleanup runs. + }, [nav, push]); const route = routeKeyFromPath(location.pathname); const topRoute = diff --git a/resources/js/lib/api/endpoints.ts b/resources/js/lib/api/endpoints.ts index aba948a..3f73961 100644 --- a/resources/js/lib/api/endpoints.ts +++ b/resources/js/lib/api/endpoints.ts @@ -22,7 +22,6 @@ import type { HostApiKey, HostApiKeyCreateEnvelope, HostTenant, - HostUser, HostUserEnvelope, ListEnvelope, McpServer, diff --git a/resources/js/lib/mutations/hooks.ts b/resources/js/lib/mutations/hooks.ts index bd85646..7f2c906 100644 --- a/resources/js/lib/mutations/hooks.ts +++ b/resources/js/lib/mutations/hooks.ts @@ -122,6 +122,12 @@ export function useHandshake(): UseMutationResult { onSuccess: (_data, id) => { qc.invalidateQueries({ queryKey: keys.servers.detail(id) }); qc.invalidateQueries({ queryKey: keys.servers.tools(id) }); + // Iter-2 fix: a handshake refreshes the server's advertised + // tool list — the FLAT `useTools()` cache (keyed under + // `tools.all()`) MUST be invalidated too, else the global Tools + // page keeps rendering pre-handshake data for up to `staleTime` + // (30s). + qc.invalidateQueries({ queryKey: keys.tools.all() }); }, }); } diff --git a/resources/js/lib/queries/queryClient.ts b/resources/js/lib/queries/queryClient.ts index db16cbe..f8f2b59 100644 --- a/resources/js/lib/queries/queryClient.ts +++ b/resources/js/lib/queries/queryClient.ts @@ -5,8 +5,9 @@ // - `retry: 1` for queries (network blips) but `retry: 0` for mutations // (destructive ops should never auto-replay). // -// Tests build their own client via `queries/testWrapper.tsx` with retries -// disabled and `gcTime: 0` so cache state doesn't leak between specs. +// Tests build their own client via `tests/js/lib/queries/wrapper.tsx` +// with retries disabled and `gcTime: 0` so cache state doesn't leak +// between specs. import { QueryClient } from '@tanstack/react-query'; import { AuthExpiredError, FeatureDisabledError } from '../api/errors'; diff --git a/tests/js/lib/api/client.test.ts b/tests/js/lib/api/client.test.ts index 24c38f2..a63b444 100644 --- a/tests/js/lib/api/client.test.ts +++ b/tests/js/lib/api/client.test.ts @@ -22,7 +22,12 @@ beforeEach(() => { // Reset the singleton client between tests so interceptor mutations don't // leak across files. Re-create against the canonical test base URL. setApiClient(createApiClient(BASE)); - document.cookie = ''; + // Iter-2 fix: `document.cookie = ''` is silently ignored in jsdom + // (it tries to set a cookie with empty name + value); the + // previously-set `XSRF-TOKEN` cookie would persist into the next + // test, making order-dependent failures. Explicitly expire the + // cookies we set in this suite by writing `key=; Max-Age=0; path=/`. + document.cookie = 'XSRF-TOKEN=; Max-Age=0; path=/'; }); describe('apiBase()', () => { diff --git a/tests/js/lib/api/endpoints.test.ts b/tests/js/lib/api/endpoints.test.ts index a557251..8622f9d 100644 --- a/tests/js/lib/api/endpoints.test.ts +++ b/tests/js/lib/api/endpoints.test.ts @@ -341,3 +341,39 @@ describe('Audit + Resilience', () => { expect(body).toEqual({ confirm_token: 'br_1' }); }); }); + +describe('subscribeEvents — SSE wiring', () => { + // Iter-2 fix: jsdom lacks `EventSource`. The helper must + // (a) return a no-op cleanup function (NOT undefined / null) so + // callers can safely invoke it in their effect teardown; + // (b) NOT crash on its own when `EventSource === undefined`. + // Pin the contract — any future regression that throws when + // EventSource is missing would fail this test on the first run. + + it('returns a cleanup function when EventSource is unavailable', () => { + const original = (globalThis as any).EventSource; + delete (globalThis as any).EventSource; + try { + const cleanup = api.subscribeEvents(() => undefined); + expect(typeof cleanup).toBe('function'); + // The cleanup is a no-op but MUST be safe to call. + expect(() => cleanup()).not.toThrow(); + } finally { + if (original !== undefined) { + (globalThis as any).EventSource = original; + } + } + }); + + it('does not throw when invoked in a non-EventSource environment', () => { + const original = (globalThis as any).EventSource; + delete (globalThis as any).EventSource; + try { + expect(() => api.subscribeEvents(() => undefined)).not.toThrow(); + } finally { + if (original !== undefined) { + (globalThis as any).EventSource = original; + } + } + }); +});