From 2411d812dfea955601085a718d850814b000f5e8 Mon Sep 17 00:00:00 2001 From: Keegan Vaz Date: Tue, 8 Sep 2026 01:04:54 +0530 Subject: [PATCH 1/7] Clarify Postgres config and migration errors Document that database passwords must be percent-encoded in production and add a more actionable migration hint when the PostgreSQL role cannot create schema objects in the target database. This makes deployment failures easier to diagnose and points operators to the correct ownership fix. --- server/.env.production.example | 3 ++- server/src/db/migrate.ts | 22 ++++++++++++---------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/server/.env.production.example b/server/.env.production.example index 54121eb..38794a2 100644 --- a/server/.env.production.example +++ b/server/.env.production.example @@ -14,7 +14,8 @@ WEB_ORIGIN=https://forrt.org/replicate-this BETTER_AUTH_URL=https://api-replicate-this.keeganvaz.in # --- Database (PostgreSQL on the same box) --- -# URL-encode the password if it contains @ : / ? # or &. +# Percent-encode the password: / ? # break the URL outright. Get the encoded +# form with: node -e "console.log(encodeURIComponent('your-password'))" DATABASE_URL=postgresql://replicate_this:CHANGE_ME@127.0.0.1:5432/replicate_this # --- Secrets: generate each with `openssl rand -hex 32` --- diff --git a/server/src/db/migrate.ts b/server/src/db/migrate.ts index 53ff232..efdb3e7 100644 --- a/server/src/db/migrate.ts +++ b/server/src/db/migrate.ts @@ -16,18 +16,20 @@ try { // This runs mid-deploy and is read in CI logs, where a driver stack trace // buries the one line that says what to fix. const { code } = (error as { cause?: { code?: string } }).cause ?? {} - const { hostname, port, pathname } = new URL(config.databaseUrl) + const { hostname, port, pathname, username } = new URL(config.databaseUrl) const target = `${hostname}:${port || '5432'}` - const hint = - code === 'ECONNREFUSED' - ? `nothing is listening at ${target} — is PostgreSQL running?` - : code === '28P01' - ? `password rejected at ${target}` - : code === '3D000' - ? `database "${pathname.slice(1)}" does not exist at ${target}` - : null + const database = pathname.slice(1) + const hints: Record = { + ECONNREFUSED: `nothing is listening at ${target} — is PostgreSQL running?`, + '28P01': `password rejected at ${target}`, + '3D000': `database "${database}" does not exist at ${target}`, + // Migrations create the drizzle schema and then tables in public, so the + // role needs to own the database, not merely connect to it. + '42501': `role "${username}" may connect to "${database}" but not create in it — make it the owner (see docs/DEPLOYMENT.md)`, + } + const hint = code ? hints[code] : undefined - if (hint === null) throw error + if (hint === undefined) throw error console.error(`Migration failed: ${hint}\nCheck DATABASE_URL in .env.`) process.exit(1) } From 410aa1b0a109e341464a558938cf94e9621f4d0c Mon Sep 17 00:00:00 2001 From: Keegan Vaz Date: Tue, 8 Sep 2026 01:12:01 +0530 Subject: [PATCH 2/7] Update app.ts --- server/src/app.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/server/src/app.ts b/server/src/app.ts index c2c7f77..f55b78e 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -30,6 +30,13 @@ export function createApp() { }), ) + // Cloudflare serves its own robots.txt for this host, which can shadow the + // route below — a response header cannot be intercepted the same way. + app.use('*', async (c, next) => { + await next() + c.header('X-Robots-Tag', 'noindex, nofollow') + }) + // Better Auth owns everything under /api/auth/*. app.on(['GET', 'POST'], '/api/auth/*', (c) => auth.handler(c.req.raw)) From 85b263b3618ca9c1ca7763a2b4a46b15cfdaf5c1 Mon Sep 17 00:00:00 2001 From: Keegan Vaz Date: Tue, 8 Sep 2026 01:16:05 +0530 Subject: [PATCH 3/7] Update migrate.ts --- server/src/db/migrate.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/server/src/db/migrate.ts b/server/src/db/migrate.ts index efdb3e7..5c3ca2b 100644 --- a/server/src/db/migrate.ts +++ b/server/src/db/migrate.ts @@ -31,5 +31,22 @@ try { if (hint === undefined) throw error console.error(`Migration failed: ${hint}\nCheck DATABASE_URL in .env.`) + + // A privilege error where the grants look right usually means this connection + // is not reaching the cluster you granted on. Ask it who it actually is. + if (code === '42501') { + try { + const { rows } = await pool.query( + `select current_user, current_database(), inet_server_addr() as host, + inet_server_port() as port, + has_database_privilege(current_user, current_database(), 'CREATE') as can_create_schema, + has_schema_privilege(current_user, 'public', 'CREATE') as can_create_tables`, + ) + console.error('This connection reports:', rows[0]) + } catch (probe) { + console.error('Could not query the connection for details:', probe) + } + } + process.exit(1) } From 74d07218ddac93af8ef8cfc5c8b02e44ce5ee706 Mon Sep 17 00:00:00 2001 From: Keegan Vaz Date: Tue, 8 Sep 2026 01:22:51 +0530 Subject: [PATCH 4/7] Update migrate.ts --- server/src/db/migrate.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/server/src/db/migrate.ts b/server/src/db/migrate.ts index 5c3ca2b..6e434d7 100644 --- a/server/src/db/migrate.ts +++ b/server/src/db/migrate.ts @@ -15,7 +15,7 @@ try { } catch (error) { // This runs mid-deploy and is read in CI logs, where a driver stack trace // buries the one line that says what to fix. - const { code } = (error as { cause?: { code?: string } }).cause ?? {} + const { code, message } = (error as { cause?: { code?: string; message?: string } }).cause ?? {} const { hostname, port, pathname, username } = new URL(config.databaseUrl) const target = `${hostname}:${port || '5432'}` const database = pathname.slice(1) @@ -23,9 +23,10 @@ try { ECONNREFUSED: `nothing is listening at ${target} — is PostgreSQL running?`, '28P01': `password rejected at ${target}`, '3D000': `database "${database}" does not exist at ${target}`, - // Migrations create the drizzle schema and then tables in public, so the - // role needs to own the database, not merely connect to it. - '42501': `role "${username}" may connect to "${database}" but not create in it — make it the owner (see docs/DEPLOYMENT.md)`, + // Postgres names the exact object it refused — database or schema — which + // matters: owning the database does not grant rights inside a schema that + // another role owns. + '42501': `${message ?? 'permission denied'} (role "${username}" on "${database}") — that object needs to be owned by the role, see docs/DEPLOYMENT.md`, } const hint = code ? hints[code] : undefined @@ -37,10 +38,15 @@ try { if (code === '42501') { try { const { rows } = await pool.query( - `select current_user, current_database(), inet_server_addr() as host, - inet_server_port() as port, + `select current_user, current_database(), inet_server_port() as port, has_database_privilege(current_user, current_database(), 'CREATE') as can_create_schema, - has_schema_privilege(current_user, 'public', 'CREATE') as can_create_tables`, + has_schema_privilege(current_user, 'public', 'CREATE') as can_create_tables, + case when to_regnamespace('drizzle') is null then null else + pg_get_userbyid((select nspowner from pg_namespace where nspname = 'drizzle')) + end as drizzle_schema_owner, + case when to_regnamespace('drizzle') is null then null else + has_schema_privilege(current_user, 'drizzle', 'CREATE') + end as can_write_drizzle_schema`, ) console.error('This connection reports:', rows[0]) } catch (probe) { From a21236eaf27529862ce91b5ad453b5ddbab44c68 Mon Sep 17 00:00:00 2001 From: Keegan Vaz Date: Tue, 8 Sep 2026 01:29:00 +0530 Subject: [PATCH 5/7] Update migrate.ts --- server/src/db/migrate.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/server/src/db/migrate.ts b/server/src/db/migrate.ts index 6e434d7..e956fdf 100644 --- a/server/src/db/migrate.ts +++ b/server/src/db/migrate.ts @@ -49,6 +49,20 @@ try { end as can_write_drizzle_schema`, ) console.error('This connection reports:', rows[0]) + + // Objects left behind by an earlier run as another role are the usual + // cause, and they surface one at a time — list them all in one go. + const { rows: foreign } = await pool.query( + `select n.nspname || '.' || c.relname as object, pg_get_userbyid(c.relowner) as owner + from pg_class c join pg_namespace n on n.oid = c.relnamespace + where n.nspname in ('public', 'drizzle') + and c.relkind in ('r', 'v', 'm', 'S') + and pg_get_userbyid(c.relowner) <> current_user + order by 1`, + ) + if (foreign.length > 0) { + console.error('Objects not owned by this role:', foreign) + } } catch (probe) { console.error('Could not query the connection for details:', probe) } From c8af896a93109006124e0f12cb0267a7b54dc221 Mon Sep 17 00:00:00 2001 From: Keegan Vaz Date: Tue, 8 Sep 2026 01:37:00 +0530 Subject: [PATCH 6/7] Update auth.ts --- server/src/auth.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/server/src/auth.ts b/server/src/auth.ts index fe3054b..fb76bd1 100644 --- a/server/src/auth.ts +++ b/server/src/auth.ts @@ -212,6 +212,13 @@ export const auth = betterAuth({ enabled: true, trustedProviders: ['google', 'github', 'orcid'], }, + // Better Auth double-checks the OAuth state against a cookie it sets on the + // API domain during sign-in. Cross-site that is a third-party cookie, which + // many browsers refuse to store, and the callback then fails with + // state_mismatch. The state itself is a single-use random value held in the + // verification table — the cookie is only defence in depth, so drop it when + // the two are on different sites. + skipStateCookieCheck: crossSite, }, socialProviders, From e03c908caa40f44088237a571f3d1bbdbe7e4bd4 Mon Sep 17 00:00:00 2001 From: Keegan Vaz Date: Tue, 8 Sep 2026 01:48:30 +0530 Subject: [PATCH 7/7] Support cross-site OAuth popup auth Enable popup/bearer auth for cross-origin sign-ins by allowing Authorization headers in CORS and attaching stored popup tokens to API requests. The sign-in page now uses the popup flow when cookies cannot cross sites, while same-origin logins keep the normal redirect flow. --- server/src/app.ts | 4 +++- server/src/auth.ts | 11 +++++++-- web/src/lib/api.ts | 6 +++++ web/src/lib/auth-client.ts | 25 ++++++++++++++++++-- web/src/pages/SignInPage.tsx | 45 ++++++++++++++++++++++++++++++------ 5 files changed, 79 insertions(+), 12 deletions(-) diff --git a/server/src/app.ts b/server/src/app.ts index f55b78e..771508d 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -25,7 +25,9 @@ export function createApp() { cors({ origin: config.webOrigin, credentials: true, - allowHeaders: ['Content-Type'], + // Authorization carries the bearer token when the session cookie cannot + // cross sites — see the oauthPopup/bearer plugins in auth.ts. + allowHeaders: ['Content-Type', 'Authorization'], allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], }), ) diff --git a/server/src/auth.ts b/server/src/auth.ts index fb76bd1..afb9a8f 100644 --- a/server/src/auth.ts +++ b/server/src/auth.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto' import { betterAuth } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' -import { genericOAuth } from 'better-auth/plugins' +import { genericOAuth, bearer, oauthPopup } from 'better-auth/plugins' import { eq } from 'drizzle-orm' import { db, schema } from '@/db' import { config } from '@/lib/env' @@ -223,7 +223,14 @@ export const auth = betterAuth({ socialProviders, - plugins: orcidConfig.length ? [genericOAuth({ config: orcidConfig })] : [], + plugins: [ + ...(orcidConfig.length ? [genericOAuth({ config: orcidConfig })] : []), + // Cross-site, the session cookie is third-party and current browsers drop + // it, so the SPA never sees a session however well the callback goes. The + // popup flow posts the session token back to the opener instead, and bearer + // lets the SPA present it as an Authorization header. + ...(crossSite ? [oauthPopup(), bearer()] : []), + ], advanced: { cookiePrefix: 'replicate-this', diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 4a6cc68..448a7b3 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,3 +1,4 @@ +import { getStoredPopupToken } from 'better-auth/client/plugins' import { apiOrigin } from './config' /** @@ -18,10 +19,15 @@ export class ApiError extends Error { } async function request(path: string, init?: RequestInit): Promise { + // Cross-origin the session cookie is dropped by the browser, so the token the + // sign-in popup handed back is what actually authenticates these calls. + const token = getStoredPopupToken() + const res = await fetch(`${apiOrigin}/api${path}`, { credentials: 'include', headers: { 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(init?.headers ?? {}), }, ...init, diff --git a/web/src/lib/auth-client.ts b/web/src/lib/auth-client.ts index 6dddd95..4881f70 100644 --- a/web/src/lib/auth-client.ts +++ b/web/src/lib/auth-client.ts @@ -1,16 +1,37 @@ import { createAuthClient } from 'better-auth/react' -import { genericOAuthClient } from 'better-auth/client/plugins' +import { + genericOAuthClient, + oauthPopupClient, + getStoredPopupToken, +} from 'better-auth/client/plugins' import { apiOrigin } from './config' /** * Better Auth lives in the Hono server under /api/auth. In dev, Vite proxies * /api to the server (see vite.config.ts); on GitHub Pages the server is on a * separate origin, so VITE_API_ORIGIN points the client at it. + * + * Cross-origin the session cookie is third-party and browsers drop it, so + * sign-in goes through a popup that hands back a token. The bundled fetch + * plugin only attaches that token inside an iframe, so a top-level page has to + * send it itself. */ export const authClient = createAuthClient({ ...(apiOrigin ? { baseURL: apiOrigin } : {}), basePath: '/api/auth', - plugins: [genericOAuthClient()], + plugins: [genericOAuthClient(), oauthPopupClient()], + fetchOptions: { + onRequest(context) { + const token = getStoredPopupToken() + if (!token) return context + const headers = new Headers(context.headers) + if (!headers.has('authorization')) headers.set('authorization', `Bearer ${token}`) + return { ...context, headers } + }, + }, }) +/** True when the SPA and API are on different origins, so cookies won't carry. */ +export const usePopupSignIn = apiOrigin !== '' + export const { useSession, signIn, signOut } = authClient diff --git a/web/src/pages/SignInPage.tsx b/web/src/pages/SignInPage.tsx index 6927c83..8c4dc17 100644 --- a/web/src/pages/SignInPage.tsx +++ b/web/src/pages/SignInPage.tsx @@ -1,6 +1,7 @@ -import { useSearchParams } from 'react-router-dom' +import { useState } from 'react' +import { useNavigate, useSearchParams } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' -import { signIn, authClient } from '@/lib/auth-client' +import { signIn, authClient, usePopupSignIn } from '@/lib/auth-client' import { api } from '@/lib/api' import { withBase } from '@/lib/config' @@ -17,6 +18,32 @@ export function SignInPage() { // strips the base path from the redirect, so put it back. const callbackURL = `${window.location.origin}${withBase(params.get('redirect') ?? '/dashboard')}` + const navigate = useNavigate() + const [error, setError] = useState(null) + const target = params.get('redirect') ?? '/dashboard' + + /** + * Cross-origin the session cookie never reaches the SPA, so sign-in runs in a + * popup that returns a token. Same-origin keeps the plain redirect flow. + */ + async function start(provider: { provider: string } | { providerId: string }) { + if (!usePopupSignIn) { + if ('provider' in provider) { + await signIn.social({ provider: provider.provider, callbackURL }) + } else { + await authClient.signIn.oauth2({ providerId: provider.providerId, callbackURL }) + } + return + } + setError(null) + const { error: popupError } = await authClient.signIn.popup({ ...provider, callbackURL }) + if (popupError) { + setError(popupError.message || 'Sign-in was cancelled.') + return + } + navigate(target, { replace: true }) + } + const { data: providers, isLoading } = useQuery({ queryKey: ['providers'], queryFn: () => api.get('/providers'), @@ -41,21 +68,25 @@ export function SignInPage() {

)} + {error && ( +

+ {error} +

+ )} +
{providers?.google && ( - signIn.social({ provider: 'google', callbackURL })}> + start({ provider: 'google' })}> Continue with Google )} {providers?.github && ( - signIn.social({ provider: 'github', callbackURL })}> + start({ provider: 'github' })}> Continue with GitHub )} {providers?.orcid && ( - authClient.signIn.oauth2({ providerId: 'orcid', callbackURL })} - > + start({ providerId: 'orcid' })}> Continue with ORCID )}