Google OAuth as a Third Identity Provider — Plus Link-While-Logged-In and Header Logout
Brought Google up to parity with GitHub and LinkedIn as a peer identity provider for the Agentic VC Dojo. Sign in with any one — including Workspace emails, which is the dominant login surface for fund operators who don't think of themselves as having a developer account. Adds the link-while-logged-in flow (callbacks now read the existing session cookie before overwriting it, so clicking a second provider attaches it to your existing User row instead of minting a separate identity) and a Log out row in the header tooltip so signing out doesn't require navigating to /me. Strategic shift: each provider gives different signal about the same person — GitHub = building behavior, LinkedIn = professional identity, Google = domain affiliation + lowest-friction VC sign-in — so connecting more than one isn't redundancy, it's triangulation.
Why this matters
The April 29 launch session captured zero participant votes despite seven polls being prepped and the OAuth gate working correctly. The most likely root cause we could ship a fix for was provider friction: the only ways through the gate were GitHub and LinkedIn, and most VC operators don’t have GitHub accounts (developer demographic) and rarely think to sign in with LinkedIn (they browse to it).
Every fund operator has a Google Workspace email for their firm. One click, zero new accounts. Adding Google as a third peer provider — not a replacement, not privileged — gives the dominant VC demographic a low-friction path through the auth gate before the May 27 Monthly All-Hands runs three live polls.
But this isn’t just “add Google for VCs.” The strategic shift is treating the three providers as different signal vectors on the same person:
| Provider | What it tells us |
|---|---|
| GitHub | Building behavior — repos, languages, commit cadence. Whether this VC is a builder or a watcher. Required for any stack-write flow that uses the GitHub App bot. |
| Professional identity — firm, role, work history, network. The legible-to-LPs view. Highest fidelity for roster + people-page cross-referencing. | |
| Google Workspace | Domain affiliation (hd claim → which Workspace org), verified firm email, lowest-friction sign-in for fund operators. Future hooks for calendar / drive integrations. |
Connecting all three turns a User row from “one identity vector” into a triangulated person — useful for the Dojo’s own intelligence (who’s actually shipping AI workflows at their firm), useful for member-to-member discovery, and useful for future Workspace-aware features. The header now nudges toward this with updated copy: “We get different signal from each — connect more for richer Dojo discovery.”
What landed
1. Google OAuth, end to end
The cleanest pattern in the auth code was LinkedIn’s (email-keyed, OIDC), so Google rides the same shape with one new route pair and a schema addition:
src/pages/api/auth/google/login.ts— OIDC authorize redirect tohttps://accounts.google.com/o/oauth2/v2/authwithscope=openid email profileandprompt=select_account(forces account chooser on shared machines).src/pages/api/auth/google/callback.ts— token exchange atoauth2.googleapis.com/token, userinfo atopenidconnect.googleapis.com/v1/userinfo,email_verifiedguard that refuses to mint a session if Google flags the email as unverified (defense in depth for the email-as-canonical-key invariant), roster passthrough match, User upsert, session JWT mint.db/config.ts—User.google_subcolumn + unique index parallel togithub_handleandlinkedin_sub. Additive, zero-downtime, pushed to Turso viaastro db push --remote.src/lib/session.ts—SessionPayload.providerwidened to'github' | 'linkedin' | 'google'with runtime validation rejecting unknown providers.src/lib/oauth-roster.ts— Google branch matches by email (same as LinkedIn). Roster stays a passthrough (synthesizes an entry for non-roster signers), so no one gets blocked.src/lib/user-record.ts—google_subhandled inresolveCanonicalUserId, primary lookup, provider-sub fallback, update, and insert paths. The existingfindUserByAnyEmailemail-scan fallback works for Google sign-ins automatically, so anyone whose Google email already lives in an existing row’semails[]gets merged onto the canonical row.src/lib/auth-events.ts—'email_unverified'and'provider_linked'added toAuthOutcome. Every Google failure mode is captured in the audit table./api/me.ts,/me.astro,/login.astro,Header.astro, both/propose.astropages — Google added to every provider iteration and lookup.allLinkedis nowlinkedCount === 3(3/3 = green dot, 1/3 or 2/3 = yellow with count surfaced in the tooltip, 0/3 = “Log in”).
2. The link-while-logged-in flow
A real architectural gap the first end-to-end test surfaced: OAuth callbacks were treating every flow as “sign in from scratch.” A user already holding a GitHub session cookie who clicked “Continue with Google” got a fresh User row with only google_sub populated — because the callback couldn’t see the existing cookie and just mints a new identity.
New linkProviderToExistingUser(existingSession, newSession) helper in user-record.ts. Every OAuth callback now reads the active session cookie before overwriting it:
const existingSession = await verifySession(cookies.get(SESSION_COOKIE_NAME)?.value);
if (existingSession && existingSession.provider !== thisProvider) {
// Attach the new provider to the existing User row.
recorded = await linkProviderToExistingUser(existingSession, session);
if (!recorded.ok) recorded = await recordUserLogin(session, rosterEntry); // fallback
} else {
recorded = await recordUserLogin(session, rosterEntry);
}
When successful, the existing User row gets the new provider’s subject column populated, emails[] union’d with the new session’s email, last_provider updated. Existing name and avatar are preserved — the user is already operating under that identity; the new provider is supplementary. The row’s id does not change.
Logged as outcome: 'provider_linked' in AuthEvent so the audit trail distinguishes a fresh signup from a link-while-logged-in. Falls through to the normal recordUserLogin lookup chain (which has its own merge fallbacks) when the link helper can’t find the existing row.
3. Header tooltip carries Log out now
Previously the only way to sign out was navigating to /me and clicking the Log out link at the bottom. Not obvious. Especially for users in the new in state (3/3 connected), the header avatar’s link was essentially a no-op — clicking it just redirected to /me.
The header tooltip now includes a Log out row below the three provider rows, separated by a top border. The tooltip is reachable in both partial (1/3 or 2/3 connected) and in (3/3) states — previously only partial. Hover or click the avatar to expand. Distinct styling: Log out turns red on hover, while provider rows use yellow/green for Connect/Connected.
4. Helper scripts for production debugging
Two new diagnostic scripts under sites/fullstack-vc/scripts/ follow the same @libsql/client pattern as _inspect-turso.mjs:
_find-google-user.mjs— dumps every User row touching a target email, grouped by where it appears (id, primary email, emails[], google_sub). Also lists recent GoogleAuthEventrows. Run asnode scripts/_find-google-user.mjs <email>._merge-duplicate-user.mjs— consolidates a duplicate row onto a canonical one. Takes--canonicaland--duplicateids, optional--dry-run. Unions emails, takes the duplicate’s provider sub if the canonical doesn’t have it, picks the freshestlast_login_at. Run on the rare orphan rows produced when a Google email isn’t in the canonical row’s emails[] yet.
Planning context
Two task plans landed in context-v/tasks/ alongside the implementation:
Wire-Google-Workspace-OAuth-Provider.md— the plan this changelog entry implements. Includes the “accept any, nudge toward all three” posture, the provider-value matrix, ten sequenced implementation steps, verification checklist, and open questions about tooltip copy and tri-state semantics.Pre-create-and-Fuzzy-Bind-Users-from-External-Rosters.md— the sibling task, not yet implemented. Covers the Zoom/Luma export ingest path that writes pre-created User rows to Turso (PII never crosses into the public markdown layer), plus a fuzzy-match service that surfaces “we think you might be this person — claim this public profile?” candidates againstparticipants/*.md. Together with Google OAuth this closes the loop: Zoom-registered Workspace user → Google sign-in → email pre-bind to existing User row → claim card surfaces matching public profile → one-click confirmation. The privacy boundary stays clean: emails in Turso, public profiles in markdown, singlehandlecolumn on User as the bridge.
Operational notes
- Vercel build does NOT push schema changes.
astro build --remoteconnects to the remote DB but doesn’t push the schema. Schema changes require an explicitpnpm astro db push --remotebefore deploys that depend on the new columns. First production sign-in via Google failed silently for this reason — thegoogle_subcolumn didn’t exist in Turso, sorecordUserLoginerrored (best-effort, swallowed),/api/meerrored (catch branch setproviders.google = true), and the/mepage displayed “Google: Linked” with no actual row written. Lesson: any future schema-touching commit needsastro db push --remotein the deploy checklist. - Google Cloud Console redirect URIs must match exactly. The OAuth client needs
https://fullstack-vc.com/api/auth/google/callback(production) listed under Authorized redirect URIs. Wildcards aren’t accepted, so Vercel preview deployments either need individual entries or skip OAuth testing (recommended — test on production only for May 27). - Roster email aliases prevent identity splits. When a fellow’s Workspace email differs from their roster
email(common — Kauffman roster predates current firm affiliations), adding the Workspace address toemail_aliases[]inkauffman_roster.jsonlets the existing callback match them to their canonical row without any code change. Michael’smichael@avalanche.vcwas added as part of this work after the first end-to-end test surfaced the gap.
What’s next
- Pre-creation + fuzzy-bind layer. The sibling task plan is written; implementation pending. Highest-leverage follow-up — eliminates the manual merge work for orphan rows when a Workspace user signs in for the first time without their email being in any roster alias.
- Production smoke test by May 27. Verify end-to-end with a real Workspace account in incognito before announcing the session link to attendees.
- Vote round-trip. With three providers live, the May 27 polls should see meaningfully higher conversion than April 29’s zero-vote result.
Files modified (21)
sites/fullstack-vc/src/lib/session.tssites/fullstack-vc/src/lib/auth-events.tssites/fullstack-vc/src/lib/oauth-roster.tssites/fullstack-vc/src/lib/user-record.tssites/fullstack-vc/db/config.tssites/fullstack-vc/src/pages/api/auth/google/login.tssites/fullstack-vc/src/pages/api/auth/google/callback.tssites/fullstack-vc/src/pages/api/auth/github/callback.tssites/fullstack-vc/src/pages/api/auth/linkedin/callback.tssites/fullstack-vc/src/pages/api/me.tssites/fullstack-vc/src/pages/me.astrosites/fullstack-vc/src/pages/login.astrosites/fullstack-vc/src/components/basics/Header.astrosites/fullstack-vc/src/pages/projects/propose.astrosites/fullstack-vc/src/pages/working-groups/propose.astrosites/fullstack-vc/src/content/kauffman_roster.jsonsites/fullstack-vc/.env.examplesites/fullstack-vc/scripts/_find-google-user.mjssites/fullstack-vc/scripts/_merge-duplicate-user.mjssites/fullstack-vc/context-v/tasks/Wire-Google-Workspace-OAuth-Provider.mdsites/fullstack-vc/context-v/tasks/Pre-create-and-Fuzzy-Bind-Users-from-External-Rosters.md