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
7 changes: 5 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,11 @@ GOOGLE_OAUTH_SECRET=your_google_oauth_secret
# DATABASE_URL is the pooled endpoint (hostname ends in -pooler) used by the running app.
# DIRECT_URL is the unpooled endpoint used by Prisma Migrate, which cannot run through a
# transaction-mode pooler. Both are shown by `neon connection-string <branch>`.
DATABASE_URL=postgresql://user:password@ep-example-pooler.region.aws.neon.tech/neondb?sslmode=require
DIRECT_URL=postgresql://user:password@ep-example.region.aws.neon.tech/neondb?sslmode=require
# `connect_timeout=15` is required, not cosmetic: Neon's free tier scales compute
# to zero when idle and a cold start can exceed Prisma's 5 s default, which fails
# the first request after a quiet period with "Can't reach database server".
DATABASE_URL=postgresql://user:password@ep-example-pooler.region.aws.neon.tech/neondb?sslmode=require&connect_timeout=15
DIRECT_URL=postgresql://user:password@ep-example.region.aws.neon.tech/neondb?sslmode=require&connect_timeout=15

# Cloudflare R2 object storage (S3-compatible)
# The API token needs Object Read & Write on this bucket only. The endpoint is derived as
Expand Down
4 changes: 4 additions & 0 deletions RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ Copy these values from Supabase Settings:

Postgres and object storage no longer come from Supabase. Take the database connection strings from Neon (`neon connection-string <branch>`, once pooled and once without `--pooled`, into `DATABASE_URL` and `DIRECT_URL`) and the four `R2_*` values from Cloudflare R2.

> **Append `&connect_timeout=15` to both Neon URLs.** The free tier scales compute to zero when idle, and a cold start can exceed Prisma's 5-second default — without this, the first request after a quiet period fails with `Can't reach database server`. Reproduced and fixed on 30/07/26: a suspended compute returned 500 on sign-in without the setting and 200 in 2.8 s with it.
>
> **Never run `neon branches create` bare** — it prints connection URIs, and Neon roles are project-scoped, so a throwaway branch exposes the same password `main` uses. Redirect stdout or use `-o json` and filter.

Create the bucket that stores Solar API GeoTIFFs. The backend expects the bucket name `geotiffs`.

```sql
Expand Down
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@prisma/client": "^6.6.0",
"@shared/types": "workspace:*",
"@supabase/supabase-js": "^2.49.4",
"better-auth": "^1.6.25",
"compression": "^1.8.1",
"cors": "^2.8.5",
"dotenv": "^16.5.0",
Expand Down
3 changes: 3 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { tariffRouter } from './routes/tariff.js'
import { errorHandler } from './middleware/errorHandler.js'
import { requestLogger } from './middleware/requestLogger.js'
import { env } from './config/env.js'
import { toNodeHandler } from 'better-auth/node'
import { auth } from './config/auth.js'

const __dirname = path.dirname(fileURLToPath(import.meta.url))

Expand Down Expand Up @@ -64,6 +66,7 @@ app.use(
}
})
)
app.all('/api/auth/*splat', toNodeHandler(auth))
app.use(express.json())
app.use(requestLogger)

Expand Down
79 changes: 79 additions & 0 deletions backend/src/config/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Better Auth server configuration — the single source of identity for the app.
*
* Replaces Supabase Auth. Sessions and accounts live in the same Neon database
* as application data, so there is no external identity provider that can pause
* or change terms underneath us.
*/

import { betterAuth } from 'better-auth'
import { prismaAdapter } from 'better-auth/adapters/prisma'
import { env } from './env.js'
import { prisma } from './prisma.js'
import { sendVerificationEmail, sendPasswordResetEmail } from '../services/emailService.js'

export const auth = betterAuth({
baseURL: env.BETTER_AUTH_URL,
secret: env.BETTER_AUTH_SECRET,
database: prismaAdapter(prisma, { provider: 'postgresql' }),
trustedOrigins: [env.FRONTEND_URL],

emailAndPassword: {
enabled: true,
// Matches the retired Supabase `enable_confirmations = true`: a new account
// cannot sign in until its address is verified.
requireEmailVerification: true,
autoSignIn: false,
sendResetPassword: async ({ user, url }) => {
await sendPasswordResetEmail(user.email, url)
}
},

emailVerification: {
sendOnSignUp: true,
sendOnSignIn: true,
sendVerificationEmail: async ({ user, url }) => {
await sendVerificationEmail(user.email, url)
}
},

socialProviders: {
google: {
clientId: env.GOOGLE_OAUTH_CLIENT_ID,
clientSecret: env.GOOGLE_OAUTH_SECRET
}
},

account: {
// Google access/refresh tokens are stored in the `account` table; encrypt
// them at rest rather than accepting the plaintext default.
encryptOAuthTokens: true,
accountLinking: {
// Mirrors the retired `enable_manual_linking = false`: signing in with
// Google using an address that already has a password account lands on
// that same account instead of creating a duplicate.
enabled: true,
trustedProviders: ['google']
}
},

user: {
additionalFields: {
// Subscription tier drives daily project quota. Server-owned: `input: false`
// stops a client from promoting itself by posting a tier on signup.
tier: {
type: 'string',
required: false,
defaultValue: 'FREE',
input: false
},
// UI language, persisted server-side so the choice follows the user
// across devices. Client-writable, unlike tier.
locale: {
type: 'string',
required: false,
input: true
}
}
}
})
4 changes: 4 additions & 0 deletions backend/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ const envSchema = z
R2_ACCESS_KEY_ID: z.string().min(1),
R2_SECRET_ACCESS_KEY: z.string().min(1),
R2_BUCKET: z.string().min(1),
BETTER_AUTH_SECRET: z.string().min(32),
BETTER_AUTH_URL: z.string().url(),
GOOGLE_OAUTH_CLIENT_ID: z.string().min(1),
GOOGLE_OAUTH_SECRET: z.string().min(1),
FRONTEND_URL: z.string().url().optional().default('http://localhost:5173'),
PDF_TOKEN_SECRET: z.string().min(32),
GEMINI_API_KEY: z.preprocess((val) => (val === '' ? undefined : val), z.string().min(1).optional()),
Expand Down
25 changes: 9 additions & 16 deletions backend/src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
/**
* Supabase bearer-token authentication middleware.
* Better Auth session authentication middleware.
*
* Verifies API requests against Supabase Auth and attaches the authenticated
* Verifies API requests against Better Auth and attaches the authenticated
* user identity to Express requests for downstream route ownership checks.
*/

import type { Request, Response, NextFunction } from 'express'
import { supabase } from '../config/supabase.js'
import { fromNodeHeaders } from 'better-auth/node'
import { auth } from '../config/auth.js'

declare global {
namespace Express {
Expand All @@ -18,31 +19,23 @@ declare global {
}

/**
* Verifies the `Authorization: Bearer <token>` session and stores the Supabase
* Verifies the Better Auth session and stores the authenticated
* user id/email on `req.user`.
*
* @param req - Incoming request carrying a Supabase access token
* @param req - Incoming request carrying Better Auth session cookies
* @param res - Response used for unauthorised JSON failures
* @param next - Continuation called after successful authentication
*/
export async function requireAuth(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
console.warn(`[Auth] Missing bearer token for ${req.method} ${req.originalUrl}`)
res.status(401).json({ error: 'Unauthorized' })
return
}

const token = authHeader.slice(7)
const { data, error } = await supabase.auth.getUser(token)
const session = await auth.api.getSession({ headers: fromNodeHeaders(req.headers) })

if (error || !data.user) {
if (!session?.user) {
console.warn(`[Auth] Invalid session for ${req.method} ${req.originalUrl}`)
res.status(401).json({ error: 'Unauthorized' })
return
}

req.user = { id: data.user.id, email: data.user.email ?? '' }
req.user = { id: session.user.id, email: session.user.email ?? '' }
console.info(`[Auth] user=${req.user.id} ${req.method} ${req.originalUrl}`)
next()
}
32 changes: 12 additions & 20 deletions backend/src/services/__tests__/userService.test.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,18 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { count, single, eq, select, from } = vi.hoisted(() => {
const single = vi.fn()
const eq = vi.fn(() => ({ single }))
const select = vi.fn(() => ({ eq }))
const from = vi.fn(() => ({ select }))
const { count, findUnique } = vi.hoisted(() => {
const findUnique = vi.fn()
const count = vi.fn()
return { count, single, eq, select, from }
return { count, findUnique }
})

vi.mock('../../config/prisma.js', () => ({
prisma: {
user: { findUnique },
projectQuotaUsage: { count }
}
}))

vi.mock('../../config/supabase.js', () => ({
supabase: { from }
}))

import { getQuotaSummary, startOfUtcDay, nextUtcMidnight } from '../userService.js'

describe('userService date helpers', () => {
Expand All @@ -40,15 +34,12 @@ describe('userService date helpers', () => {

describe('getQuotaSummary', () => {
beforeEach(() => {
single.mockReset()
findUnique.mockReset()
count.mockReset()
from.mockClear()
select.mockClear()
eq.mockClear()
})

it('returns FREE tier quota with used count and reset timestamp', async () => {
single.mockResolvedValue({ data: { tier: 'FREE' }, error: null })
findUnique.mockResolvedValue({ tier: 'FREE' })
count.mockResolvedValue(3)

const summary = await getQuotaSummary('user_1', new Date('2026-04-17T10:00:00.000Z'))
Expand All @@ -62,10 +53,11 @@ describe('getQuotaSummary', () => {
expect(count).toHaveBeenCalledWith({
where: { userId: 'user_1', createdAt: { gte: new Date('2026-04-17T00:00:00.000Z') } }
})
expect(findUnique).toHaveBeenCalledWith({ where: { id: 'user_1' }, select: { tier: true } })
})

it('blocks FREE user at 5/5 used (caller enforces)', async () => {
single.mockResolvedValue({ data: { tier: 'FREE' }, error: null })
findUnique.mockResolvedValue({ tier: 'FREE' })
count.mockResolvedValue(5)

const summary = await getQuotaSummary('user_1', new Date('2026-04-17T10:00:00.000Z'))
Expand All @@ -75,7 +67,7 @@ describe('getQuotaSummary', () => {
})

it('returns PRO tier with 20-project cap', async () => {
single.mockResolvedValue({ data: { tier: 'PRO' }, error: null })
findUnique.mockResolvedValue({ tier: 'PRO' })
count.mockResolvedValue(12)

const summary = await getQuotaSummary('user_2', new Date('2026-04-17T10:00:00.000Z'))
Expand All @@ -86,7 +78,7 @@ describe('getQuotaSummary', () => {
})

it('returns ENTERPRISE tier with null (unlimited) limit', async () => {
single.mockResolvedValue({ data: { tier: 'ENTERPRISE' }, error: null })
findUnique.mockResolvedValue({ tier: 'ENTERPRISE' })
count.mockResolvedValue(99)

const summary = await getQuotaSummary('user_3', new Date('2026-04-17T10:00:00.000Z'))
Expand All @@ -97,7 +89,7 @@ describe('getQuotaSummary', () => {
})

it('falls back to FREE when profile row is missing', async () => {
single.mockResolvedValue({ data: null, error: { message: 'not found' } })
findUnique.mockResolvedValue(null)
count.mockResolvedValue(0)

const summary = await getQuotaSummary('ghost', new Date('2026-04-17T10:00:00.000Z'))
Expand All @@ -107,7 +99,7 @@ describe('getQuotaSummary', () => {
})

it('resets used count after UTC midnight (injectable clock)', async () => {
single.mockResolvedValue({ data: { tier: 'FREE' }, error: null })
findUnique.mockResolvedValue({ tier: 'FREE' })
count.mockResolvedValueOnce(5).mockResolvedValueOnce(0)

const before = await getQuotaSummary('user_1', new Date('2026-04-17T23:59:00.000Z'))
Expand Down
27 changes: 27 additions & 0 deletions backend/src/services/emailService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Transactional email dispatch.
*
* Placeholder implementation: issue #7 replaces these bodies with direct Resend
* calls and the branded templates ported out of `supabase/templates/`. Until
* then the links are logged so local sign-up flows remain completable.
*/

/**
* Sends an address-verification link to a newly registered user.
*
* @param email - Recipient address
* @param url - Better Auth verification link
*/
export async function sendVerificationEmail(email: string, url: string): Promise<void> {
console.info(`[Email] verification for ${email}: ${url}`)
}

/**
* Sends a password-reset link.
*
* @param email - Recipient address
* @param url - Better Auth password-reset link
*/
export async function sendPasswordResetEmail(email: string, url: string): Promise<void> {
console.info(`[Email] password reset for ${email}: ${url}`)
Comment on lines +15 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

Replace token logging with real email delivery before enabling this flow.

requireEmailVerification makes new accounts unusable without delivery, while logging raw reset URLs exposes bearer credentials to anyone with log access and enables account takeover. Send these links through the email provider and never log the URL or recipient email in production.

🧰 Tools
🪛 ast-grep (0.45.0)

[warning] 15-15: Avoid logging sensitive data
Context: console.info([Email] verification for ${email}: ${url})
Note: [CWE-532] Insertion of Sensitive Information into Log File.

(log-sensitive-data-typescript)


[warning] 25-25: Avoid logging sensitive data
Context: console.info([Email] password reset for ${email}: ${url})
Note: [CWE-532] Insertion of Sensitive Information into Log File.

(log-sensitive-data-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/services/emailService.ts` around lines 15 - 26, Update
sendVerificationEmail and sendPasswordResetEmail to deliver the links through
the configured email provider instead of logging them. Remove console.info calls
and ensure neither the recipient address nor the URL is written to production
logs, while preserving each function’s existing parameters and async behavior.

Source: Linters/SAST tools

}
15 changes: 7 additions & 8 deletions backend/src/services/userService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
* UTC reset windows.
*/

import { supabase } from '../config/supabase.js'
import { prisma } from '../config/prisma.js'
import { TIER_DAILY_LIMITS, type UserTier, type QuotaSummary } from '@shared/types'

Expand Down Expand Up @@ -34,18 +33,18 @@ export function nextUtcMidnight(now: Date = new Date()): Date {
}

/**
* Reads the user's subscription tier from Supabase profiles.
* Reads the user's subscription tier from the application user record.
*
* @param userId - Authenticated user id matching the profile row
* @returns User tier, defaulting to `FREE` when the profile is missing
* @param userId - Authenticated user id matching the user record
* @returns User tier, defaulting to `FREE` when the user is missing
*/
export async function getUserTier(userId: string): Promise<UserTier> {
const { data, error } = await supabase.from('profiles').select('tier').eq('id', userId).single()
if (error || !data) {
console.warn(`[UserTier] profile missing for user=${userId}, defaulting to FREE`, error?.message ?? '')
const user = await prisma.user.findUnique({ where: { id: userId }, select: { tier: true } })
if (!user) {
console.warn(`[UserTier] profile missing for user=${userId}, defaulting to FREE`)
return 'FREE'
}
return data.tier as UserTier
return user.tier
}

/**
Expand Down
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@shared/types": "workspace:*",
"@supabase/supabase-js": "^2.98.0",
"@tanstack/react-query": "^5.75.5",
"better-auth": "^1.6.25",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.38.0",
Expand Down
Loading
Loading