Skip to content

feat(auth): allow multiple orgs to claim the same email domain#3447

Open
viktormarinho wants to merge 6 commits into
mainfrom
viktormarinho/multi-org-domain-link
Open

feat(auth): allow multiple orgs to claim the same email domain#3447
viktormarinho wants to merge 6 commits into
mainfrom
viktormarinho/multi-org-domain-link

Conversation

@viktormarinho
Copy link
Copy Markdown
Contributor

@viktormarinho viktormarinho commented May 22, 2026

What is this contribution about?

Drops the UNIQUE constraint on organization_domains.domain so several orgs can opt into auto-join from the same corporate domain — useful when different teams at the same company set up independent orgs. The onboarding flow now renders an org picker when the domain matches more than one org, and POST /api/auth/custom/domain-join takes an explicit organizationSlug to disambiguate (returns 409 requiresSelection when omitted with >1 eligible match). /domain-setup no longer rejects when another org has the same domain; the storage-layer 23505 unique-violation handler and the dead "domain race" cleanup in setup are removed.

How to Test

  1. Run migrations (bun run --cwd=apps/mesh migrate).
  2. As user A (a@acme.test), onboard and create org "Acme" with auto-join enabled — claims acme.test.
  3. As user B (b@acme.test), onboard and create a second org "Acme Labs" with auto-join enabled (previously blocked) — should succeed and claim acme.test alongside Acme.
  4. As user C (c@acme.test), hit /onboarding: should see the picker listing both orgs, and joining either one redirects to that org.

Migration Notes

091-organization-domains-allow-multi drops organization_domains_domain_key and adds a non-unique organization_domains_domain_idx for the by-domain lookup path. Reversible.

Review Checklist

  • PR title is clear and descriptive
  • Changes are tested and working (auth route tests pass; UI flow not exercised in a browser)
  • Documentation is updated (if needed)
  • No breaking changes (existing single-claim orgs continue to work)

Summary by cubic

Allow multiple organizations to claim the same email domain. Onboarding now shows a picker when more than one org matches, and auto-join requires a chosen org in that case.

  • New Features

    • Dropped UNIQUE on organization_domains.domain; added non-unique organization_domains_domain_idx.
    • GET /api/auth/custom/domain-lookup returns organizations: [] with all matches (id, name, slug, logo, autoJoinEnabled), including auto-join-disabled rows.
    • POST /api/auth/custom/domain-join accepts optional { organizationSlug }; joins the only eligible match when omitted, else returns 409 requiresSelection; rejects unverified/generic domains and disabled claims.
    • /api/auth/custom/domain-setup permits duplicate claims and redirects existing members to an org that already claims the domain.
    • Onboarding shows a multi-org picker and posts the slug; request now sets Content-Type: application/json.
  • Bug Fixes

    • Added Playwright e2e coverage for the multi-org picker; DB fixture honors DATABASE_URL in CI and falls back to local embedded Postgres (walks up CWD for .deco/services/postgres/state.json); teardown removes only membership rows to avoid FK errors.
    • Updated agent-model-popover test to use the "Haiku 4.5" label to fix CI.

Written for commit cf39ef0. Summary will update on new commits. Review in cubic

Drops the UNIQUE constraint on organization_domains.domain so several
orgs can opt into auto-join from the same corporate domain. The
onboarding flow now renders an org picker when more than one match is
returned, and /domain-join takes an explicit organizationSlug to
disambiguate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

🧪 Benchmark

Should we run the Virtual MCP strategy benchmark for this PR?

React with 👍 to run the benchmark.

Reaction Action
👍 Run quick benchmark (10 & 128 tools)

Benchmark will run on the next push after you react.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 22, 2026

Release Options

Suggested: Minor (2.344.0) — based on feat: prefix

React with an emoji to override the release type:

Reaction Type Next Version
👍 Prerelease 2.343.6-alpha.1
🎉 Patch 2.343.6
❤️ Minor 2.344.0
🚀 Major 3.0.0

Current version: 2.343.5

Note: If multiple reactions exist, the smallest bump wins. If no reactions, the suggested bump is used (default: patch).

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 7 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/mesh/migrations/091-organization-domains-allow-multi.ts">

<violation number="1" location="apps/mesh/migrations/091-organization-domains-allow-multi.ts:30">
P2: `down()` is not safely reversible after this migration is used: re-adding `UNIQUE (domain)` will fail when duplicate domains exist.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

.execute();
await sql`
ALTER TABLE organization_domains
ADD CONSTRAINT organization_domains_domain_key UNIQUE (domain)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: down() is not safely reversible after this migration is used: re-adding UNIQUE (domain) will fail when duplicate domains exist.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mesh/migrations/091-organization-domains-allow-multi.ts, line 30:

<comment>`down()` is not safely reversible after this migration is used: re-adding `UNIQUE (domain)` will fail when duplicate domains exist.</comment>

<file context>
@@ -0,0 +1,32 @@
+    .execute();
+  await sql`
+    ALTER TABLE organization_domains
+    ADD CONSTRAINT organization_domains_domain_key UNIQUE (domain)
+  `.execute(db);
+}
</file context>

viktormarinho and others added 3 commits May 22, 2026 15:42
PR #3445 renamed the Claude Code "Haiku" tier label to "Haiku 4.5" and
updated agent-models.test and agent-section.test but missed
agent-model-popover.test, leaving its getByText("Haiku") lookup failing
in CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds HTTP integration tests for the onboarding auto-join endpoints, with
explicit coverage for the new multi-org behavior:

- /domain-lookup returns ALL orgs that claim the same domain (the
  picker's input), and still surfaces auto-join-disabled rows so the
  caller decides per row.
- /domain-join joins the only eligible match when slug is omitted;
  returns 409 requiresSelection when 2+ eligible matches exist; honors
  a caller-picked slug and rejects slugs that don't claim the domain
  or have auto-join disabled.

Stubs auth.api.addMember with a direct member-table insert so the tests
can assert membership without wiring the full Better Auth adapter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the previous HTTP-integration tests (heavy on mocks) with a
Playwright spec that drives the multi-org onboarding picker against a
live dev server.

The dev server's embedded postgres listens on a dynamic port written to
<home>/services/postgres/state.json. A new fixture (e2e/fixtures/db.ts)
discovers that file across the data-dir defaults the CLI and `deco
services` commands each use, then opens a real `pg.Client`.

The spec seeds two orgs that both claim `acme-e2e-<ts>.test` with
auto-join enabled, signs up a fresh user through the actual signup
form, flips their `emailVerified` flag + tears down the auto-created
org via SQL (the parts the UI cannot reach without OTP/magic-link
flows), then drives /onboarding through the real DOM: asserts the
multi-org heading, both org cards, two Join buttons, and that clicking
one persists the correct `member` row while leaving the other empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/mesh/migrations/091-organization-domains-allow-multi.ts">

<violation number="1" location="apps/mesh/migrations/091-organization-domains-allow-multi.ts:30">
P2: `down()` is not safely reversible after this migration is used: re-adding `UNIQUE (domain)` will fail when duplicate domains exist.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread apps/mesh/e2e/fixtures/db.ts Outdated
viktormarinho and others added 2 commits May 22, 2026 16:36
Fixes two issues uncovered by running the new spec against a live dev
server:

1. The fixture only checked `process.cwd()/.deco` — Playwright runs
   from apps/mesh, but the dev server (started from repo root) writes
   state.json into the repo-root .deco. Now walks up four levels from
   CWD looking for a state.json that exists, before falling back to
   ~/deco.

2. The teardown tried to DELETE the auto-created org after stripping
   memberships, but the org has RESTRICT-FK children (seeded
   connection_aggregations etc.) that block the delete. Removing the
   user's membership rows is enough on its own — authClient.organization.list()
   only returns orgs the caller is a member of, so the orphaned org
   doesn't show up in the redirect logic.

Verified: spec passes (1 passed, 4.7s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The e2e workflow boots a real postgres:16 service on :5432 and sets
DATABASE_URL — there is no embedded-postgres state.json on CI, so the
fixture's discovery walk failed every time.

Now the fixture prefers DATABASE_URL when set (CI path) and only falls
back to the state.json scan for local dev. Local run still passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@viktormarinho viktormarinho force-pushed the viktormarinho/multi-org-domain-link branch from 744cca3 to cf39ef0 Compare May 22, 2026 20:20
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