← All changelog entries
April 26, 2025 · Feature

Hand-Rolled OAuth (GitHub + LinkedIn), Privacy Policy, and the /stack/me Scaffold

Built the auth foundation for the Interactive Stack Display: hand-rolled GitHub + LinkedIn OAuth (no Auth0/Clerk), jose-based JWT sessions in HttpOnly cookies, allowlist via kauffman_roster.json, /login + /stack/me pages, and a published privacy policy. Switched the site to Astro server output with the Vercel adapter; all existing static pages opt back in via prerender. ~250 lines of TypeScript replaces what would have been a $35/mo SaaS dependency.

Authors
Michael Staton
Augmented with
Claude Code on Opus 4.7
Tags
#OAuth#GitHub-OAuth#LinkedIn-OAuth#Authentication#Hand-Rolled#JWT#Privacy-Policy#Vercel-Adapter#Server-Mode#Markdown-as-Database#Agentic-VC-Dojo#Kauffman-Fellows

Changelog - 2025-04-26 (01)

Hand-Rolled OAuth (GitHub + LinkedIn), Privacy Policy, and the /stack/me Scaffold

Why Care?

For the firm (Mike): The whole Interactive Stack Display spec rests on one assumption — that OAuth + a roster check + signed cookies is enough to authenticate ~200 community members without reaching for Auth0, Clerk, or Supabase Auth. This commit proves the assumption. The full auth surface (two OAuth providers, allowlist matching, sessions, login UI, the bounce page for non-Fellows) is ~250 lines of TypeScript across 7 files. Auth0’s first paid tier is $35/mo and brings vendor lock-in plus a stack of abstractions future contributors and AI assistants would have to learn. We avoided all of it. The pattern is now reusable for any future Astro Knots site that needs OAuth-gated views.

For our end users (VCs visiting the dojo): A working /login page with two clean buttons — “Continue with GitHub” and “Continue with LinkedIn”. Sign in, the site verifies you’re on the Kauffman Fellows roster, and you land at /stack/me with your name + avatar visible. If you’re not on the roster, you get a friendly bounce page that explains why and tells you who to email. Plus a published /privacy page that spells out — in plain language, no dark patterns — exactly what data the site collects, where it lives, who can see it, and how to remove it.


What landed

Astro server output + Vercel adapter

The site was previously pure SSG. OAuth callbacks need request-scoped server execution (cookies, env-var-driven secrets), so the site now runs in output: 'server' mode with the @astrojs/vercel adapter. Every existing static page opts back into prerendering via export const prerender = true; — so all the marketing/content pages still ship as static HTML, only the /api/* and /stack/me routes go through the Vercel function at request time.

This matches the pattern hypernova-site already uses; sibling sites can adopt the same shape when they need server endpoints.

Auth core

  • src/lib/session.ts — JWT signing/verification using jose (audited, MIT, ~30 KB, zero deps). Sessions live in an HttpOnly, Secure, SameSite=Lax cookie. Signed with HS256, scoped by issuer and audience. 30-day TTL. No server-side session store.
  • src/lib/oauth-roster.ts — provider-aware allowlist matcher. GitHub matches by handle; LinkedIn matches by primary email or email_aliases. Throws no errors on missing data — returns null so the caller sends the user to the friendly bounce page.
  • src/content/kauffman_roster.json — seed roster, currently one entry. The schema supports kauffman_class (Class 20, 21, etc.) plus optional email_aliases for the common case where a Fellow’s primary LinkedIn email differs from the address Kauffman has on file.

OAuth endpoints (5 server routes)

RoutePurpose
GET /api/auth/github/loginRedirects to GitHub authorize URL with CSRF-state cookie
GET /api/auth/github/callbackCode exchange → user fetch → roster check → cookie
GET /api/auth/linkedin/loginLinkedIn OIDC authorize URL with CSRF-state cookie
GET /api/auth/linkedin/callbackOIDC token exchange → userinfo → roster check → cookie
GET|POST /api/auth/logoutClears the session cookie, redirects home

Both providers use the same session shape via the multi-provider SessionPayload type (provider + subject + email + name + avatar). The roster matcher branches on provider.

User-facing pages

  • /login — both OAuth buttons, redirects to /stack/me if you’re already authed.
  • /login/not-on-roster — friendly bounce for non-roster users with a contact email and a “try a different account” link.
  • /stack/me — server-rendered authed page. Shows avatar, name, firm, Kauffman class. Currently a scaffold (”// the stack-builder UI lands in v0.2”) — the actual editing UI ships in the next entry.
  • /privacy — full privacy policy page. Plain language, lists every piece of data the site collects, where it lives, who can see what, three ways to remove your data. Required by LinkedIn’s app review; published anyway as good practice for any site that touches user identity.

Header chrome

Added a “Log in” CTA button to the header, styled distinctly from nav links so it reads as an action.

Schema correction (kauffman_yearkauffman_class)

The earlier draft used kauffman_year in three places (lib, page, spec). Corrected to kauffman_class throughout to match how Fellows actually refer to themselves (“Class 20”) and to disambiguate from the year-they-joined-vs-year-they-finished ambiguity.

dev-mode env loading fix

astro.config.mjs now uses Vite’s loadEnv() to mirror .env values into process.env at config-load time. Without this, server endpoints in dev mode couldn’t reliably read non-PUBLIC_ env vars. Now process.env.GITHUB_OAUTH_CLIENT_ID works in both dev and the Vercel runtime, no special Node --env-file flag needed.


What this enables

ConcernStatus
OAuth login (GitHub + LinkedIn)✓ wired, ready for credentials
Allowlist enforcement
Session management (sign, verify, expire, clear)
Authed page that shows logged-in state✓ scaffold, no editing UI yet
Privacy policy✓ published
Production deployment readiness✓ Vercel adapter packaged
StackBuilder.svelte (the actual editing UI)next entry
Write path (PR-based submission)next entry
LinkedIn app review (requires privacy page — now satisfied)unblocked

Manual setup required to actually log in

The code is wired but the OAuth credentials are environment configuration, not code. To exercise the flow:

  1. Create a GitHub OAuth App at https://github.com/organizations/lossless-group/settings/applications (callback URL: http://localhost:4324/api/auth/github/callback for dev, https://fullstack-vc.com/api/auth/github/callback for prod — or use two separate apps per environment).
  2. Add to sites/fullstack-vc/.env (gitignored):
    GITHUB_OAUTH_CLIENT_ID=...
    GITHUB_OAUTH_CLIENT_SECRET=...
    JWT_SIGNING_SECRET=  # openssl rand -base64 32
  3. (Optional, for v0.3) Same for LinkedIn — register at https://www.linkedin.com/developers/apps, request “Sign In with LinkedIn using OpenID Connect,” add LINKEDIN_OAUTH_CLIENT_ID + LINKEDIN_OAUTH_CLIENT_SECRET. The /privacy page satisfies LinkedIn’s review requirement.
  4. Restart the dev server (the env-loader runs once at config load).

Full setup walkthrough lives in context-v/specs/Maintain-an-Interactive-Stack-Display.md — Authentication Model section.


Why hand-rolled (not Auth0 / Clerk / Supabase Auth)

Auth0, Clerk, and Supabase Auth are correct choices when you need: many providers, password reset, MFA, SSO/SAML, multi-tenant isolation, identity-as-billing-key, or compliance-scale audit logging. None of those apply at our scale — 200 users, two providers, allowlist via JSON.

What hand-rolling replaces:

ConcernVendor SDKHand-rolled lines
OAuth handshakeabstract opaque flow~30 LOC per provider
Token exchangeauth.exchange(code)one fetch
Session storageservice-managedsigned JWT in cookie
Allowlistusually a separate serviceArray.find()

The dollar cost ($35–$120/mo at our scale on most vendors) is small. The architectural cost — abstraction that future contributors and AI assistants have to learn — is larger. Plain TypeScript reads well under any AI assistant’s window; vendor SDKs do not.

This aligns directly with the firm’s “AI handles markdown and JSON” thesis from the spec: prefer plain code and plain data over platforms that bury both behind proprietary types.

Files modified (22)
  • sites/fullstack-vc/astro.config.mjs
  • sites/fullstack-vc/package.json
  • sites/fullstack-vc/src/components/basics/Header.astro
  • sites/fullstack-vc/src/content/kauffman_roster.json
  • sites/fullstack-vc/src/lib/oauth-roster.ts
  • sites/fullstack-vc/src/lib/session.ts
  • sites/fullstack-vc/src/pages/api/auth/github/login.ts
  • sites/fullstack-vc/src/pages/api/auth/github/callback.ts
  • sites/fullstack-vc/src/pages/api/auth/linkedin/login.ts
  • sites/fullstack-vc/src/pages/api/auth/linkedin/callback.ts
  • sites/fullstack-vc/src/pages/api/auth/logout.ts
  • sites/fullstack-vc/src/pages/login.astro
  • sites/fullstack-vc/src/pages/login/not-on-roster.astro
  • sites/fullstack-vc/src/pages/stack/me.astro
  • sites/fullstack-vc/src/pages/privacy.astro
  • sites/fullstack-vc/src/pages/index.astro
  • sites/fullstack-vc/src/pages/brand-kit/index.astro
  • sites/fullstack-vc/src/pages/changelog/index.astro
  • sites/fullstack-vc/src/pages/changelog/[id].astro
  • sites/fullstack-vc/src/pages/design-system/index.astro
  • sites/fullstack-vc/src/pages/webinars/index.astro
  • sites/fullstack-vc/src/pages/webinars/[id].astro