ubiquitous-invention/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-multi-email-identity.md

160 lines
10 KiB
Markdown
Raw Normal View History

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 11:02:38 -04:00
---
kind: task
slug: multi-email-identity
title: Multi-email identity on user profile (foundation for invite-by-email)
plan_slug: multitenant-saas-hardening
epic_slug: tenant-lifecycle
feat(identity): OAuth-aware sign-in writes accounts + identity rows (Task 1, part 2/2) Closes Task-multi-email-identity. Rebuilds the OAuth half of the jwt callback around the new user_email_identities table AND the existing (but until-now empty) accounts table, with per-provider email_verified resolution and a cross-user conflict guard. Credentials sign-in path is unchanged. Per the OAuth research subagent: NextAuth has no adapter configured, so the accounts table has been sitting empty since this app started. Rather than leave it that way, the new resolveOAuthUser helper writes to it on every OAuth sign-in. (provider, providerAccountId) is now the canonical "this OAuth identity belongs to this user" record and gives us a fast path that doesn't depend on email matching. Sign-in resolution order for an OAuth account: 1. Lookup accounts by (provider, providerAccountId). Hit -> bump last_used_at on the matching identity row, return user_id. 2. Lookup user_email_identities by (email, verified_at IS NOT NULL). Hit AND the owner has zero existing OAuth accounts -> link this new OAuth account to that user (covers "Credentials user adds their first OAuth provider"). Insert a fresh accounts row. Hit AND the owner already has an OAuth account -> REFUSE. Returning a token without an id field denies the session; the user lands on NextAuth's error page. (This is the "Bob's GitHub claims alice's verified email" rejection.) 3. Fall back to legacy users.email match. Hit -> link to that user (covers users created before migration 0005). 4. Otherwise mint a new users row + a source='primary' identity in the identities table, then write the accounts row. The verified identity row is upserted only when the provider's email_verified claim is true. The new resolveOAuthEmailVerified helper: - Google + Authentik: read profile.email_verified directly (the Auth.js v5 jwt callback receives `profile` on the sign-in trigger). Authentik caveat documented inline: since the 2025.10 release the claim defaults to false unless an admin adds a custom property mapping. - GitHub: GitHubProfile does not expose the claim. We GET /user/emails with the OAuth access_token and read `verified` on the entry matching the primary email. Failure to fetch (rate limit, network) is treated as unverified. What's intentionally not in this commit: - Vitest tests for the callback logic. apps/web has no vitest config yet (the test foundation only wired up the packages). Filed a follow-up: Task-bootstrap-vitest-for-apps-web.md (P2) under Epic-test-foundation. The auth-callback assertions will land against that harness when it's stood up. - Race-condition transaction isolation. The current sequence (account lookup -> identity lookup -> account/identity upsert) has the same race window the old ensureUserIdByEmail had — two simultaneous OAuth sign-ins for a brand-new email could both pass the identity check before either INSERT fires. Mitigated in practice by the partial unique on email WHERE verified_at IS NOT NULL — postgres will reject the second insert — but the loser gets an opaque error. Filed as a follow-up if it becomes a real issue. Task file (plans/.../Task-multi-email-identity.md) updated with the detailed smoke-test playbook an operator needs to run before the OAuth path goes to production (sign in fresh, sign in repeat, sign in cross-provider, sign in cross-user-conflict). admin@tasks.dev signs in via Credentials so the dev fixtures alone do not exercise this code. Lint + type-check + test all green (14/14 tests, 0 lint errors, 14 unchanged warnings, 6/6 packages type-check). Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:14:16 -04:00
status: done
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 11:02:38 -04:00
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "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):
```ts
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).
```sql
-- 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 `ensureUserIdByEmail``ensureUserIdByVerifiedEmail`. 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
feat(identity): OAuth-aware sign-in writes accounts + identity rows (Task 1, part 2/2) Closes Task-multi-email-identity. Rebuilds the OAuth half of the jwt callback around the new user_email_identities table AND the existing (but until-now empty) accounts table, with per-provider email_verified resolution and a cross-user conflict guard. Credentials sign-in path is unchanged. Per the OAuth research subagent: NextAuth has no adapter configured, so the accounts table has been sitting empty since this app started. Rather than leave it that way, the new resolveOAuthUser helper writes to it on every OAuth sign-in. (provider, providerAccountId) is now the canonical "this OAuth identity belongs to this user" record and gives us a fast path that doesn't depend on email matching. Sign-in resolution order for an OAuth account: 1. Lookup accounts by (provider, providerAccountId). Hit -> bump last_used_at on the matching identity row, return user_id. 2. Lookup user_email_identities by (email, verified_at IS NOT NULL). Hit AND the owner has zero existing OAuth accounts -> link this new OAuth account to that user (covers "Credentials user adds their first OAuth provider"). Insert a fresh accounts row. Hit AND the owner already has an OAuth account -> REFUSE. Returning a token without an id field denies the session; the user lands on NextAuth's error page. (This is the "Bob's GitHub claims alice's verified email" rejection.) 3. Fall back to legacy users.email match. Hit -> link to that user (covers users created before migration 0005). 4. Otherwise mint a new users row + a source='primary' identity in the identities table, then write the accounts row. The verified identity row is upserted only when the provider's email_verified claim is true. The new resolveOAuthEmailVerified helper: - Google + Authentik: read profile.email_verified directly (the Auth.js v5 jwt callback receives `profile` on the sign-in trigger). Authentik caveat documented inline: since the 2025.10 release the claim defaults to false unless an admin adds a custom property mapping. - GitHub: GitHubProfile does not expose the claim. We GET /user/emails with the OAuth access_token and read `verified` on the entry matching the primary email. Failure to fetch (rate limit, network) is treated as unverified. What's intentionally not in this commit: - Vitest tests for the callback logic. apps/web has no vitest config yet (the test foundation only wired up the packages). Filed a follow-up: Task-bootstrap-vitest-for-apps-web.md (P2) under Epic-test-foundation. The auth-callback assertions will land against that harness when it's stood up. - Race-condition transaction isolation. The current sequence (account lookup -> identity lookup -> account/identity upsert) has the same race window the old ensureUserIdByEmail had — two simultaneous OAuth sign-ins for a brand-new email could both pass the identity check before either INSERT fires. Mitigated in practice by the partial unique on email WHERE verified_at IS NOT NULL — postgres will reject the second insert — but the loser gets an opaque error. Filed as a follow-up if it becomes a real issue. Task file (plans/.../Task-multi-email-identity.md) updated with the detailed smoke-test playbook an operator needs to run before the OAuth path goes to production (sign in fresh, sign in repeat, sign in cross-provider, sign in cross-user-conflict). admin@tasks.dev signs in via Credentials so the dev fixtures alone do not exercise this code. Lint + type-check + test all green (14/14 tests, 0 lint errors, 14 unchanged warnings, 6/6 packages type-check). Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:14:16 -04:00
- [x] Added `user_email_identities` schema in `packages/database/src/schema/users.ts` with full inline documentation of source values and constraint semantics.
- [x] Generated migration `0005_cooing_midnight.sql` via `pnpm db:generate`, augmented with the backfill `INSERT` (using `ON CONFLICT (user_id, email)` — Drizzle generates unique indexes, not named constraints, so column-based conflict targets are required). Applied to dev DB; existing `admin@tasks.dev` row now has a `primary` identity with `verified_at = users.created_at`.
- [x] Created `apps/web/server/lib/identity.ts` exporting `userOwnsEmail(userId, email)` and `findUserIdByVerifiedEmail(email)`. Both are pure read queries that the invite-accept procedure (Task 2) will call.
- [x] Refactored `apps/web/lib/auth.ts`. **Substantial change** — see the commit body for the security model. Key differences from the original task spec:
- **Actually uses the existing `accounts` table**, which the OAuth-research subagent identified as vestigial. `(provider, providerAccountId)` is now the canonical "this OAuth identity belongs to this user" record. Repeat sign-ins use a fast-path lookup; new sign-ins go through the identity-table fallback.
- **Per-provider `email_verified` resolution** via a new `resolveOAuthEmailVerified` helper. Google and Authentik read directly from `profile.email_verified`. GitHub does not expose the claim, so we make a `GET /user/emails` call with the OAuth access token and read `verified` on the entry matching the primary email. Authentik post-2025.10 caveat (default `false` unless an admin adds a custom property mapping) is documented inline.
- **Cross-user conflict path**: if a verified identity for the OAuth-claimed email already belongs to another user who already has at least one OAuth account linked, we refuse to silently re-link. The JWT returns without an `id` field, which causes the session to be unauthenticated and the user lands on the NextAuth error page.
- [x] Added a tRPC procedure `identity.listMine` and a read-only "Linked emails" section at `/<workspaceSlug>/settings/profile` that lists each identity with email, source badge, verified state, and last-used relative time.
- [ ] **Deferred: vitest tests for the auth callback.** `apps/web` does not have vitest configured yet (the test foundation in `Plan-multitenant-saas-hardening/Epic-test-foundation` only wired it up for `packages/*`). Setting up vitest for a Next.js app — alias resolution for `@/`, environment for server modules, optional JSDOM — is its own task. Filed as `Task-bootstrap-vitest-for-apps-web.md` P2. The auth-callback assertions (identity write on OAuth, cross-user conflict rejection, no-duplicate-write on repeat sign-in) belong against that harness when it lands. Until then, smoke testing is the regression net.
- [x] `pnpm lint && pnpm type-check && pnpm test` all clean (14 lint warnings unchanged from pre-Task-1 baseline; 6/6 type-check; 14/14 tests).
## Smoke test (manual, recommended before any OAuth provider goes to production)
The OAuth path is not exercised by any of the dev fixtures (only `admin@tasks.dev` exists, which signs in via Credentials). To verify the new code path end-to-end, an operator with a configured OAuth provider should:
1. Sign in via OAuth as a fresh account (no existing `users` row). Confirm: new `users` row, new `user_email_identities` row with `source='oauth:<provider>'` and `verified_at != null`, new `accounts` row with the matching `(provider, providerAccountId)`.
2. Sign out, sign back in as the same OAuth account. Confirm: no new rows; `user_email_identities.last_used_at` bumped.
3. With the same email, attempt to sign in via a *different* OAuth provider. Confirm: a second `accounts` row is written; the existing identity row gets `last_used_at` bumped (no new identity row because the email is the same).
4. From a second browser/incognito, attempt to sign in via a third OAuth account claiming the same verified email. Confirm: sign-in is refused, no rows are added.
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 11:02:38 -04:00
## Owner or assignee
Unassigned
## Status
feat(identity): OAuth-aware sign-in writes accounts + identity rows (Task 1, part 2/2) Closes Task-multi-email-identity. Rebuilds the OAuth half of the jwt callback around the new user_email_identities table AND the existing (but until-now empty) accounts table, with per-provider email_verified resolution and a cross-user conflict guard. Credentials sign-in path is unchanged. Per the OAuth research subagent: NextAuth has no adapter configured, so the accounts table has been sitting empty since this app started. Rather than leave it that way, the new resolveOAuthUser helper writes to it on every OAuth sign-in. (provider, providerAccountId) is now the canonical "this OAuth identity belongs to this user" record and gives us a fast path that doesn't depend on email matching. Sign-in resolution order for an OAuth account: 1. Lookup accounts by (provider, providerAccountId). Hit -> bump last_used_at on the matching identity row, return user_id. 2. Lookup user_email_identities by (email, verified_at IS NOT NULL). Hit AND the owner has zero existing OAuth accounts -> link this new OAuth account to that user (covers "Credentials user adds their first OAuth provider"). Insert a fresh accounts row. Hit AND the owner already has an OAuth account -> REFUSE. Returning a token without an id field denies the session; the user lands on NextAuth's error page. (This is the "Bob's GitHub claims alice's verified email" rejection.) 3. Fall back to legacy users.email match. Hit -> link to that user (covers users created before migration 0005). 4. Otherwise mint a new users row + a source='primary' identity in the identities table, then write the accounts row. The verified identity row is upserted only when the provider's email_verified claim is true. The new resolveOAuthEmailVerified helper: - Google + Authentik: read profile.email_verified directly (the Auth.js v5 jwt callback receives `profile` on the sign-in trigger). Authentik caveat documented inline: since the 2025.10 release the claim defaults to false unless an admin adds a custom property mapping. - GitHub: GitHubProfile does not expose the claim. We GET /user/emails with the OAuth access_token and read `verified` on the entry matching the primary email. Failure to fetch (rate limit, network) is treated as unverified. What's intentionally not in this commit: - Vitest tests for the callback logic. apps/web has no vitest config yet (the test foundation only wired up the packages). Filed a follow-up: Task-bootstrap-vitest-for-apps-web.md (P2) under Epic-test-foundation. The auth-callback assertions will land against that harness when it's stood up. - Race-condition transaction isolation. The current sequence (account lookup -> identity lookup -> account/identity upsert) has the same race window the old ensureUserIdByEmail had — two simultaneous OAuth sign-ins for a brand-new email could both pass the identity check before either INSERT fires. Mitigated in practice by the partial unique on email WHERE verified_at IS NOT NULL — postgres will reject the second insert — but the loser gets an opaque error. Filed as a follow-up if it becomes a real issue. Task file (plans/.../Task-multi-email-identity.md) updated with the detailed smoke-test playbook an operator needs to run before the OAuth path goes to production (sign in fresh, sign in repeat, sign in cross-provider, sign in cross-user-conflict). admin@tasks.dev signs in via Credentials so the dev fixtures alone do not exercise this code. Lint + type-check + test all green (14/14 tests, 0 lint errors, 14 unchanged warnings, 6/6 packages type-check). Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:14:16 -04:00
done
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 11:02:38 -04:00
## Estimation
feat(identity): OAuth-aware sign-in writes accounts + identity rows (Task 1, part 2/2) Closes Task-multi-email-identity. Rebuilds the OAuth half of the jwt callback around the new user_email_identities table AND the existing (but until-now empty) accounts table, with per-provider email_verified resolution and a cross-user conflict guard. Credentials sign-in path is unchanged. Per the OAuth research subagent: NextAuth has no adapter configured, so the accounts table has been sitting empty since this app started. Rather than leave it that way, the new resolveOAuthUser helper writes to it on every OAuth sign-in. (provider, providerAccountId) is now the canonical "this OAuth identity belongs to this user" record and gives us a fast path that doesn't depend on email matching. Sign-in resolution order for an OAuth account: 1. Lookup accounts by (provider, providerAccountId). Hit -> bump last_used_at on the matching identity row, return user_id. 2. Lookup user_email_identities by (email, verified_at IS NOT NULL). Hit AND the owner has zero existing OAuth accounts -> link this new OAuth account to that user (covers "Credentials user adds their first OAuth provider"). Insert a fresh accounts row. Hit AND the owner already has an OAuth account -> REFUSE. Returning a token without an id field denies the session; the user lands on NextAuth's error page. (This is the "Bob's GitHub claims alice's verified email" rejection.) 3. Fall back to legacy users.email match. Hit -> link to that user (covers users created before migration 0005). 4. Otherwise mint a new users row + a source='primary' identity in the identities table, then write the accounts row. The verified identity row is upserted only when the provider's email_verified claim is true. The new resolveOAuthEmailVerified helper: - Google + Authentik: read profile.email_verified directly (the Auth.js v5 jwt callback receives `profile` on the sign-in trigger). Authentik caveat documented inline: since the 2025.10 release the claim defaults to false unless an admin adds a custom property mapping. - GitHub: GitHubProfile does not expose the claim. We GET /user/emails with the OAuth access_token and read `verified` on the entry matching the primary email. Failure to fetch (rate limit, network) is treated as unverified. What's intentionally not in this commit: - Vitest tests for the callback logic. apps/web has no vitest config yet (the test foundation only wired up the packages). Filed a follow-up: Task-bootstrap-vitest-for-apps-web.md (P2) under Epic-test-foundation. The auth-callback assertions will land against that harness when it's stood up. - Race-condition transaction isolation. The current sequence (account lookup -> identity lookup -> account/identity upsert) has the same race window the old ensureUserIdByEmail had — two simultaneous OAuth sign-ins for a brand-new email could both pass the identity check before either INSERT fires. Mitigated in practice by the partial unique on email WHERE verified_at IS NOT NULL — postgres will reject the second insert — but the loser gets an opaque error. Filed as a follow-up if it becomes a real issue. Task file (plans/.../Task-multi-email-identity.md) updated with the detailed smoke-test playbook an operator needs to run before the OAuth path goes to production (sign in fresh, sign in repeat, sign in cross-provider, sign in cross-user-conflict). admin@tasks.dev signs in via Credentials so the dev fixtures alone do not exercise this code. Lint + type-check + test all green (14/14 tests, 0 lint errors, 14 unchanged warnings, 6/6 packages type-check). Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:14:16 -04:00
M-L (came in around the upper end)
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 11:02:38 -04:00
## Acceptance criteria
feat(identity): OAuth-aware sign-in writes accounts + identity rows (Task 1, part 2/2) Closes Task-multi-email-identity. Rebuilds the OAuth half of the jwt callback around the new user_email_identities table AND the existing (but until-now empty) accounts table, with per-provider email_verified resolution and a cross-user conflict guard. Credentials sign-in path is unchanged. Per the OAuth research subagent: NextAuth has no adapter configured, so the accounts table has been sitting empty since this app started. Rather than leave it that way, the new resolveOAuthUser helper writes to it on every OAuth sign-in. (provider, providerAccountId) is now the canonical "this OAuth identity belongs to this user" record and gives us a fast path that doesn't depend on email matching. Sign-in resolution order for an OAuth account: 1. Lookup accounts by (provider, providerAccountId). Hit -> bump last_used_at on the matching identity row, return user_id. 2. Lookup user_email_identities by (email, verified_at IS NOT NULL). Hit AND the owner has zero existing OAuth accounts -> link this new OAuth account to that user (covers "Credentials user adds their first OAuth provider"). Insert a fresh accounts row. Hit AND the owner already has an OAuth account -> REFUSE. Returning a token without an id field denies the session; the user lands on NextAuth's error page. (This is the "Bob's GitHub claims alice's verified email" rejection.) 3. Fall back to legacy users.email match. Hit -> link to that user (covers users created before migration 0005). 4. Otherwise mint a new users row + a source='primary' identity in the identities table, then write the accounts row. The verified identity row is upserted only when the provider's email_verified claim is true. The new resolveOAuthEmailVerified helper: - Google + Authentik: read profile.email_verified directly (the Auth.js v5 jwt callback receives `profile` on the sign-in trigger). Authentik caveat documented inline: since the 2025.10 release the claim defaults to false unless an admin adds a custom property mapping. - GitHub: GitHubProfile does not expose the claim. We GET /user/emails with the OAuth access_token and read `verified` on the entry matching the primary email. Failure to fetch (rate limit, network) is treated as unverified. What's intentionally not in this commit: - Vitest tests for the callback logic. apps/web has no vitest config yet (the test foundation only wired up the packages). Filed a follow-up: Task-bootstrap-vitest-for-apps-web.md (P2) under Epic-test-foundation. The auth-callback assertions will land against that harness when it's stood up. - Race-condition transaction isolation. The current sequence (account lookup -> identity lookup -> account/identity upsert) has the same race window the old ensureUserIdByEmail had — two simultaneous OAuth sign-ins for a brand-new email could both pass the identity check before either INSERT fires. Mitigated in practice by the partial unique on email WHERE verified_at IS NOT NULL — postgres will reject the second insert — but the loser gets an opaque error. Filed as a follow-up if it becomes a real issue. Task file (plans/.../Task-multi-email-identity.md) updated with the detailed smoke-test playbook an operator needs to run before the OAuth path goes to production (sign in fresh, sign in repeat, sign in cross-provider, sign in cross-user-conflict). admin@tasks.dev signs in via Credentials so the dev fixtures alone do not exercise this code. Lint + type-check + test all green (14/14 tests, 0 lint errors, 14 unchanged warnings, 6/6 packages type-check). Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:14:16 -04:00
- [x] Existing users have one `primary` identity row each after migration. Verified via `psql` against the dev DB.
- [x] `userOwnsEmail(userId, emailLower)` is exported and correctly filters on `verified_at IS NOT NULL`.
- [x] All three CI gates green (lint, type-check, test).
- [ ] **Operator-verified**: cross-OAuth-provider linking works (verified email matches existing user, new `accounts` row written, no duplicate identity row). Pending manual smoke test against a configured OAuth provider.
- [ ] **Operator-verified**: cross-user email conflict is rejected. Same.
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 11:02:38 -04:00
feat(identity): OAuth-aware sign-in writes accounts + identity rows (Task 1, part 2/2) Closes Task-multi-email-identity. Rebuilds the OAuth half of the jwt callback around the new user_email_identities table AND the existing (but until-now empty) accounts table, with per-provider email_verified resolution and a cross-user conflict guard. Credentials sign-in path is unchanged. Per the OAuth research subagent: NextAuth has no adapter configured, so the accounts table has been sitting empty since this app started. Rather than leave it that way, the new resolveOAuthUser helper writes to it on every OAuth sign-in. (provider, providerAccountId) is now the canonical "this OAuth identity belongs to this user" record and gives us a fast path that doesn't depend on email matching. Sign-in resolution order for an OAuth account: 1. Lookup accounts by (provider, providerAccountId). Hit -> bump last_used_at on the matching identity row, return user_id. 2. Lookup user_email_identities by (email, verified_at IS NOT NULL). Hit AND the owner has zero existing OAuth accounts -> link this new OAuth account to that user (covers "Credentials user adds their first OAuth provider"). Insert a fresh accounts row. Hit AND the owner already has an OAuth account -> REFUSE. Returning a token without an id field denies the session; the user lands on NextAuth's error page. (This is the "Bob's GitHub claims alice's verified email" rejection.) 3. Fall back to legacy users.email match. Hit -> link to that user (covers users created before migration 0005). 4. Otherwise mint a new users row + a source='primary' identity in the identities table, then write the accounts row. The verified identity row is upserted only when the provider's email_verified claim is true. The new resolveOAuthEmailVerified helper: - Google + Authentik: read profile.email_verified directly (the Auth.js v5 jwt callback receives `profile` on the sign-in trigger). Authentik caveat documented inline: since the 2025.10 release the claim defaults to false unless an admin adds a custom property mapping. - GitHub: GitHubProfile does not expose the claim. We GET /user/emails with the OAuth access_token and read `verified` on the entry matching the primary email. Failure to fetch (rate limit, network) is treated as unverified. What's intentionally not in this commit: - Vitest tests for the callback logic. apps/web has no vitest config yet (the test foundation only wired up the packages). Filed a follow-up: Task-bootstrap-vitest-for-apps-web.md (P2) under Epic-test-foundation. The auth-callback assertions will land against that harness when it's stood up. - Race-condition transaction isolation. The current sequence (account lookup -> identity lookup -> account/identity upsert) has the same race window the old ensureUserIdByEmail had — two simultaneous OAuth sign-ins for a brand-new email could both pass the identity check before either INSERT fires. Mitigated in practice by the partial unique on email WHERE verified_at IS NOT NULL — postgres will reject the second insert — but the loser gets an opaque error. Filed as a follow-up if it becomes a real issue. Task file (plans/.../Task-multi-email-identity.md) updated with the detailed smoke-test playbook an operator needs to run before the OAuth path goes to production (sign in fresh, sign in repeat, sign in cross-provider, sign in cross-user-conflict). admin@tasks.dev signs in via Credentials so the dev fixtures alone do not exercise this code. Lint + type-check + test all green (14/14 tests, 0 lint errors, 14 unchanged warnings, 6/6 packages type-check). Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:14:16 -04:00
## Follow-ups filed
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 11:02:38 -04:00
- `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.
feat(identity): OAuth-aware sign-in writes accounts + identity rows (Task 1, part 2/2) Closes Task-multi-email-identity. Rebuilds the OAuth half of the jwt callback around the new user_email_identities table AND the existing (but until-now empty) accounts table, with per-provider email_verified resolution and a cross-user conflict guard. Credentials sign-in path is unchanged. Per the OAuth research subagent: NextAuth has no adapter configured, so the accounts table has been sitting empty since this app started. Rather than leave it that way, the new resolveOAuthUser helper writes to it on every OAuth sign-in. (provider, providerAccountId) is now the canonical "this OAuth identity belongs to this user" record and gives us a fast path that doesn't depend on email matching. Sign-in resolution order for an OAuth account: 1. Lookup accounts by (provider, providerAccountId). Hit -> bump last_used_at on the matching identity row, return user_id. 2. Lookup user_email_identities by (email, verified_at IS NOT NULL). Hit AND the owner has zero existing OAuth accounts -> link this new OAuth account to that user (covers "Credentials user adds their first OAuth provider"). Insert a fresh accounts row. Hit AND the owner already has an OAuth account -> REFUSE. Returning a token without an id field denies the session; the user lands on NextAuth's error page. (This is the "Bob's GitHub claims alice's verified email" rejection.) 3. Fall back to legacy users.email match. Hit -> link to that user (covers users created before migration 0005). 4. Otherwise mint a new users row + a source='primary' identity in the identities table, then write the accounts row. The verified identity row is upserted only when the provider's email_verified claim is true. The new resolveOAuthEmailVerified helper: - Google + Authentik: read profile.email_verified directly (the Auth.js v5 jwt callback receives `profile` on the sign-in trigger). Authentik caveat documented inline: since the 2025.10 release the claim defaults to false unless an admin adds a custom property mapping. - GitHub: GitHubProfile does not expose the claim. We GET /user/emails with the OAuth access_token and read `verified` on the entry matching the primary email. Failure to fetch (rate limit, network) is treated as unverified. What's intentionally not in this commit: - Vitest tests for the callback logic. apps/web has no vitest config yet (the test foundation only wired up the packages). Filed a follow-up: Task-bootstrap-vitest-for-apps-web.md (P2) under Epic-test-foundation. The auth-callback assertions will land against that harness when it's stood up. - Race-condition transaction isolation. The current sequence (account lookup -> identity lookup -> account/identity upsert) has the same race window the old ensureUserIdByEmail had — two simultaneous OAuth sign-ins for a brand-new email could both pass the identity check before either INSERT fires. Mitigated in practice by the partial unique on email WHERE verified_at IS NOT NULL — postgres will reject the second insert — but the loser gets an opaque error. Filed as a follow-up if it becomes a real issue. Task file (plans/.../Task-multi-email-identity.md) updated with the detailed smoke-test playbook an operator needs to run before the OAuth path goes to production (sign in fresh, sign in repeat, sign in cross-provider, sign in cross-user-conflict). admin@tasks.dev signs in via Credentials so the dev fixtures alone do not exercise this code. Lint + type-check + test all green (14/14 tests, 0 lint errors, 14 unchanged warnings, 6/6 packages type-check). Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:14:16 -04:00
- `Task-bootstrap-vitest-for-apps-web.md` — set up Vitest for `apps/web` so the auth-callback assertions and identity-helper integration tests can have a home. New, P2.
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 11:02:38 -04:00
## Links to related Epic / Plan
- Epic: `./Epic-tenant-lifecycle.md`
- Plan: `../Plan-multitenant-saas-hardening.md`
- Blocks: `./Task-workspace-invites-and-roles.md`, `./Task-invite-recipient-autocomplete.md`