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.
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 usingjose(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 oremail_aliases. Throws no errors on missing data — returnsnullso the caller sends the user to the friendly bounce page.src/content/kauffman_roster.json— seed roster, currently one entry. The schema supportskauffman_class(Class 20, 21, etc.) plus optionalemail_aliasesfor the common case where a Fellow’s primary LinkedIn email differs from the address Kauffman has on file.
OAuth endpoints (5 server routes)
| Route | Purpose |
|---|---|
GET /api/auth/github/login | Redirects to GitHub authorize URL with CSRF-state cookie |
GET /api/auth/github/callback | Code exchange → user fetch → roster check → cookie |
GET /api/auth/linkedin/login | LinkedIn OIDC authorize URL with CSRF-state cookie |
GET /api/auth/linkedin/callback | OIDC token exchange → userinfo → roster check → cookie |
GET|POST /api/auth/logout | Clears 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/meif 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_year → kauffman_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
| Concern | Status |
|---|---|
| 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:
- Create a GitHub OAuth App at https://github.com/organizations/lossless-group/settings/applications (callback URL:
http://localhost:4324/api/auth/github/callbackfor dev,https://fullstack-vc.com/api/auth/github/callbackfor prod — or use two separate apps per environment). - Add to
sites/fullstack-vc/.env(gitignored):GITHUB_OAUTH_CLIENT_ID=... GITHUB_OAUTH_CLIENT_SECRET=... JWT_SIGNING_SECRET= # openssl rand -base64 32 - (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/privacypage satisfies LinkedIn’s review requirement. - 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:
| Concern | Vendor SDK | Hand-rolled lines |
|---|---|---|
| OAuth handshake | abstract opaque flow | ~30 LOC per provider |
| Token exchange | auth.exchange(code) | one fetch |
| Session storage | service-managed | signed JWT in cookie |
| Allowlist | usually a separate service | Array.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.mjssites/fullstack-vc/package.jsonsites/fullstack-vc/src/components/basics/Header.astrosites/fullstack-vc/src/content/kauffman_roster.jsonsites/fullstack-vc/src/lib/oauth-roster.tssites/fullstack-vc/src/lib/session.tssites/fullstack-vc/src/pages/api/auth/github/login.tssites/fullstack-vc/src/pages/api/auth/github/callback.tssites/fullstack-vc/src/pages/api/auth/linkedin/login.tssites/fullstack-vc/src/pages/api/auth/linkedin/callback.tssites/fullstack-vc/src/pages/api/auth/logout.tssites/fullstack-vc/src/pages/login.astrosites/fullstack-vc/src/pages/login/not-on-roster.astrosites/fullstack-vc/src/pages/stack/me.astrosites/fullstack-vc/src/pages/privacy.astrosites/fullstack-vc/src/pages/index.astrosites/fullstack-vc/src/pages/brand-kit/index.astrosites/fullstack-vc/src/pages/changelog/index.astrosites/fullstack-vc/src/pages/changelog/[id].astrosites/fullstack-vc/src/pages/design-system/index.astrosites/fullstack-vc/src/pages/webinars/index.astrosites/fullstack-vc/src/pages/webinars/[id].astro