bb-plane-fork/BINARYBEACHIO.md
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

19 KiB
Raw Blame History

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.md for the bucket taxonomy and bridge contract
  • binarybeachio/docs/architecture/bridge-jwt-replay-protection.md for the JWT replay-protection contract
  • binarybeachio/docs/services/plane/migration-plan.md for 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 60006099 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 RS256 JWT signed with BRIDGE_SIGNING_KEY (private). Claims: iss=bb-bridge, aud=plane, iat, exp (now+60s), jti (UUIDv4), sub, email, first_name, last_name, tenant, and bb_mailbox (when emitted by Zitadel's bb-claims Action — see binarybeachio/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, atomically SETNX bb_bridge_jti:<jti> in shared-redis with TTL = exp - now + 30s, find-or-creates User keyed on bb_mailbox (preferred) or email (fallback), calls user_login(), 302s to next_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.py and 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 sibling views/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-admin user 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 createdis_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:

  1. Forgejo sourcegit.binarybeach.io/binarybeach/bb-plane-fork is publicly readable.
  2. GitHub mirror — push-mirror to github.com/binarybeachllc/bb-plane-fork.
  3. 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)

  1. Local build smoke: both images build cleanly.
  2. Local stack: docker compose -f docker-compose.bb-local.yml --env-file .env.bb-local up -d (with BB_BRIDGE_PUBLIC_KEY_URL unset) → vanilla email+password sign-in works (regression check).
  3. Trusted-JWT happy path: with BB_BRIDGE_PUBLIC_KEY_URL pointing at production bridge, hand-mint a JWT (claims: iss=bb-bridge, aud=plane, valid exp, fresh jti, valid email), GET /auth/sign-in-trusted/?token=<jwt>&next_path=/, expect 302 to / with sessionid cookie set.
  4. 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.
  5. Trusted-JWT disabled regression: unset BB_BRIDGE_PUBLIC_KEY_URL, hit /auth/sign-in-trusted/, expect 404.
  6. Production deploy: bump tag in binarybeachio/infrastructure/plane/docker-compose.ymlpy infrastructure/_shared/bootstrap.py → verify on pm.binarybeach.binarybeach.io.