Skip to content

Umbrella organization model (#432): brand→org access foundation#461

Open
jrhizor wants to merge 12 commits into
mainfrom
jrhizor/brand-access-helper
Open

Umbrella organization model (#432): brand→org access foundation#461
jrhizor wants to merge 12 commits into
mainfrom
jrhizor/brand-access-helper

Conversation

@jrhizor

@jrhizor jrhizor commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Implements the umbrella-organization model from #432: one customer organization owning many brands, replacing the 1:1 brand.id === organization.id model. Landed in three reviewed phases (see plans/ 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 the brand.id === organization.id backfill (migration 0010). Access now resolves a brand to its owning org.

  • New evaluateBrandAccess pure policy (mirrors evaluateOrgScope) + 5 unit tests, incl. the brand-absent (null) and multi-org cases.
  • checkBrandAccess / requireBrandAccess in lib/auth/helpers.ts (single joined query).
  • 22 read/mutate call sites migrated across server/{brands,prompts,dashboard,citations,visibility,opportunities,query-fanout,analysis}.ts + the prompt-settings route.
  • Behavior-preserving while brand.id === org.id.

Phase 2 — umbrella provisioning + decoupled brand ids ✅

  • Cloud creates the customer org at signup via a user.create.after hook (provisionUmbrellaOrg, org id = random UUID), mirroring how local provisions its default org.
  • Create-brand attaches to the caller's org (createBrandInOrgFn) with a server-generated, globally-unique brand slug (id !== organizationId). The org-per-brand minting paths are retired.
  • $brand loader, brand switcher (/app lists brands, not orgs), team functions, and accept-invitation resolve org through brands.organizationId.
  • Backward-compatible: existing brand.id === org.id data still resolves; the legacy needsOnboarding branch (whitelabel empty-org onboarding) is preserved.

Phase 3 — activeOrganizationId as real org context + org naming ✅

  • session.activeOrganizationId is now populated in the shared customSession (stored value, else first membership) — the standing org context billing Stripe subscription billing via @better-auth/stripe #345 will use as its Stripe referenceId.
  • Removed Phase 2's single-org limitation: createBrandInOrgFn now 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.
  • Workspace rename: admin-gated 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/ensureOrganization create path (only a slugifyOrgNameslugify rename, 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/settings route.

Closes #432.

jrhizor added 2 commits July 15, 2026 18:59
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.
@vercel

vercel Bot commented Jul 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
elmo Ready Ready Preview, Comment Jul 17, 2026 4:17am

Request Review

jrhizor added 6 commits July 15, 2026 23:08
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.
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.

Umbrella organization model: one customer org containing many brands

1 participant