Skip to content

feat: org switching - #26

Open
marcorivm wants to merge 1 commit into
chore/upstream-v1.45.0from
feat/org-switching
Open

feat: org switching#26
marcorivm wants to merge 1 commit into
chore/upstream-v1.45.0from
feat/org-switching

Conversation

@marcorivm

Copy link
Copy Markdown
Member

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. bootstrapOrganization gives every user their own org as owner; 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.ts derives organizationId from the resolved project and ignores x-organization-id whenever a project resolves — which on flat editions is always, because resolveProjectId falls back to findUserDefaultProject. The org header was simply dead there.

Fixed by making the fallback org-aware rather than inverting precedence:

findUserDefaultProject(userId, preferredOrgId?)

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 resolveProjectId validates a project against any org you belong to, not the selected one.

The fence only ever narrows

Two properties, both tested:

  • The active-membership gate still applies, so a forged X-Organization-Id cannot promote a stranger's project.
  • 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, or session auth 401s the user everywhere.

hasResolvableProjectExcluding deliberately does not learn about orgs

This 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/organizations mirrors /v1/projects' guard stack, including the scope === "project" fence — a leaked agent key must not enumerate its owner's other organizations. Read-only, so nothing to audit.
  • Suspended memberships are excluded: a switch there would resolve to nothing.
  • The switcher renders only with 2+ memberships — gated on the count, not an edition flag, so it appears exactly when useful (single-org-shared has one org by construction).
  • Like the project switcher, switching clears the whole query cache: getOrganizationId() is an undefined stub, 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.

pnpm --filter @onecli/api test   # 1212 passed
pnpm --filter @onecli/web test   # 13 passed
cargo test -p onecli-gateway     # 615 passed
pnpm run check                   # 9/9

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
marcorivm force-pushed the feat/org-switching branch from ae2474d to a0dd794 Compare August 8, 2026 19:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant