A web-based IBM 3270 terminal emulator that authenticates against PostHog via OAuth + RFC 7591 Dynamic Client Registration. Live at mainframe.posthog.com.
The user-facing form looks like a vintage TSO/ISPF login (ACCOUNT, REGION, USERID, PASSWORD). Only the REGION field is functional — it picks US (us.posthog.com) or EU (eu.posthog.com). Pressing Enter redirects to PostHog's real OAuth consent screen.
- Next.js 16 (App Router, Turbopack) on Vercel
- Auth.js v5 with two custom OIDC providers (
posthog-us,posthog-eu) - JWT session in an
__Host-HttpOnly cookie — no DB - rbanffy/3270 font + a small CSS CRT layer (scanlines, phosphor glow, vignette)
- Tailwind v4 for layout primitives
- Strict CSP + HSTS + frame-ancestors none
pnpm install
# Generate a JWT secret
openssl rand -base64 32
# Register OAuth clients with PostHog (RFC 7591 DCR)
PUBLIC_URL=http://localhost:3000 pnpm tsx scripts/register-client.tsThen fill in .env.local:
AUTH_SECRET=<output of openssl>
AUTH_URL=http://localhost:3000
POSTHOG_US_CLIENT_ID=<from script>
POSTHOG_EU_CLIENT_ID=<from script>Run:
pnpm devOpen http://localhost:3000 — type US or EU into the REGION field, press Enter, complete OAuth on PostHog.
vercel linkandvercel env addforAUTH_SECRET,AUTH_URL,POSTHOG_US_CLIENT_ID,POSTHOG_EU_CLIENT_ID.- Re-run the DCR script with
PUBLIC_URL=https://mainframe.posthog.comso the redirect URI matches production. - Point the
mainframeCNAME at Vercel; Vercel auto-provisions TLS.
- Public OAuth client → no client secret to leak. PKCE-only.
- Read-only OAuth scopes.
openid profile email+ a fixed set of PostHog:readscopes, defined once insrc/lib/scopes.tsand shared by both the consent request (auth.ts) and the client registration (register-client.ts). The list is typed`${string}:read`, so a mutating scope can't even compile. - Decorative fields use non-
type="password"inputs +-webkit-text-security: discso password managers don't autofill anything. - A dim banner above the field row reads
AUTH MODE: OAUTH-3270 :: PRESS ENTER TO CONNECT TO POSTHOG.COM— keeps the page out of phishing territory. - CSP locks
connect-src/form-actionto PostHog domains.frame-ancestors 'none', HSTS preload,X-Content-Type-Options: nosniff. - Stateless JWT session in
__Host-authjs.session-token(HttpOnly, Secure, SameSite=Lax).
src/
auth.ts Auth.js v5 config (US + EU OIDC providers)
middleware.ts Protects /menu
app/
layout.tsx, page.tsx Root + login screen entry
globals.css CRT + phosphor + 3270 font face
actions.ts "use server" signIn / signOut
api/auth/[...nextauth]/route.ts
menu/page.tsx ISPF Primary Option Menu
components/
Screen.tsx CRT bezel + scanline overlay
OIA.tsx Operator Information Area (row 25)
LoginScreen.tsx The 3270 login screen
MenuScreen.tsx Post-login menu
lib/
regions.ts US / EU enumeration
scopes.ts Read-only OAuth scope list (single source of truth)
screen.ts Grid math + POSTHOG banner
scripts/
register-client.ts RFC 7591 DCR bootstrap (US + EU)
public/fonts/
3270-Regular.{woff,ttf} rbanffy/3270font v3.0.1
- The OIA row (line 25) updates the cursor position every key event. Make sure to thread
cursorRow/cursorColprops if you add new screens. - Menu options past
1 BROWSEare placeholders — addapp/menu/<option>/page.tsxand a route from the command field. - PostHog scopes live in
src/lib/scopes.tsand are read-only by policy. To surface a new PostHog resource, add its:readscope there (the type enforces the:readsuffix), then re-run the DCR script so the registered client picks it up. Never add a:write/mutating scope — this app only reads.