feat: org switching - #26
Open
marcorivm wants to merge 1 commit into
Open
Conversation
Slice 4 of docs/project-lifecycle.md, unblocked by the v1.45.0 adoption that brings DEFAULT_ORG_COOKIE. Multi-org membership was already ordinary and already unreachable: every user gets their own org at bootstrap, and accepting an invitation adds a membership in someone else's — so anyone who has accepted an invite belonged to two orgs with no way to see or switch between them. THE BLOCKER was precedence, not UI. session.ts derives organizationId FROM the resolved project and ignores x-organization-id whenever a project resolves, which on flat editions is always (resolveProjectId falls back to findUserDefaultProject). So the org header was simply dead there. Fixed by making the fallback org-aware rather than inverting precedence: findUserDefaultProject takes an optional preferredOrgId, sourced from the org header. Switching org therefore means "land on my default project WITHIN that org", and org still derives from the winning project, so the two can never disagree. The fence only ever NARROWS. The active-membership gate still applies, so a forged header cannot promote a stranger's project; and a preference that resolves nothing falls through to the unfenced answer. That fallback is the lockout guard: a stale selection — org left, membership suspended, its last project deleted — must not resolve to no project at all, or session auth 401s the user everywhere. hasResolvableProjectExcluding deliberately does NOT learn about orgs. The two predicates must describe the same SET of resolvable projects; the preference changes only which one wins. Fencing the oracle too would make it answer a narrower question than the delete guard asks and reintroduce the lockout. A test asserts the two still agree. Switching org clears the project cookie. Non-negotiable: a project cookie from the previous org would otherwise win, since the project header takes precedence and the org is derived from it. The switcher renders only with 2+ memberships — gated on the count, not an edition flag, so it appears exactly when it is useful (single-org-shared deployments have one org by construction). 7 new tests. 1212 API, 13 web, 615 gateway; check clean.
marcorivm
force-pushed
the
feat/org-switching
branch
from
August 8, 2026 19:35
ae2474d to
a0dd794
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Slice 4 of #20, stacked on #25. 13 files, ~430 lines. Unblocked by the v1.45.0 adoption, which brings
DEFAULT_ORG_COOKIE.The gap
Multi-org membership was already ordinary and already unreachable.
bootstrapOrganizationgives every user their own org asowner; accepting an invitation adds a membership in someone else's. So anyone who has accepted an invite belonged to two organizations with no way to see or switch between them.The blocker was precedence, not UI
session.tsderivesorganizationIdfrom the resolved project and ignoresx-organization-idwhenever a project resolves — which on flat editions is always, becauseresolveProjectIdfalls back tofindUserDefaultProject. The org header was simply dead there.Fixed by making the fallback org-aware rather than inverting precedence:
Switching org now means "land on my default project within that org". Org still derives from the winning project, so the two can never disagree — which is what makes the existing design coherent. Inverting precedence instead would have touched the lockout invariant and opened a mismatch class, since
resolveProjectIdvalidates a project against any org you belong to, not the selected one.The fence only ever narrows
Two properties, both tested:
X-Organization-Idcannot promote a stranger's project.hasResolvableProjectExcludingdeliberately does not learn about orgsThis is the invariant #20 flagged as the highest-risk edit in either scope, so it's worth being explicit. The two predicates must describe the same set of resolvable projects; the preference changes only which one wins. Fencing the oracle too would make it answer a narrower question than the delete guard asks ("will this user resolve something afterwards?") and reintroduce the lockout. A test asserts the two still agree.
Switching clears the project cookie
Non-negotiable: a project cookie from the previous org would otherwise win, because the project header takes precedence and the org is derived from it. You'd switch org and stay put.
Smaller choices
GET /v1/organizationsmirrors/v1/projects' guard stack, including thescope === "project"fence — a leaked agent key must not enumerate its owner's other organizations. Read-only, so nothing to audit.single-org-sharedhas one org by construction).getOrganizationId()is anundefinedstub, so no query key changes.Tests
7 new (
organization-service.test.ts), covering the preference winning over an older project elsewhere, the fallback when it resolves nothing, the forged-header case, agreement with the lockout oracle, and the org list's active-only/role/foreign-org behaviour.