← All changelog entries
May 21, 2026 · Feature

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.

Authors
Michael Staton
Augmented with
Claude Code on Opus 4.7
Tags
#OAuth#Google-OAuth#Google-Workspace#Authentication#Identity-Model#Provider-Parity#Session-Linking#Header-Auth-Indicator#Multi-Provider#Triangulated-Identity#May-27-Prep

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:

ProviderWhat it tells us
GitHubBuilding 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.
LinkedInProfessional identity — firm, role, work history, network. The legible-to-LPs view. Highest fidelity for roster + people-page cross-referencing.
Google WorkspaceDomain 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 to https://accounts.google.com/o/oauth2/v2/auth with scope=openid email profile and prompt=select_account (forces account chooser on shared machines).
  • src/pages/api/auth/google/callback.ts — token exchange at oauth2.googleapis.com/token, userinfo at openidconnect.googleapis.com/v1/userinfo, email_verified guard 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.tsUser.google_sub column + unique index parallel to github_handle and linkedin_sub. Additive, zero-downtime, pushed to Turso via astro db push --remote.
  • src/lib/session.tsSessionPayload.provider widened 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.tsgoogle_sub handled in resolveCanonicalUserId, primary lookup, provider-sub fallback, update, and insert paths. The existing findUserByAnyEmail email-scan fallback works for Google sign-ins automatically, so anyone whose Google email already lives in an existing row’s emails[] gets merged onto the canonical row.
  • src/lib/auth-events.ts'email_unverified' and 'provider_linked' added to AuthOutcome. Every Google failure mode is captured in the audit table.
  • /api/me.ts, /me.astro, /login.astro, Header.astro, both /propose.astro pages — Google added to every provider iteration and lookup. allLinked is now linkedCount === 3 (3/3 = green dot, 1/3 or 2/3 = yellow with count surfaced in the tooltip, 0/3 = “Log in”).

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 Google AuthEvent rows. Run as node scripts/_find-google-user.mjs <email>.
  • _merge-duplicate-user.mjs — consolidates a duplicate row onto a canonical one. Takes --canonical and --duplicate ids, optional --dry-run. Unions emails, takes the duplicate’s provider sub if the canonical doesn’t have it, picks the freshest last_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 against participants/*.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, single handle column on User as the bridge.

Operational notes

  • Vercel build does NOT push schema changes. astro build --remote connects to the remote DB but doesn’t push the schema. Schema changes require an explicit pnpm astro db push --remote before deploys that depend on the new columns. First production sign-in via Google failed silently for this reason — the google_sub column didn’t exist in Turso, so recordUserLogin errored (best-effort, swallowed), /api/me errored (catch branch set providers.google = true), and the /me page displayed “Google: Linked” with no actual row written. Lesson: any future schema-touching commit needs astro db push --remote in 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 to email_aliases[] in kauffman_roster.json lets the existing callback match them to their canonical row without any code change. Michael’s michael@avalanche.vc was 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.ts
  • sites/fullstack-vc/src/lib/auth-events.ts
  • sites/fullstack-vc/src/lib/oauth-roster.ts
  • sites/fullstack-vc/src/lib/user-record.ts
  • sites/fullstack-vc/db/config.ts
  • sites/fullstack-vc/src/pages/api/auth/google/login.ts
  • sites/fullstack-vc/src/pages/api/auth/google/callback.ts
  • sites/fullstack-vc/src/pages/api/auth/github/callback.ts
  • sites/fullstack-vc/src/pages/api/auth/linkedin/callback.ts
  • sites/fullstack-vc/src/pages/api/me.ts
  • sites/fullstack-vc/src/pages/me.astro
  • sites/fullstack-vc/src/pages/login.astro
  • sites/fullstack-vc/src/components/basics/Header.astro
  • sites/fullstack-vc/src/pages/projects/propose.astro
  • sites/fullstack-vc/src/pages/working-groups/propose.astro
  • sites/fullstack-vc/src/content/kauffman_roster.json
  • sites/fullstack-vc/.env.example
  • sites/fullstack-vc/scripts/_find-google-user.mjs
  • sites/fullstack-vc/scripts/_merge-duplicate-user.mjs
  • sites/fullstack-vc/context-v/tasks/Wire-Google-Workspace-OAuth-Provider.md
  • sites/fullstack-vc/context-v/tasks/Pre-create-and-Fuzzy-Bind-Users-from-External-Rosters.md