bb-plane-fork/apps/web/core/services/api.service.ts
binarybeach a9b4921973 binarybeachio: SPA 401 interceptor hard-nav via /sign-in/ + cache-bust
Vanilla 401 handler did `window.location.replace('/?next_path=<currentPath>')`.
That IS a hard nav, but the browser's HTTP cache returns the cached SPA
bundle for `/` — the SPA boots, re-fetches the same /api endpoint, gets 401
again, and loops without ever hitting Traefik at the document level. Diagnosed
2026-05-05 via HAR analysis: 9 history entries bouncing `/` ↔ `/?next_path=/`
at ~780ms intervals; zero requests to bridge or oauth2-proxy during the
loop; first bridge.binarybeach.io/handoff request only after Ctrl+Shift+R.

Trigger on the platform side: oauth2-proxy refresh fails for cross-org
gmail-federated users (separate root cause — disabled platform-wide via
OAUTH2_PROXY_OIDC_GROUPS_CLAIM=). The hard-nav fix here is the safety net
that handles that and any other future 401-causing scenario.

Replace with `window.location.replace('/sign-in/?_bb_reauth=<Date.now()>')`:
- /sign-in/ matches Plane's priority-200 plane-signin-redirect Traefik
  router (matched on PathRegexp `^/(sign-in|sign-up|signin|login|register|
  accounts/sign-in)(/.*)?$$`), which 302s to the bridge handoff regardless
  of cookie state.
- _bb_reauth=<ts> cache-busts so even a previously-cached /sign-in/
  response can't short-circuit the request.

Vanilla Plane regression-safe: /sign-in/ is also a known SPA route in
upstream that bounces to /, so non-platform deployments see the same
behavior they'd get without this patch (modulo a single extra navigation).

Also fixes BINARYBEACHIO.md frontend build instructions: Dockerfile.web
needs the monorepo root as build context (turbo prune scope), opposite of
Dockerfile.api which needs apps/api/ as context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:38:03 -10:00

77 lines
2.7 KiB
TypeScript

/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { AxiosInstance, AxiosRequestConfig } from "axios";
import axios from "axios";
export abstract class APIService {
protected baseURL: string;
private axiosInstance: AxiosInstance;
constructor(baseURL: string) {
this.baseURL = baseURL;
this.axiosInstance = axios.create({
baseURL,
withCredentials: true,
});
this.setupInterceptors();
}
private setupInterceptors() {
this.axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
if (error.response && error.response.status === 401) {
// binarybeachio fork — re-auth must miss the browser HTTP cache.
// Vanilla Plane navigated to `/?next_path=<currentPath>` which is the
// SPA root route; the browser served the cached SPA bundle, the SPA
// re-fetched the same /api endpoint, the call 401'd again, and the
// page looped without ever hitting the network at the document
// level. Plane's Traefik plane-signin-redirect router (priority 200,
// matching `^/(sign-in|...)`) catches `/sign-in/` regardless of
// cookie state and 302s to the bridge handoff — but only if the
// browser actually fetches it. Append a ts param so the URL is
// never in cache, and target /sign-in/ so the priority-200 router
// wins. Vanilla Plane handles /sign-in/ in its SPA too (the SPA
// bounces it to /), so the patch is also benign in non-platform
// deployments. See binarybeachio/docs/conventions/per-app-edge-
// identity-validation.md and feedback_plane_spa_cached_loop.
window.location.replace(`/sign-in/?_bb_reauth=${Date.now()}`);
}
return Promise.reject(error);
}
);
}
get(url: string, params = {}, config: AxiosRequestConfig = {}) {
return this.axiosInstance.get(url, {
...params,
...config,
});
}
post(url: string, data = {}, config: AxiosRequestConfig = {}) {
return this.axiosInstance.post(url, data, config);
}
put(url: string, data = {}, config: AxiosRequestConfig = {}) {
return this.axiosInstance.put(url, data, config);
}
patch(url: string, data = {}, config: AxiosRequestConfig = {}) {
return this.axiosInstance.patch(url, data, config);
}
delete(url: string, data?: any, config: AxiosRequestConfig = {}) {
return this.axiosInstance.delete(url, { data, ...config });
}
request(config = {}) {
return this.axiosInstance(config);
}
}