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>
19 KiB
bb-plane-fork — binarybeachio customizations of Plane
This file is the canonical contract between this fork and the binarybeachio platform repo. It exists so anyone (or any agent) on a fresh session can answer "what's customized, why, and how do I refresh from upstream" without reading code.
Fork repo convention (template — same shape for every Path B fork in binarybeachio):
upstream remote → original project on github.com (read-only, merge-source)
origin remote → git.binarybeach.io/binarybeach/bb-<name>-fork (where we push)
github mirror → github.com/binarybeachllc/bb-<name>-fork (push-mirror, off-site backup)
upstream branch — clean mirror of upstream's default branch, never modified
main branch — our customizations on top of latest upstream tag we've integrated
update/<v> — short-lived integration branch when pulling a new upstream version
git log main..upstream = "upstream changes I haven't pulled in"
git log upstream..main = "binarybeachio's customizations"
Upstream
| Field | Value |
|---|---|
| Project | Plane (open-source project management) |
| Upstream repo | https://github.com/makeplane/plane |
| Upstream default branch | preview |
| Currently integrated upstream version | v1.3.0 (release commit cf696d2) |
| License | AGPL-3.0-only (we MUST publish source of any deployed customizations — public Forgejo + push-mirror to GitHub satisfies this) |
Why we forked (post-2026-05-04 platform-architecture pivot)
Plane's first-party OIDC support is gated behind the Pro/Business commercial edition (Pro tier minimum 25 users = $338+/mo). The community edition's /god-mode/authentication/oidc page is a frontend stub — the backend handler returns 404. Plane CE has GitHub/GitLab/Gitea/Google OAuth providers but no native OIDC, no SAML, and no trusted-proxy-header auth.
We integrate Plane into the binarybeachio platform via the architecture's Bucket 4 pattern: a single additive trusted-JWT endpoint that the platform's auth-bridge calls after oauth2-proxy validates a Zitadel session at the edge. See:
binarybeachio/docs/architecture/01-platform-architecture.mdfor the bucket taxonomy and bridge contractbinarybeachio/docs/architecture/bridge-jwt-replay-protection.mdfor the JWT replay-protection contractbinarybeachio/docs/services/plane/migration-plan.mdfor the full per-service migration write-up
The previous shape of this fork (in-place patching of provider/oauth/github.py to repurpose GitHub OAuth as Zitadel OIDC) was reverted on 2026-05-04 in favor of the Bucket-4 additive endpoint, which has a smaller fork surface, fewer hot files to track on upstream merges, and centralizes OIDC handling in the auth-bridge instead of duplicating it per-app. The pre-revert source state is preserved on the pre-migration-2026-05-04 branch for reference.
What's customized (the inventory — keep current)
Three logical patch groups across the repo. Touch surface is intentionally minimal.
Patch 1: Bucket-4 trusted-JWT entry-point (additive — 1 new file + 2 line additions)
| File | Change | Risk on upgrade |
|---|---|---|
apps/api/plane/authentication/views/app/trusted.py |
New file. Django View that validates a bridge-issued RS256 JWT, atomically claims its jti in shared-redis (replay protection), find-or-creates the User keyed on bb_mailbox (four-layer identity model — falls back to email when the claim is absent), and calls user_login(request, user, is_app=True) to set the Django session cookie. Sets _bb_edge_sub cookie on the redirect response (mine.7) — the per-app edge-identity marker compared by the middleware on every authenticated request. PEM is fetched at runtime from BB_BRIDGE_PUBLIC_KEY_URL (avoids the env-PEM corruption issue Coolify has with backslash-escaped keys). Endpoint is implicitly disabled (returns 404) when the env is unset. |
Low. Depends only on User model, user_login, post_user_auth_workflow, and get_safe_redirect_url — all stable upstream APIs. PyJWT and requests are existing deps. |
apps/api/plane/authentication/urls.py |
1-line addition appending path("sign-in-trusted/", TrustedSignInEndpoint.as_view(), name="sign-in-trusted") to the urlpatterns list. |
Low. Pure append; no existing routes modified. |
apps/api/plane/authentication/views/__init__.py |
1-line addition exporting TrustedSignInEndpoint. |
Low. Pure append. |
apps/api/plane/authentication/adapter/error.py |
Adds 7 error codes in the 6000–6099 range (reserved for fork additions). Pure dict-additions; no existing entries renumbered. | None. |
apps/api/plane/middleware/bb_edge_identity.py |
New file (mine.7). Django middleware that compares the _bb_edge_sub cookie to X-Auth-Request-User on every authenticated request: lazy-populates on legacy sessions, and on mismatch flushes the Django session + replaces request.user with AnonymousUser so DRF returns 401 / browser navigations land at the bridge handoff redirect. See binarybeachio/docs/conventions/per-app-edge-identity-validation.md. |
Low. Pure addition; uses only django.contrib.auth.models.AnonymousUser and request.session.flush(). Skip-paths handled by the request.user.is_anonymous short-circuit (covers login, trusted-sign-in, healthcheck, public webhooks). |
apps/api/plane/settings/common.py |
1-line MIDDLEWARE addition (mine.7) — plane.middleware.bb_edge_identity.BbEdgeIdentityMiddleware inserted after django.contrib.auth.middleware.AuthenticationMiddleware. |
Low. Pure list-append at a stable position. |
apps/api/plane/authentication/views/app/signout.py |
(mine.7) Reads optional BB_LOGOUT_REDIRECT_URL env; when set, the SPA's sign-out form-POST gets 302'd to the platform bridge /logout instead of back to the app root. Bridge clears _bb_oauth2 and back-channels Zitadel end_session. Also explicitly deletes _bb_edge_sub on the response. Falls back to vanilla base_host() when env is unset. |
Low. Single-file behavioral change gated by env; vanilla regression-safe. |
The full bridge ↔ Plane contract:
- Bridge mints
RS256JWT signed withBRIDGE_SIGNING_KEY(private). Claims:iss=bb-bridge,aud=plane,iat,exp(now+60s),jti(UUIDv4),sub,email,first_name,last_name,tenant, andbb_mailbox(when emitted by Zitadel'sbb-claimsAction — seebinarybeachio/docs/architecture/multi-tenant-identity.md§4). - Bridge 302s the user's browser to
https://pm.<tenant>.binarybeach.io/auth/sign-in-trusted/?token=<jwt>&next_path=<rd>. - Plane's view: fetches public key from
BB_BRIDGE_PUBLIC_KEY_URL(cached 5 min), verifies signature + claims, atomicallySETNX bb_bridge_jti:<jti>in shared-redis with TTL =exp - now + 30s, find-or-creates User keyed onbb_mailbox(preferred) oremail(fallback), callsuser_login(), 302s tonext_path. - Replay protection is fail closed: if shared-redis is unavailable, the request is rejected. Operator break-glass uses the email+password sign-in (vanilla upstream code) which doesn't depend on either Redis or the bridge.
Patch 2: Presigned PUT for uploads (R2/B2 don't implement PostObject)
| File | Change | Risk on upgrade |
|---|---|---|
apps/api/plane/settings/storage.py |
S3Storage.generate_presigned_post(...) rewritten to mint a presigned PUT URL via generate_presigned_url(HttpMethod="PUT"). Method name preserved for caller compat. |
Medium. If Plane's upload flow changes upstream, conflict surface grows. Candidate for upstream PR. |
apps/api/plane/utils/openapi/responses.py |
OpenAPI example response updated to PUT shape. | Low. |
apps/api/plane/tests/unit/settings/test_storage.py |
2 tests retargeted to assert generate_presigned_url boto3 call. |
Low. |
packages/types/src/file.ts |
TFileSignedURLResponse.upload_data adds method?: "PUT" | "POST", drops AWS POST-form-data fields. |
Low. |
packages/services/src/file/helper.ts |
generateFileUploadPayload(...) returns a TFileUploadRequest descriptor; dispatches PUT/POST. |
Medium. |
packages/services/src/file/file-upload.service.ts + apps/web/core/services/file-upload.service.ts |
uploadFile(...) signature changed to (payload, progress?). Uses axios.request({method, url, data, headers}). |
Medium. |
apps/web/core/services/file.service.ts, apps/web/core/services/issue/issue_attachment.service.ts, packages/services/src/file/sites-file.service.ts |
5 caller sites updated to pass TFileUploadRequest to uploadFile. |
Low. |
Decision record at binarybeachio/docs/features/storage-upload-flow.md. Patch 2 is independent of Patch 1 — git revert <storage-PUT sha> undoes it cleanly.
Patch 3: Brand asset (kept as dormant; entry-point UX is Traefik-driven)
| File | Change | Risk on upgrade |
|---|---|---|
apps/web/app/assets/logos/binarybeach-logo.png |
New asset. Currently unreferenced; preserved for future AGPL §13 footer-link addition or other branding work. | None. |
The previous fork's GitHub-button rebrand patch (apps/web/core/hooks/oauth/core.tsx) was reverted on 2026-05-04. Sign-in entry-point UX is now driven by a Traefik redirectregex middleware applied to the per-tenant Plane router that 302s /sign-in*, /sign-up*, /accounts/sign-in* to https://bridge.binarybeach.io/handoff?app=plane&tenant=<slug>&.... Pure infrastructure config; no source modification needed for the redirect.
Files not changed (deliberately):
apps/api/plane/authentication/provider/oauth/github.py— upstream-clean. Vanilla GitHub OAuth still works if configured via god-mode UI.apps/api/plane/authentication/views/app/github.pyand the gitlab/gitea/google equivalents — all upstream-clean.apps/admin/...— god-mode UI unchanged.apps/space/...— public-share OAuth unchanged. Authenticated public boards continue to use email+password sign-in (vanilla upstream). When a tenant needs SSO for shared boards, add a siblingviews/space/trusted.py(estimated ~80 LOC, mirrors the app/ view).
Required runtime config
Set on the patched plane-backend container (binarybeachio sets these in infrastructure/plane/.env):
# Activates the trusted-JWT endpoint. URL points at the in-cluster bridge
# service's public-key endpoint. Unset → endpoint returns 404 (regression-safe).
BB_BRIDGE_PUBLIC_KEY_URL=http://auth-bridge-<uuid>:3000/.well-known/bb-bridge.pub.pem
# When set, the SPA's /auth/sign-out/ form-POST is 302'd to the platform
# bridge's synced-logout endpoint, which clears the edge `_bb_oauth2` cookie
# and back-channels Zitadel's end_session. Unset → falls back to vanilla
# behavior (redirect to app root).
BB_LOGOUT_REDIRECT_URL=https://bridge.binarybeach.io/logout?rd=https://pm.binarybeach.io/
Bridge-side configuration (in binarybeachio/infrastructure/auth-bridge/.env):
ADAPTER_PLANE_BINARYBEACH_BASE_URL=https://pm.binarybeach.binarybeach.io
# BRIDGE_SIGNING_KEY is loaded centrally by bridge-key.ts; the matching
# public key is served at /.well-known/bb-bridge.pub.pem and consumed by
# Plane via BB_BRIDGE_PUBLIC_KEY_URL.
Plane god-mode admin UI (/god-mode/authentication/...):
- All four upstream OAuth providers (GitHub/GitLab/Gitea/Google) can be left disabled. The trusted-JWT entry-point is the SSO front door.
- Email+password sign-in remains available as the break-glass admin path. Per-tenant
bb-adminuser is seeded with a permanent password from_shared/.env.bb-admin.
Cross-fork conventions adopted
This fork pulls in binarybeachio's session lifecycle convention — 15-min idle timeout, slide-on-activity. Applied automatically by bootstrap.py at deploy. To override for this fork specifically, set SESSION_COOKIE_AGE / ADMIN_SESSION_COOKIE_AGE / SESSION_SAVE_EVERY_REQUEST in infrastructure/plane/.env (per-app .env beats convention).
Refresh from upstream — the procedure
When a new Plane release lands and we want to integrate:
git fetch upstream
# Sync the upstream mirror branch (never touched by us)
git switch upstream
git reset --hard upstream/preview # or @v1.4.0 if we track tags
git push origin upstream
# Integration branch
git switch main
git switch -c update/v1.4.0
git merge upstream # likely conflict-free since fork is additive
# Hand-test:
# 1. Local stack via docker-compose.bb-local.yml — confirm sign-in works.
# 2. Trusted endpoint with a hand-minted JWT (helper script TBD; for now,
# mint via a node REPL using bridge-key.ts:signBridgeJwt).
# 3. Vanilla email+password regression test.
# Once happy:
git switch main
git merge --ff-only update/v1.4.0
git branch -d update/v1.4.0
git push origin main
# Then on laptop: rebuild + tag + push images (see "Build" below)
# Then in binarybeachio repo: bump tag in infrastructure/plane/docker-compose.yml
# Then: py infrastructure/_shared/bootstrap.py to trigger the Coolify deploy
Build — which images to rebuild and how
Per binarybeachio architecture doc §7.4 ("only rebuild what we touched"), this fork only requires rebuilding two of the six Plane images:
| Image | Customized? | Source | Build target |
|---|---|---|---|
plane-backend |
YES (Patch 1 + Patch 2) | apps/api/Dockerfile.api |
git.binarybeach.io/binarybeach/plane-backend:v1.3.0-mine.<n> |
plane-frontend (aka web) |
YES (Patch 2 frontend bits only) | apps/web/Dockerfile.web |
git.binarybeach.io/binarybeach/plane-frontend:v1.3.0-mine.<n> |
plane-space |
no | upstream makeplane/plane-space:v1.3.0 |
(no rebuild) |
plane-admin |
no | upstream makeplane/plane-admin:v1.3.0 |
(no rebuild) |
plane-live |
no | upstream makeplane/plane-live:v1.3.0 |
(no rebuild) |
plane-proxy |
no | upstream makeplane/plane-proxy:v1.3.0 |
(no rebuild) |
Tag scheme per architecture §6 #7: <upstream-version>-mine.<n>. Push immutable tag + :latest:
# Backend — Dockerfile.api COPY paths are relative to apps/api/, so the
# build context must be apps/api/, NOT the repo root. Building from the
# repo root (`-f apps/api/Dockerfile.api .`) fails: "/plane: not found".
cd C:\Users\maxwe\GitHubRepos\bb-plane-fork\apps\api
docker build -t git.binarybeach.io/binarybeach/plane-backend:v1.3.0-mine.<n> \
-t git.binarybeach.io/binarybeach/plane-backend:latest \
-f Dockerfile.api .
docker push git.binarybeach.io/binarybeach/plane-backend:v1.3.0-mine.<n>
docker push git.binarybeach.io/binarybeach/plane-backend:latest
# Frontend — OPPOSITE convention: Dockerfile.web does `COPY . .` then
# `turbo prune --scope=web --docker`, so the context must be the monorepo
# ROOT (turbo needs to see all workspaces to prune correctly).
cd C:\Users\maxwe\GitHubRepos\bb-plane-fork
docker build -t git.binarybeach.io/binarybeach/plane-frontend:v1.3.0-mine.<n> \
-t git.binarybeach.io/binarybeach/plane-frontend:latest \
-f apps/web/Dockerfile.web .
docker push git.binarybeach.io/binarybeach/plane-frontend:v1.3.0-mine.<n>
docker push git.binarybeach.io/binarybeach/plane-frontend:latest
mine.<n> resets to mine.1 on every upstream version bump; increments per local rebuild within the same upstream version.
Tag history
Plane runs as two patched images (plane-backend, plane-frontend); they bump on independent cadences. Backend tag is the one that lives in infrastructure/plane/.env::PLANE_BACKEND_IMAGE.
| Tag | Upstream | Date | What changed |
|---|---|---|---|
plane-frontend:v1.3.0-mine.4 |
v1.3.0 | 2026-05-05 | SPA 401 interceptor hard-navs to /sign-in/?_bb_reauth=<ts> (with cache-bust ts) instead of /?next_path=.... Vanilla path was a hard nav too, but the browser HTTP cache returned the cached SPA bundle for /, looping on every XHR 401 without ever hitting Traefik. Routing through /sign-in/ matches the priority-200 plane-signin-redirect Traefik router → bridge handoff. Bundled with the mine.7 backend edge-identity work. |
plane-backend:v1.3.0-mine.7 |
v1.3.0 | 2026-05-05 | Per-app edge-identity validation (_bb_edge_sub cookie + BbEdgeIdentityMiddleware); BB_LOGOUT_REDIRECT_URL env re-points sign-out to platform bridge /logout. Per binarybeachio/docs/conventions/per-app-edge-identity-validation.md. |
plane-backend:v1.3.0-mine.6 |
v1.3.0 | 2026-05-05 | Trusted view keys User on bb_mailbox (four-layer identity model T2.4); WARN-log fallback to federation email when claim absent. |
plane-backend:v1.3.0-mine.5 |
v1.3.0 | 2026-05-04 | Trusted view mirrors OauthAdapter user-create shape (Profile, username uuid hex, is_email_verified, is_password_autoset, transaction). |
plane-backend:v1.3.0-mine.4 |
v1.3.0 | 2026-05-04 | Trusted view: rename log extra key created → is_signup (LogRecord built-in collision). |
plane-backend:v1.3.0-mine.3 |
v1.3.0 | 2026-05-04 | Bucket-4 trusted-JWT auth (/auth/sign-in-trusted/). Replaces in-place github.py-as-OIDC patch. |
plane-backend:v1.3.0-mine.2 |
v1.3.0 | 2026-05-01 | Presigned-PUT signature mismatch fix on empty Content-Type. |
plane-backend:v1.3.0-mine.1 |
v1.3.0 | 2026-04-30 | Initial fork — presigned PUT for uploads (R2/B2 don't implement PostObject), GitHub-as-OIDC, brand asset. |
plane-frontend follows independently. mine.3 was Patch 2 frontend bits (presigned-PUT plumbing). mine.4 (2026-05-05) is the 401-interceptor hard-nav fix bundled with the edge-identity rollout.
License compliance
Plane is AGPL-3.0-only. The license requires us to provide the source of any modified version we deploy or offer over a network. Our compliance:
- Forgejo source —
git.binarybeach.io/binarybeach/bb-plane-forkis publicly readable. - GitHub mirror — push-mirror to
github.com/binarybeachllc/bb-plane-fork. - In-product source link — TODO. AGPL §13 requires "prominent" notice to network users; a footer suffices. Tracked separately.
Test plan (manual, until we have CI)
- Local build smoke: both images build cleanly.
- Local stack:
docker compose -f docker-compose.bb-local.yml --env-file .env.bb-local up -d(withBB_BRIDGE_PUBLIC_KEY_URLunset) → vanilla email+password sign-in works (regression check). - Trusted-JWT happy path: with
BB_BRIDGE_PUBLIC_KEY_URLpointing at production bridge, hand-mint a JWT (claims:iss=bb-bridge,aud=plane, validexp, freshjti, valid email),GET /auth/sign-in-trusted/?token=<jwt>&next_path=/, expect 302 to/with sessionid cookie set. - Trusted-JWT replay rejection: hit the same URL with the same token twice. First → 302 + sessionid. Second → 302 to error redirect with
TRUSTED_JWT_TOKEN_REPLAYED. - Trusted-JWT disabled regression: unset
BB_BRIDGE_PUBLIC_KEY_URL, hit/auth/sign-in-trusted/, expect 404. - Production deploy: bump tag in
binarybeachio/infrastructure/plane/docker-compose.yml→py infrastructure/_shared/bootstrap.py→ verify onpm.binarybeach.binarybeach.io.