From 2c1ded3c3bcf050e2d296a34e4338906eeeb651f Mon Sep 17 00:00:00 2001 From: Yonatan Hen Date: Thu, 30 Jul 2026 19:38:37 +0300 Subject: [PATCH 1/3] content: replace demo posts with general-interest topics --- apps/server/src/scripts/seed.ts | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/server/src/scripts/seed.ts b/apps/server/src/scripts/seed.ts index 2ba68256..ed715f2f 100644 --- a/apps/server/src/scripts/seed.ts +++ b/apps/server/src/scripts/seed.ts @@ -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'), }, ] From a79e7b099b7846b2d446fa7e8394dd96edf67458 Mon Sep 17 00:00:00 2001 From: Yonatan Hen Date: Thu, 30 Jul 2026 19:48:47 +0300 Subject: [PATCH 2/3] docs: drop the stale two-codebases note now that master is the rebuild [skip ci] --- README.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/README.md b/README.md index e0f017c2..98276c9e 100644 --- a/README.md +++ b/README.md @@ -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, From b1574ad94c6c629300094341adaa3cf0bbf8117b Mon Sep 17 00:00:00 2001 From: Yonatan Hen Date: Thu, 30 Jul 2026 20:17:46 +0300 Subject: [PATCH 3/3] fix(auth): route OAuth callback URLs through the RENDER_EXTERNAL_URL fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google sign-in was rejecting every attempt in production with redirect_uri_mismatch. The server was sending Google a callback of http://localhost:5173/api/v1/auth/google/callback instead of the real onrender.com URL. Root cause: registerOAuthStrategies was built from `process.env.PUBLIC_ORIGIN ?? 'http://localhost:5173'` directly in routes/v1/auth.ts, bypassing the RENDER_EXTERNAL_URL fallback entirely — that logic only lived inside env.ts's resolveFileBackedSecrets(), which auth.ts never called. With PUBLIC_ORIGIN correctly left unset on Render (per its own sync: false / "only needed for a custom domain" contract), the raw process.env read was undefined and fell straight to the hardcoded default. Extracted resolvePublicOrigin() as a standalone function in env.ts, independent of the full EnvSchema, and call it from auth.ts. It has to be standalone rather than routed through loadEnv(): the full schema also requires MONGODB_URI/SESSION_SECRET, which the test harness never populates in process.env directly (those are passed to buildTestApp as explicit options), so calling loadEnv() at OAuth-route time broke five existing tests that don't need any of that to check a provider flag. New regression test builds the actual test app with GOOGLE_CLIENT_ID set, PUBLIC_ORIGIN unset, and RENDER_EXTERNAL_URL set to a fake onrender.com URL, then asserts the real redirect_uri query param on the Google redirect resolves to that URL, not localhost — reproducing the exact failure from production. Lives in its own file: configuredProviders() caches its result in a module-level singleton on first call, and oauth.test.ts's own tests are already the first callers in that module instance with no credentials configured. --- apps/server/src/lib/env.ts | 22 ++++++-- apps/server/src/routes/v1/auth.ts | 11 +++- .../src/routes/v1/oauth-callback-url.test.ts | 51 +++++++++++++++++++ 3 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 apps/server/src/routes/v1/oauth-callback-url.test.ts diff --git a/apps/server/src/lib/env.ts b/apps/server/src/lib/env.ts index 992bfe02..c9379e7a 100644 --- a/apps/server/src/lib/env.ts +++ b/apps/server/src/lib/env.ts @@ -59,6 +59,22 @@ export type Env = z.infer 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) { @@ -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 } diff --git a/apps/server/src/routes/v1/auth.ts b/apps/server/src/routes/v1/auth.ts index 10cd4013..88ec1724 100644 --- a/apps/server/src/routes/v1/auth.ts +++ b/apps/server/src/routes/v1/auth.ts @@ -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, @@ -15,6 +16,14 @@ 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({ @@ -22,7 +31,7 @@ function configuredProviders(): ConfiguredProviders { 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 } diff --git a/apps/server/src/routes/v1/oauth-callback-url.test.ts b/apps/server/src/routes/v1/oauth-callback-url.test.ts new file mode 100644 index 00000000..f9625115 --- /dev/null +++ b/apps/server/src/routes/v1/oauth-callback-url.test.ts @@ -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') + }) +})