Umbrella organization model (#432): brand→org access foundation#461
Open
jrhizor wants to merge 12 commits into
Open
Umbrella organization model (#432): brand→org access foundation#461jrhizor wants to merge 12 commits into
jrhizor wants to merge 12 commits into
Conversation
Brand-scoped access today piggybacks on requireOrgAccess(userId, brandId), which only works because brand.id === organization.id from the 0010_scope_brands_to_orgs backfill. requireBrandAccess resolves a brand to its owning org (brands.organizationId) and checks membership there, so it keeps working once a brand's id and its org's id can diverge.
Switches the ~22 read/mutate server functions that check access on an already-existing brand from requireOrgAccess(userId, brandId) to requireBrandAccess(userId, brandId). Behavior-preserving today (brand.id still equals organization.id everywhere), but required before a brand's id and its owning org's id can diverge. createBrandFn and the onboarding/team create paths are untouched — they check org access before the brand row exists, which requireBrandAccess would incorrectly deny.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Rename slugifyOrgName to slugify (shared by both brand ids and org slugs), add findUniqueBrandId for globally-unique brand ids, and add provisionUmbrellaOrg for the single customer org a user gets on signup. Retire provisionAdditionalLocalOrg (and its findUniqueOrgId helper) now that creating a brand no longer mints a new org per brand.
Mirror local mode's user.create.after hook: cloud now creates the customer's org (and admin membership) at signup time via provisionUmbrellaOrg, instead of implicitly minting one the first time they create a brand.
Replace createBrandWithOrgFn with createBrandInOrgFn: the multi-brand "create new brand" flow now attaches a brand (with a fresh, globally unique id) to the caller's existing organization rather than provisioning a brand-new org for it. createBrandFn (the legacy path where brand.id == org.id) is unchanged.
…=org The $brand loader now looks up the brand row first and checks access via brand.organizationId; it only falls back to treating the url param as an org id for the legacy no-brand-row onboarding case (brand.id === org.id), which whitelabel still relies on. The /app switcher lists the user's brands (scoped across all of their orgs) instead of their orgs, and /app/new calls the new createBrandInOrgFn. Also drops the !supportsMultiOrg redirect, which never fired (every mode reports supportsMultiOrg: true) and would list the wrong id now anyway.
Add getBrandOrganizationId and use it in team.ts: each team fn now checks requireBrandAccess(brandId) and resolves the umbrella org id before touching member/invitation rows, instead of using the brand id directly as an organization id. Team membership is now shared across all of an org's brands. accept-invitation navigates to the /app switcher instead of assuming the invitation's org id is a brand id.
The wizard's server fns run against a brand that already exists (the route has navigated to /app/$brand by then), so they can resolve access through the brand's owning org like the other brand-scoped fns, instead of treating brandId as an org id directly.
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.
Implements the umbrella-organization model from #432: one customer organization owning many brands, replacing the 1:1
brand.id === organization.idmodel. Landed in three reviewed phases (seeplans/for the full spec).Phase 1 — brand→org access foundation ✅
requireOrgAccess(userId, brandId)passed a brand id where an org id is expected, which only worked because of thebrand.id === organization.idbackfill (migration 0010). Access now resolves a brand to its owning org.evaluateBrandAccesspure policy (mirrorsevaluateOrgScope) + 5 unit tests, incl. the brand-absent (null) and multi-org cases.checkBrandAccess/requireBrandAccessinlib/auth/helpers.ts(single joined query).server/{brands,prompts,dashboard,citations,visibility,opportunities,query-fanout,analysis}.ts+ the prompt-settings route.brand.id === org.id.Phase 2 — umbrella provisioning + decoupled brand ids ✅
user.create.afterhook (provisionUmbrellaOrg, org id = random UUID), mirroring how local provisions itsdefaultorg.createBrandInOrgFn) with a server-generated, globally-unique brand slug (id !== organizationId). The org-per-brand minting paths are retired.$brandloader, brand switcher (/applists brands, not orgs), team functions, and accept-invitation resolve org throughbrands.organizationId.brand.id === org.iddata still resolves; the legacyneedsOnboardingbranch (whitelabel empty-org onboarding) is preserved.Phase 3 — activeOrganizationId as real org context + org naming ✅
session.activeOrganizationIdis now populated in the sharedcustomSession(stored value, else first membership) — the standing org context billing Stripe subscription billing via @better-auth/stripe #345 will use as its StripereferenceId.createBrandInOrgFnnow attaches to the active org (first-membership fallback), so users in more than one org — e.g. after accepting a team invite — can create brands again.updateOrganizationFn+ a Workspace-name field on the (org-level) team page; team copy is now workspace-scoped.Untouched (verified byte-for-byte across all phases): the public API (
/api/v1/brands/**),lib/auth/policies.ts(API-key auth + org-plugin block),onboarding-core.ts/ensureOrganizationcreate path (only aslugifyOrgName→slugifyrename, logic identical), whitelabel Auth0 sync,provisionLocalOrg, and the DB schema/migrations. No schema change.Verification (each phase, in an isolated worktree):
pnpm --filter @workspace/web check-types✅ · web tests 227 ✅ · lib tests 221 ✅.Out of scope (follow-ups): brand-scoped guest grants (#344), Stripe billing wiring (#345), checkout-first onboarding (#346), API/UI create unification (#405), and moving the team page to a top-level
/app/settingsroute.Closes #432.