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/app.ts b/server/src/app.ts
index c2c7f77..771508d 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -25,11 +25,20 @@ 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'],
}),
)
+ // 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))
diff --git a/server/src/auth.ts b/server/src/auth.ts
index fe3054b..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'
@@ -212,11 +212,25 @@ 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,
- 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/server/src/db/migrate.ts b/server/src/db/migrate.ts
index 53ff232..e956fdf 100644
--- a/server/src/db/migrate.ts
+++ b/server/src/db/migrate.ts
@@ -15,19 +15,58 @@ 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 { hostname, port, pathname } = new URL(config.databaseUrl)
+ 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 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}`,
+ // 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
- if (hint === null) throw error
+ 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_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,
+ 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])
+
+ // 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)
+ }
+ }
+
process.exit(1)
}
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
)}