ubiquitous-invention/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-multi-email-identity.md
Randall Stillwell 820dae6510 docs(plans): split workspace-invites convoy into identity + invites + autocomplete
User pushed back on "strict email match in v1" — the right architectural
answer is multi-email identity (one users row owning multiple verified
emails), not a stopgap. Scaling the convoy accordingly:

1. Task-multi-email-identity (NEW, P1, foundation)
   - user_email_identities table (user_id, email lowercased, verified_at,
     source: primary | oauth:<provider> | manual)
   - Refactor ensureUserIdByEmail -> ensureUserIdByVerifiedEmail against
     the new table.
   - OAuth callback writes a source='oauth:<provider>' identity when the
     provider returns email_verified=true. Cross-user conflict rejects.
   - Profile UI: "Linked emails" section, read-only in v1.
   - Exports userOwnsEmail(userId, emailLower) for invite accept to call.

2. Task-workspace-invites-and-roles (existing, narrowed)
   - All the original spec.
   - Accept procedure calls userOwnsEmail() instead of comparing
     users.email directly. Mismatch renders an explainer page, not a
     silent accept.

3. Task-invite-recipient-autocomplete (NEW, P1, polish)
   - invites.suggestRecipient returns typed suggestions across four
     kinds: member / pending_invite / known_user / new_email.
   - Tenancy fence on known_user is the security-relevant assertion;
     test for it explicitly.
   - Combobox UI renders each kind with its own affordance.

Three follow-ups filed explicitly to keep this convoy PR-sized:
- Task-manual-email-verification (add an email outside OAuth)
- Task-disconnect-linked-email (destructive, needs last-verified guard)
- Task-account-merge (handle the legacy duplicate-users case)

Epic file refreshed with the new task table, follow-up table, and a
phase ordering note. Identity lands first because it touches the
sign-in path; invites and autocomplete can ship in their own PRs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 10:02:38 -05:00

7 KiB

kind slug title plan_slug epic_slug status priority tenant_id owner cursor_todo_id updated_at
task multi-email-identity Multi-email identity on user profile (foundation for invite-by-email) multitenant-saas-hardening tenant-lifecycle ready P1 global unassigned null 2026-06-02

Task summary

Decouple application identity from a single users.email column. Add a user_email_identities table so one user can own multiple verified emails (primary + OAuth-claimed + later manual). Required so the invite-acceptance flow can correctly resolve "the human at alice@gmail.com" even when they're signed in via GitHub as alice@personal.

This is the foundation task for the invites convoy. Lands before Task-workspace-invites-and-roles because it changes the sign-in path that invite-accept depends on.

Why now (and why not later)

The current ensureUserIdByEmail in apps/web/lib/auth.ts collapses sign-in to a case-insensitive lookup on users.email. Concrete consequence: a user who signs up with alice@personal.com via GitHub today and then signs in with alice@gmail.com via Google tomorrow ends up as two separate users rows, neither of which is "the user." Every multitenant feature we layer on top — invites, audit log attribution, billing — would inherit this confusion.

Doing it right once is cheaper than dragging it for the next year.

Scope

Schema additions

New table in packages/database/src/schema/users.ts (kept colocated with users since they're logically the same identity surface):

export const userEmailIdentities = pgTable(
  "user_email_identities",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    userId: uuid("user_id")
      .notNull()
      .references(() => users.id, { onDelete: "cascade" }),
    email: varchar("email", { length: 255 }).notNull(), // stored lowercase
    verifiedAt: timestamp("verified_at", { withTimezone: true }),
    source: varchar("source", { length: 30 }).notNull(),
    // 'primary' | 'oauth:github' | 'oauth:google' | 'oauth:authentik' | 'manual'
    createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
    lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
  },
  (table) => ({
    userIdx: index("user_email_identities_user_id_idx").on(table.userId),
    emailIdx: index("user_email_identities_email_idx").on(table.email),
    userEmailUnique: uniqueIndex("user_email_identities_user_id_email_unique").on(
      table.userId,
      table.email,
    ),
    // A verified email belongs to exactly one user globally.
    verifiedEmailUnique: uniqueIndex("user_email_identities_verified_email_unique").on(
      table.email,
    ).where(sql`verified_at IS NOT NULL`),
  }),
);

Migration

Generate with pnpm db:generate. The migration must include a backfill step: insert one row per existing users row with source='primary', email=lower(users.email), verified_at=users.created_at (we trust existing rows because we minted them).

-- inside the generated migration, after CREATE TABLE
INSERT INTO user_email_identities (user_id, email, verified_at, source, created_at)
SELECT id, lower(email), created_at, 'primary', created_at FROM users
ON CONFLICT DO NOTHING;

Auth wiring

Refactor apps/web/lib/auth.ts:

  • Rename ensureUserIdByEmailensureUserIdByVerifiedEmail. Lookup hits user_email_identities WHERE email = lower(?) AND verified_at IS NOT NULL first. On miss, create a new user + a source='primary' identity in one transaction.
  • Add a callback hook (see research subagent recommendation — likely signIn or jwt) that, on OAuth sign-in where profile.email_verified === true, upserts a source='oauth:<provider>' identity for that user. If the email is already a verified identity belonging to a different user → reject the sign-in with a clear error.
  • Bump last_used_at on the identity row that actually authenticated this session.

Server-side helper

Export userOwnsEmail(userId: string, emailLower: string): Promise<boolean> from apps/web/server/lib/identity.ts (new file). Returns true iff user_email_identities has a row with that user + lowercased email + verified_at IS NOT NULL. This is the function Task-workspace-invites-and-roles will call.

Profile UI

Add a "Linked emails" section to the profile/settings page. Lists each identity with:

  • Email (lowercased display)
  • Source badge (Primary, GitHub, Google, Authentik, Manual)
  • Verified state (icon if verified, "Pending verification" otherwise)
  • Last used (relative time)

Read-only in this task. Adding a "Disconnect" button is Task-disconnect-linked-email (filed separately because it has destructive edge cases — disconnecting your last verified email locks you out).

Tests

Vitest is wired now, so add real assertions:

  • packages/database or apps/web (test runner can land in either): identity-table CRUD round-trip.
  • Sign-in callback writes a new identity on OAuth login.
  • Sign-in callback rejects when the OAuth email is verified for another user.
  • userOwnsEmail returns the right boolean across all four identity states (primary/oauth/manual/missing).

Subtasks

  • Add user_email_identities schema in packages/database/src/schema/users.ts.
  • Generate migration via pnpm db:generate, hand-augment with the backfill INSERT.
  • Refactor apps/web/lib/auth.ts: rename to ensureUserIdByVerifiedEmail, add OAuth identity upsert, add cross-user conflict rejection.
  • Create apps/web/server/lib/identity.ts with userOwnsEmail.
  • Add "Linked emails" read-only section on the profile/settings page.
  • Add vitest tests for userOwnsEmail + callback behavior.
  • Run pnpm lint && pnpm type-check && pnpm test clean.
  • Smoke test: sign out, sign back in via the same provider, verify only one identity row (no duplicates).

Owner or assignee

Unassigned

Status

ready

Estimation

M-L

Acceptance criteria

  • An existing user can sign out, sign back in via OAuth with a different verified email than their users.email, and end up resolved to the same users.id. Profile page lists both emails.
  • A second user attempting to sign in via OAuth with an email that's already verified on another user is rejected with a clear error.
  • userOwnsEmail(userId, emailLower) returns the correct boolean for all four cases (primary owned, oauth owned, manual owned, not owned).
  • Existing users have one primary identity row each after migration.
  • All three CI gates green.

Follow-ups explicitly NOT in scope

  • Task-manual-email-verification.md — type a new email, get a one-time code, verify.
  • Task-disconnect-linked-email.md — unlink with safety checks (last-verified protection).
  • Task-account-merge.md — merge two existing users who turn out to share an email.
  • Epic: ./Epic-tenant-lifecycle.md
  • Plan: ../Plan-multitenant-saas-hardening.md
  • Blocks: ./Task-workspace-invites-and-roles.md, ./Task-invite-recipient-autocomplete.md