fix(plane): call the Gauzy API over loopback from the in-process Plane proxy

Merged
EV
18d ago
+262-4

Problem

pm.gauzy.co half-loads, with many 500s like:

Upstream API unreachable: connect ETIMEDOUT 188.114.97.5:443; connect ENETUNREACH 2a06:98c1:3120::5:443 - Local (:::0); connect ETIMEDOUT 188.114.96.5:443; ...

The Plane proxy is mounted on the Gauzy API's own HTTP server (/api/plane), but it sent its calls to Gauzy on the public API_BASE_URL (https://api.gauzy.co/api), set in both PlaneProxyService.getDefaultProxyConfig() and PlaneIntegrationService.getConfigForTenant(). So each Plane call went:

browser → Cloudflare → gauzy-api (proxy) → out of the pod → Cloudflare → back in → gauzy-api

The IPs in the error are api.gauzy.co's Cloudflare addresses: the pod could not open that outbound connection. Both IPv4 attempts timed out, and the pod has no IPv6 route. When it does connect, the round trip adds about 0.5–1.5 s per proxied call. The user-properties 400 "Failed to find or create new view properties" is the same failure: both of its calls to Gauzy fail.

Fix

  • The proxy now calls this API process over loopback, on the address and port the server actually listens on (httpServer.address()). A wildcard bind is reached on 127.0.0.1 / [::1], so there is no DNS, TLS or CDN in the path.
  • PLANE_PROXY_UPSTREAM_URL (new, optional) overrides it, in the same form as API_BASE_URL (an /api suffix is optional). Use it to point at a cluster Service, or at the public URL to restore the old behaviour.
  • A proxy run outside Gauzy (@ever-gauzy/plugin-integration-plane-api standalone) is unaffected: it still uses its own GAUZY_API_BASE_URL.
  • API_BASE_URL is unchanged everywhere else (emails, OAuth, redirects).
  • The resolved upstream is logged once: Plane proxy calls the Gauzy API at http://127.0.0.1:3000/api.

Why loopback is safe

  • externalBaseApiUrl is only used for the proxy's own apiFetch calls. It is never returned to the browser.
  • Gauzy serves plain HTTP (TLS ends at the ingress), so loopback needs no certificate.
  • No recursion: upstream paths are Gauzy routes (/api/...), not /api/plane/....
  • Nothing in core grants extra trust to loopback requests. The loopback hits in packages/auth only validate OAuth redirect URIs.
  • Rate limiting: authenticated routes are limited per user (taken from the forwarded Bearer token). Public routes such as email-check and login are limited per IP, and those already shared a single bucket, the cluster's egress IP, because the proxy never forwarded the client IP. Follow-up for the plugin repo: forward the client IP.

Tests

  • New plane-proxy.service.upstream.spec.ts (16 tests). It covers address mapping (wildcard v4/v6, specific v4/v6, unknown address) and override normalisation. It reaches a real server bound to 0.0.0.0 and to 127.0.0.1. Through the service's resolveConfig, it checks loopback for tenant and tenant-less requests and checks that the override wins.
  • Fails before the fix: the tenant case received https://api.gauzy.co/api.
  • Run: node --experimental-vm-modules node_modules/jest/bin/jest.js -c packages/plugins/integration-plane/jest.config.ts --runInBand: 2 suites, 23 tests pass.
  • The plugin's specs were never run in CI; this PR adds a Test the Plane integration plugin step to build.yml.

Rollout

No env change is needed: the loopback default applies on the next release. After deploy, check the pod log for Plane proxy calls the Gauzy API at http://127.0.0.1:..., and check that pm.gauzy.co has no Upstream API unreachable errors.

🤖 Generated with Claude Code