A proxy that bridges Plane project management frontend with Ever Gauzy backend services. It intercepts requests from Plane UI, transforms them to match the Gauzy API contract, forwards them, and transforms the responses back to the format Plane UI expects.
Plane UI ──► Proxy APIs ──► Ever Gauzy API
(transform request)
(transform response)
This project is an independent, community-driven integration between Ever® Gauzy™ and Plane. It is not affiliated with, endorsed by, or sponsored by Plane or its maintainers in any way.
This proxy is provided "as is" for the sole purpose of enabling interoperability between Ever® Gauzy™ and Plane. Use of the Plane name and any references to its APIs are solely for the purpose of describing the integration target.
The proxy can run in two modes:
| Mode | Description | Use case |
|---|---|---|
| Standalone | Independent NestJS process on port 3300 | Development, isolated deployment |
| Integrated | Mounted in-process inside the Gauzy API | Production single-process deployment, multi-tenant |
| Package | Description |
|---|---|
@ever-gauzy/plugin-integration-plane-api |
Core proxy: NestJS modules, controllers, services, transformers, and mountPlaneProxy() |
@ever-gauzy/plugin-integration-plane-models |
Shared TypeScript interfaces and models (DTOs, API response types, entity models) |
ever-gauzy-plugins-plane/
├── apps/
│ └── api-plane/ # Thin standalone runner (imports bootstrap)
├── packages/
│ ├── plugin-plane/ # Core proxy (published as @ever-gauzy/plugin-integration-plane-api)
│ │ └── src/
│ │ ├── index.ts # Public API exports
│ │ ├── main.ts # bootstrap() for standalone mode
│ │ ├── mount.ts # mountPlaneProxy() for integrated mode
│ │ ├── plane-proxy.module.ts # Dynamic NestJS module (forRoot / forRootAsync)
│ │ ├── plane-config.registry.ts # Config registry (static + per-request via AsyncLocalStorage)
│ │ ├── plane-plugin-options.interface.ts # Configuration types + ResolveConfigFn
│ │ ├── config/ # Constants, serializers, decorators, utilities
│ │ └── modules/ # Feature modules (auth, issues, projects, etc.)
│ └── models/ # Shared models (published as @ever-gauzy/plugin-integration-plane-models)
├── .github/workflows/
│ └── publish.yml # NPM publish on version tags
├── turbo.json # Turborepo configuration
└── .env # Environment variables
- Node.js >= 18
- Yarn 1.22+
- A running Ever Gauzy API instance
- A Gauzy Tenant API key and secret (generated from the Gauzy admin panel)
git clone https://github.com/ever-co/ever-gauzy-plugins-plane.git
cd ever-gauzy-plugins-plane
yarn installCreate a .env file at the project root:
# Gauzy API connection
GAUZY_API_BASE_URL=http://localhost:5500/api
GAUZY_API_KEY=your_generated_api_key
GAUZY_API_SECRET=your_generated_api_secret
# Plane UI URLs (used for CORS and redirects)
PLANE_CLIENT_BASE_URL=http://localhost:3000
PLANE_CLIENT_ADMIN_URL=http://localhost:3001
PLANE_CLIENT_SPACE_URL=http://localhost:3002
PLANE_APP_BASE_URL=http://localhost:3040
# Optional
PLANE_GITHUB_APP_NAME=
PLANE_SLACK_CLIENT_ID=
PLANE_POSTHOG_KEY=
PLANE_POSTHOG_HOST=# Development (with hot reload)
yarn dev
# or
yarn start:api:dev
# Production
yarn build
yarn start:apiThe proxy will listen on http://localhost:3300. Swagger docs are available at http://localhost:3300/docs.
In your Plane frontend .env:
VITE_API_BASE_URL=http://localhost:3300When integrated, the proxy runs inside the Gauzy API process. All /api/plane/* requests are intercepted at the Node.js HTTP server level before reaching Gauzy's own middleware stack. No additional port is opened.
In your Gauzy plugin's package.json:
{
"dependencies": {
"@ever-gauzy/plugin-integration-plane-api": "^0.0.3"
}
}import { Module, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { mountPlaneProxy, MountPlaneProxyResult } from '@ever-gauzy/plugin-integration-plane-api';
@Module({})
export class PlaneIntegrationModule implements OnModuleInit, OnModuleDestroy {
private proxyResult: MountPlaneProxyResult | null = null;
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
async onModuleInit() {
this.proxyResult = mountPlaneProxy(
this.httpAdapterHost.httpAdapter.getHttpServer()
);
}
async onModuleDestroy() {
await this.proxyResult?.shutdown();
}
}GAUZY_API_BASE_URL=http://localhost:5500/api
GAUZY_API_KEY=your_api_key
GAUZY_API_SECRET=your_api_secret
PLANE_CLIENT_BASE_URL=http://localhost:3000In your Plane frontend .env:
VITE_API_BASE_URL=http://localhost:5500/api/planeIn a multi-tenant deployment, each tenant configures their own Plane integration from the Gauzy UI:
- Tenant admin navigates to Integrations in the Gauzy dashboard
- Selects Plane and enters their Plane UI URLs
- The system auto-generates an
apiKey/apiSecretfor that tenant - Everything is stored in the database, per-tenant
The proxy supports this through two callbacks:
resolveConfig(req)— looks up the tenant's config from the databaseextractTenantId(req)— extracts a tenant identifier from the request (used as cache key)
The proxy caches resolved configs in memory so the database is only hit once per tenant per TTL period (default: 60 seconds).
import { Module, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { mountPlaneProxy, MountPlaneProxyResult } from '@ever-gauzy/plugin-integration-plane-api';
import { PlaneIntegrationConfigService } from './plane-integration-config.service';
@Module({})
export class PlaneIntegrationModule implements OnModuleInit, OnModuleDestroy {
private proxyResult: MountPlaneProxyResult | null = null;
constructor(
private readonly httpAdapterHost: HttpAdapterHost,
private readonly configService: PlaneIntegrationConfigService
) {}
async onModuleInit() {
this.proxyResult = mountPlaneProxy(
this.httpAdapterHost.httpAdapter.getHttpServer(),
{
// Quick sync extraction — reads tenant ID from header, cookie, etc.
extractTenantId: (req) => {
return this.configService.extractTenantId(req);
},
// Async DB lookup — called only on cache miss
resolveConfig: async (req) => {
const tenantId = this.configService.extractTenantId(req);
const config = await this.configService.getConfigForTenant(tenantId);
return {
externalBaseApiUrl: config.gauzyApiUrl,
apiKey: config.apiKey,
apiSecret: config.apiSecret,
clientBaseUrl: config.planeWebUrl,
clientAdminUrl: config.planeAdminUrl,
clientSpaceUrl: config.planeSpaceUrl,
};
},
// Cache resolved configs for 60 seconds (default)
cacheTtl: 60_000,
}
);
}
async onModuleDestroy() {
await this.proxyResult?.shutdown();
}
}- A request arrives at
/api/plane/auth/email-checkfromhttps://plane.tenant-a.com extractTenantId(req)returns"tenant-a-uuid"— quick, sync, no DB- The proxy checks its in-memory cache for
"tenant-a-uuid":- Cache hit (< 60s since last lookup): uses cached config, no DB call
- Cache miss: calls
resolveConfig(req), stores result in cache
- The resolved
PlanePluginOptions(Tenant A'sapiKey,apiSecret,clientBaseUrl) is stored inAsyncLocalStorage - All proxy services (
ApiFetchService,AuthService, etc.) automatically read Tenant A's values viaPlaneConfigRegistry - The response goes back with Tenant A's CORS headers
| Scenario | DB calls | Latency |
|---|---|---|
| 1st request from Tenant A | 1 (cache miss) | ~5-10ms (DB) |
| Next 100 requests from Tenant A (within 60s) | 0 (cache hit) | ~0ms |
| Request after TTL expires | 1 (cache refresh) | ~5-10ms (DB) |
| Tenant admin updates config in Gauzy UI | Change takes effect within 60s (next cache refresh) |
Set cacheTtl: 0 to disable caching (every request hits the DB). Omit extractTenantId to disable caching as well.
| Value | Source |
|---|---|
apiKey / apiSecret |
Auto-generated by Gauzy when the tenant enables the Plane integration |
clientBaseUrl |
Entered by the tenant admin (where they host their Plane UI) |
clientAdminUrl |
Entered by the tenant admin |
clientSpaceUrl |
Entered by the tenant admin |
externalBaseApiUrl |
Usually the same Gauzy instance for all tenants |
PlaneConfigRegistry resolves values in this order:
- Request-scoped — from
resolveConfigviaAsyncLocalStorage(multi-tenant) - Static — from
PlaneProxyModule.forRoot(options)set at startup - Environment variables —
GAUZY_API_KEY,PLANE_CLIENT_BASE_URL, etc. - Defaults — hardcoded fallbacks
Existing standalone deployments (single-tenant, env-based) keep working with zero changes.
mountPlaneProxy(httpServer, options?) is the single entry-point for in-process integration. It:
- Intercepts the Node.js
http.Serverrequestevent for URLs starting with/api/plane - If
resolveConfigis provided, resolves the tenant's config (from cache or by calling the callback) - Handles CORS (including
OPTIONSpreflight) using the resolved client URLs - Creates a NestJS application in-process with
app.init()(noapp.listen(), no extra port) - Strips the
/api/planeprefix and delegates to the proxy's Express handler - If
resolveConfigwas used, wraps the handler inAsyncLocalStorage.run()so all downstream services read the tenant's values - Passes through all non-matching requests to the original server handler (Gauzy)
Returns a MountPlaneProxyResult with a shutdown() method for graceful cleanup.
mountPlaneProxy(httpServer, {
// URL prefix (default: '/api/plane')
prefix: '/api/plane',
// Multi-tenant: resolve config per-request from database
resolveConfig: async (req) => ({ ... }),
// Extract tenant ID from request (used as cache key)
extractTenantId: (req) => req.headers['x-tenant-id'] as string,
// Cache TTL in ms (default: 60000, set 0 to disable)
cacheTtl: 60_000,
// Single-tenant fallbacks (used when resolveConfig is not provided):
externalBaseApiUrl: 'http://localhost:5500/api',
clientBaseUrl: 'http://localhost:3000',
apiKey: '...',
apiSecret: '...',
});| Option | Env Variable | Required | Description |
|---|---|---|---|
externalBaseApiUrl |
GAUZY_API_BASE_URL |
Yes | Base URL of the Gauzy API (e.g. http://localhost:5500/api) |
apiKey |
GAUZY_API_KEY |
Yes | Gauzy Tenant API key (sent as X-APP-ID header) |
apiSecret |
GAUZY_API_SECRET |
Yes | Gauzy Tenant API secret (sent as X-API-KEY header) |
clientBaseUrl |
PLANE_CLIENT_BASE_URL |
No | Plane web app URL (default: http://localhost:3000) |
clientAdminUrl |
PLANE_CLIENT_ADMIN_URL |
No | Plane admin app URL (default: http://localhost:3001) |
clientSpaceUrl |
PLANE_CLIENT_SPACE_URL |
No | Plane space app URL (default: http://localhost:3002) |
appBaseUrl |
PLANE_APP_BASE_URL |
No | URL returned in instance config responses |
apiToken |
PLANE_API_TOKEN |
No | Optional API token |
githubAppName |
PLANE_GITHUB_APP_NAME |
No | GitHub app name for instance config |
slackClientId |
PLANE_SLACK_CLIENT_ID |
No | Slack client ID for instance config |
posthogKey |
PLANE_POSTHOG_KEY |
No | PostHog analytics API key |
posthogHost |
PLANE_POSTHOG_HOST |
No | PostHog host URL |
type ResolveConfigFn = (req: http.IncomingMessage) => PlanePluginOptions | Promise<PlanePluginOptions>;Resolves the tenant's configuration. Called on cache miss (or on every request if caching is disabled). Can be sync or async.
type ExtractTenantIdFn = (req: http.IncomingMessage) => string | undefined;Extracts a tenant identifier from the raw request (header, cookie, Origin, etc.). Must be sync and fast — it's called on every request to check the cache. Return undefined to skip caching for that request.
For advanced use cases, the NestJS module can be imported with full control:
// Static configuration
PlaneProxyModule.forRoot({
externalBaseApiUrl: 'http://localhost:5500/api',
apiKey: 'xxx',
apiSecret: 'yyy',
clientBaseUrl: 'http://localhost:3000',
});
// Async configuration (e.g. from database or ConfigService)
PlaneProxyModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
externalBaseApiUrl: config.get('GAUZY_API_BASE_URL'),
apiKey: config.get('GAUZY_API_KEY'),
apiSecret: config.get('GAUZY_API_SECRET'),
}),
});The proxy covers the following Plane functionality:
| Module | Endpoints | Description |
|---|---|---|
| Auth | /auth/* |
Email check, login, logout, CSRF tokens |
| Instances | /api/instances |
Plane instance configuration |
| Users | /api/users/me/* |
Current user profile, settings, workspaces |
| Workspaces | /api/workspaces/* |
Workspace CRUD, members, roles |
| Projects | /:workspace/projects/* |
Project CRUD, members, deploy boards |
| Issues | /:workspace/projects/:id/issues/* |
Issue CRUD, bulk operations |
| States | /:workspace/projects/:id/states/* |
Workflow states |
| Labels | /:workspace/projects/:id/labels/* |
Issue labels |
| Cycles | /:workspace/projects/:id/cycles/* |
Sprint/cycle management |
| Modules | /:workspace/projects/:id/modules/* |
Project modules |
| Comments | /:workspace/projects/:id/issues/:id/comments/* |
Issue comments |
| Reactions | /:workspace/.../reactions/* |
Comment and issue reactions |
| Relations | /:workspace/projects/:id/issues/:id/relations/* |
Issue dependencies |
| Links | /:workspace/projects/:id/issues/:id/links/* |
Issue links |
| Views | /:workspace/projects/:id/views/* |
Saved issue views |
| Pages | /:workspace/projects/:id/pages/* |
Project pages/wiki |
| Dashboard | /api/dashboard/* |
Dashboard widgets and stats |
| Analytics | /:workspace/analytics/* |
Advanced analytics and charts |
| Notifications | /:workspace/users/notifications/* |
User notifications |
| Favorites | /:workspace/users/favorites/* |
User favorites |
| File Assets | /api/assets/* |
File upload and retrieval |
| Invitations | /:workspace/invitations/* |
Workspace invitations |
| Activity | /:workspace/projects/:id/activities/* |
Activity feed |
The proxy translates between Gauzy and Plane data models in both directions:
Request transformation (Plane → Gauzy):
name→title,target_date→dueDate,assignee_ids→members,label_ids→tags, etc.
Response transformation (Gauzy → Plane):
title→name,members→assignee_ids,dueDate→target_date, etc.
Transformers are located in packages/plugin-plane/src/config/serializers/.
- Cookie Parser — Extracts cookies from incoming requests
- TokenMiddleware — Reads JWT from
auth-proxy-plane-token-*cookies and attaches it to the request - WorkspaceMiddleware — Resolves workspace context (tenant, organization) from the URL
- AuthGuard — Protects routes requiring authentication (public routes are decorated with
@Public())
yarn dev # Start all packages in dev mode (Turborepo)
yarn start:api:dev # Start the proxy in dev mode with hot reload (nodemon)
yarn build # Build all packages
yarn start:api # Start production server
yarn lint # Run ESLint
yarn format # Format with Prettier- Create a new folder under
packages/plugin-plane/src/modules/ - Create the NestJS module, controller, and service
- Add the module to the
FEATURE_MODULESarray inplane-proxy.module.ts - Add transformers/serializers in
packages/plugin-plane/src/config/serializers/if needed
Publishing is automated via GitHub Actions (.github/workflows/publish.yml).
- Push a version tag to
main:git tag v0.1.0 git push origin v0.1.0
- The workflow verifies the tag is on
main, builds the project, and publishes both packages to NPM under the@ever-gauzyorganization.
- An
NPM_TOKENsecret in the repository settings (Granular Access Token with 2FA bypass enabled) - The tag must be on the
mainbranch
The workflow also supports workflow_dispatch for manual runs from the GitHub Actions UI.
- Runtime: Node.js >= 18
- Framework: NestJS 10.x
- Language: TypeScript 5.4 (strict mode)
- HTTP Client: Axios via
@nestjs/axios - Authentication: JWT with cookie-based storage
- Build System: Turborepo + Yarn workspaces
- API Docs: Swagger/OpenAPI (standalone mode)
- Testing: Jest
Ever® is a registered trademark of Ever Co. LTD. Ever® Demand™, Ever® Gauzy™, Ever® Teams™, Ever® Rec™, Ever® Recu™, Ever® Cloc™, Ever® Works™ and Ever® OpenSaaS™ are all trademarks of Ever Co. LTD.
The trademarks may only be used with the written permission of Ever Co. LTD. and may not be used to promote or otherwise market competitive products or services.
Plane is a trademark of Plane Software, Inc. (or its respective owner). This project's use of the name "Plane" is strictly for identification and interoperability purposes and does not imply any ownership, affiliation, or endorsement
All other brand and product names are trademarks, registered trademarks, or service marks of their respective holders.
- Please give us ⭐ on Github, it helps!
- You are more than welcome to submit feature requests in the separate repo
- Pull requests are always welcome! Please base pull requests against the develop branch and follow the contributing guide.
This project is licensed under the AGPLv3 License — see the LICENSE file for details.