Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,3 @@
# Blog-Chat

> **Two codebases live in this repo.** `master` is the original 2021 MERN (CRA + Redux + Express +
> Socket.io) app, still live in production. Everything below describes the from-scratch rebuild on
> `staging` — Express + React (Vite) + TypeScript — which is what you get from a fresh clone. See
> `CLAUDE.md` and `docs/superpowers/specs/2026-07-16-express-react-rebuild-design.md` for the full
> design rationale and phase history.

## About

A MERN blog + chat app. It reimplements every real feature of the legacy app — session auth, posts,
Expand Down
22 changes: 17 additions & 5 deletions apps/server/src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,22 @@ export type Env = z.infer<typeof EnvSchema>

const FILE_BACKED_KEYS = ['SESSION_SECRET', 'CLOUDINARY_API_SECRET'] as const

/**
* Standalone on purpose: callers that need PUBLIC_ORIGIN alone (the OAuth
* strategy setup in routes/v1/auth.ts) must not have to satisfy the full
* EnvSchema — e.g. MONGODB_URI/SESSION_SECRET — just to resolve one field.
* loadEnv() below is one caller of this, not the only path to it.
*/
export function resolvePublicOrigin(source: NodeJS.ProcessEnv = process.env): string {
const explicit = source.PUBLIC_ORIGIN?.trim()
if (explicit) return explicit
// Render injects RENDER_EXTERNAL_URL. Without this fallback a deploy keeps
// building localhost callbacks and breaks sign-in, with nothing failing loudly.
const renderUrl = source.RENDER_EXTERNAL_URL?.trim()
if (renderUrl) return renderUrl
return 'http://localhost:5173'
}

function resolveFileBackedSecrets(source: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const resolved = { ...source }
for (const key of FILE_BACKED_KEYS) {
Expand All @@ -67,11 +83,7 @@ function resolveFileBackedSecrets(source: NodeJS.ProcessEnv): NodeJS.ProcessEnv
resolved[key] = readFileSync(filePath, 'utf-8').trim()
}
}
// Render injects RENDER_EXTERNAL_URL. Without this fallback a deploy keeps
// building localhost callbacks and breaks sign-in, with nothing failing loudly.
if (!resolved.PUBLIC_ORIGIN?.trim() && resolved.RENDER_EXTERNAL_URL?.trim()) {
resolved.PUBLIC_ORIGIN = resolved.RENDER_EXTERNAL_URL.trim()
}
resolved.PUBLIC_ORIGIN = resolvePublicOrigin(resolved)
return resolved
}

Expand Down
11 changes: 10 additions & 1 deletion apps/server/src/routes/v1/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { LoginSchema, SignupSchema } from '@blog/zod-shared'
import { ServiceUnavailableError, UnauthorizedError } from '../../lib/errors.js'
import { Router } from 'express'
import passport from 'passport'
import { resolvePublicOrigin } from '../../lib/env.js'
import {
registerOAuthStrategies,
type ConfiguredProviders,
Expand All @@ -15,14 +16,22 @@ export const authRouter = Router()

// Lazily registered so the strategies are built after loadEnv has validated the
// credential pairs, and so a deployment with no OAuth apps pays nothing.
//
// REGRESSION GUARD: this used to build PUBLIC_ORIGIN from raw
// `process.env.PUBLIC_ORIGIN ?? 'http://localhost:5173'`, bypassing the
// RENDER_EXTERNAL_URL fallback entirely. On Render, where PUBLIC_ORIGIN is
// correctly left unset, that raw read is undefined and fell straight to the
// localhost default — so the Google OAuth callback URL sent to Google was
// `http://localhost:5173/...` in production. resolvePublicOrigin() is the one
// place that fallback lives; call it here rather than re-deriving it.
let providers: ConfiguredProviders | undefined
function configuredProviders(): ConfiguredProviders {
providers ??= registerOAuthStrategies({
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
FACEBOOK_APP_ID: process.env.FACEBOOK_APP_ID,
FACEBOOK_APP_SECRET: process.env.FACEBOOK_APP_SECRET,
PUBLIC_ORIGIN: process.env.PUBLIC_ORIGIN ?? 'http://localhost:5173',
PUBLIC_ORIGIN: resolvePublicOrigin(),
})
return providers
}
Expand Down
51 changes: 51 additions & 0 deletions apps/server/src/routes/v1/oauth-callback-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import request from 'supertest'
import { afterAll, describe, expect, it } from 'vitest'
import { buildTestApp, useTestDb } from '../../test/helpers.js'

useTestDb()

// A dedicated file, not a case inside oauth.test.ts: configuredProviders()
// caches its result in a module-level singleton on first call, and
// oauth.test.ts's own tests are the first callers in that module instance
// (with GOOGLE_CLIENT_ID unset). This file needs a fresh module load so
// resolvePublicOrigin() actually runs against the env set below.
const ORIGINAL = {
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
PUBLIC_ORIGIN: process.env.PUBLIC_ORIGIN,
RENDER_EXTERNAL_URL: process.env.RENDER_EXTERNAL_URL,
}
afterAll(() => {
for (const [key, value] of Object.entries(ORIGINAL)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
})

process.env.GOOGLE_CLIENT_ID = 'test-client-id'
process.env.GOOGLE_CLIENT_SECRET = 'test-client-secret'
delete process.env.PUBLIC_ORIGIN

describe('GET /api/v1/auth/google — callback URL production regression', () => {
// REGRESSION: registerOAuthStrategies used to be built from
// `process.env.PUBLIC_ORIGIN ?? 'http://localhost:5173'` directly, bypassing
// the RENDER_EXTERNAL_URL fallback. With PUBLIC_ORIGIN correctly left unset
// on Render, that sent Google a callback URL of
// `http://localhost:5173/api/v1/auth/google/callback` in production —
// rejected by Google as a redirect_uri_mismatch, since only the real
// onrender.com URL was registered. This proves resolvePublicOrigin() is
// actually reached from the OAuth route, not just from loadEnv().
it('builds the callback from RENDER_EXTERNAL_URL when PUBLIC_ORIGIN is unset', async () => {
process.env.RENDER_EXTERNAL_URL = 'https://blog-chat-app.onrender.com'

const res = await request(buildTestApp()).get('/api/v1/auth/google')

expect(res.status).toBe(302)
const location = res.headers.location
if (!location) throw new Error('Expected a Location header on the OAuth redirect.')
expect(location).toContain('accounts.google.com')
const redirectUri = new URL(location).searchParams.get('redirect_uri')
expect(redirectUri).toBe('https://blog-chat-app.onrender.com/api/v1/auth/google/callback')
expect(redirectUri).not.toContain('localhost')
})
})
30 changes: 15 additions & 15 deletions apps/server/src/scripts/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,30 +14,30 @@ const DEMO_PASSWORD = 'demo-password-1234'

const POSTS = [
{
title: 'Rebuilding a Five-Year-Old MERN App',
tags: ['engineering', 'react'],
title: 'Why the Sky Is Blue and the Sunset Is Red',
tags: ['science', 'nature'],
body: [
'This blog is a rebuild of a MERN app I wrote five years ago.',
'The original had five authorization holes, a Redux store that cached server state by hand, and a Dockerfile that never worked. Every one of those is a test in this codebase now.',
'The rebuild is an Express REST API with a React SPA in front of it. Not because the old stack was slow — because the new one is explicit.',
"Sunlight looks white, but it's every color mixed together, and each color travels through air as a wave of a different length. Blue light has a short wavelength and scatters off air molecules far more easily than red does.",
"At midday the sun is overhead and its light takes the shortest path through the atmosphere, so that scattered blue reaches your eyes from every direction — the whole sky glows blue.",
"At sunset the light travels through much more atmosphere at a low angle. Most of the blue scatters away long before it reaches you, leaving the longer red and orange wavelengths to dominate what's left.",
].join('\n\n'),
},
{
title: 'Why Identity Never Comes From The Request Body',
tags: ['security'],
title: 'How Coffee Went From an Ethiopian Hillside to a Global Habit',
tags: ['history', 'food'],
body: [
'The legacy app had an endpoint that took a user id and a new password, both from the request body, and applied them.',
'That is an account takeover, not a bug. Anyone could rewrite anyone. The fix is one sentence: identity always comes from the session, never from a body field.',
'Every mutation in this API compares req.session.userId to the resource owner, and there is a test for each of the five holes the old app had.',
'Legend credits a goat herder named Kaldi, who noticed his goats grew unusually energetic after eating berries from a certain tree, and brought them to a local monastery to ask what they were.',
'Whatever really happened in that highland region of Ethiopia, coffee cultivation had spread to Yemen by the 15th century, where it was first roasted and brewed roughly the way it is today.',
"From Yemeni ports it moved into the Ottoman Empire, then into Europe through Venetian trade routes in the 1600s, arriving in a city near you a few centuries later as the thing that gets you through a Monday.",
].join('\n\n'),
},
{
title: 'Gating Content At The Serialization Boundary',
tags: ['engineering', 'security'],
title: 'The Monarch Butterfly Migration Nobody Fully Explained Until Recently',
tags: ['nature', 'biology'],
body: [
'A registration wall implemented in a component is a suggestion. The body is still in the JSON, one DevTools tab away.',
'If you are reading this paragraph you are signed in — the API never serialized it otherwise.',
'The rule lives in postService.getBySlug, which does not copy the body into its return value when the reader is anonymous. There is nothing to find in the response because it was never put there.',
"Monarch butterflies in eastern North America fly up to 3,000 miles to a handful of specific forests in central Mexico each winter, despite no single butterfly ever having made the round trip before.",
"The butterflies that arrive in Mexico are three or four generations removed from the ones that left the previous spring, so the route can't be memorized by an individual — it has to be encoded some other way.",
"Researchers eventually traced the compass to a combination of the sun's position and an internal circadian clock, sensed through antennae that appear to also register the Earth's magnetic field as a backup on cloudy days.",
].join('\n\n'),
},
]
Expand Down
Loading