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
168 changes: 168 additions & 0 deletions packages/auth-service/src/__tests__/pds-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/**
* Tests for ensurePdsUrl() and requireInternalEnv().
*
* Both are pure startup-validation helpers — no I/O, no mocks needed
* beyond temporarily mutating process.env.
*/
import { describe, it, expect, afterEach } from 'vitest'
import { ensurePdsUrl } from '../lib/pds-url.js'
import { requireInternalEnv } from '../lib/require-internal-env.js'

// ---------------------------------------------------------------------------
// ensurePdsUrl
// ---------------------------------------------------------------------------

describe('ensurePdsUrl', () => {
describe('valid URLs', () => {
it('accepts http:// URL', () => {
expect(ensurePdsUrl('http://example.com')).toBe('http://example.com')
})

it('accepts https:// URL', () => {
expect(ensurePdsUrl('https://example.com')).toBe('https://example.com')
})

it('accepts HTTPS:// with uppercase scheme', () => {
expect(ensurePdsUrl('HTTPS://example.com')).toBe('HTTPS://example.com')
})

it('strips a single trailing slash', () => {
expect(ensurePdsUrl('https://example.com/')).toBe('https://example.com')
})

it('strips multiple trailing slashes', () => {
expect(ensurePdsUrl('https://example.com///')).toBe('https://example.com')
})

it('preserves path segments that are not trailing slashes', () => {
expect(ensurePdsUrl('https://example.com/foo/bar')).toBe(
'https://example.com/foo/bar',
)
})
})

describe('fallback', () => {
it('uses fallback when raw is undefined', () => {
expect(ensurePdsUrl(undefined, 'https://fallback.com')).toBe(
'https://fallback.com',
)
})

it('uses fallback when raw is empty string', () => {
expect(ensurePdsUrl('', 'https://fallback.com')).toBe(
'https://fallback.com',
)
})

it('prefers raw over fallback when raw is valid', () => {
expect(ensurePdsUrl('https://primary.com', 'https://fallback.com')).toBe(
'https://primary.com',
)
})
})

describe('missing URL', () => {
it('throws when raw is undefined and no fallback given', () => {
expect(() => ensurePdsUrl(undefined)).toThrow(
'PDS_INTERNAL_URL is not set and no fallback URL was provided',
)
})

it('throws when raw is empty string and no fallback given', () => {
expect(() => ensurePdsUrl('')).toThrow(
'PDS_INTERNAL_URL is not set and no fallback URL was provided',
)
})
})

describe('missing scheme', () => {
it('throws for bare hostname', () => {
expect(() => ensurePdsUrl('core.railway.internal')).toThrow(
'PDS_INTERNAL_URL is missing the http:// or https:// scheme: "core.railway.internal"',
)
})

it('throws for hostname with path but no scheme', () => {
expect(() => ensurePdsUrl('example.com/foo')).toThrow(
'PDS_INTERNAL_URL is missing the http:// or https:// scheme',
)
})

it('throws for ftp:// scheme', () => {
expect(() => ensurePdsUrl('ftp://example.com')).toThrow(
'PDS_INTERNAL_URL is missing the http:// or https:// scheme',
)
})

it('throws when fallback also lacks a scheme', () => {
expect(() => ensurePdsUrl(undefined, 'core.railway.internal')).toThrow(
'PDS_INTERNAL_URL is missing the http:// or https:// scheme',
)
})
})
})

// ---------------------------------------------------------------------------
// requireInternalEnv
// ---------------------------------------------------------------------------

describe('requireInternalEnv', () => {
const ORIGINAL_ENV = process.env

afterEach(() => {
process.env = { ...ORIGINAL_ENV }
})

function setEnv(vars: Record<string, string | undefined>) {
process.env = { ...ORIGINAL_ENV, ...vars }
}

it('returns pdsUrl and internalSecret when both are valid', () => {
setEnv({
PDS_INTERNAL_URL: 'https://core.internal',
EPDS_INTERNAL_SECRET: 'secret123',
})
expect(requireInternalEnv()).toEqual({
pdsUrl: 'https://core.internal',
internalSecret: 'secret123',
})
})

it('strips trailing slash from PDS_INTERNAL_URL', () => {
setEnv({
PDS_INTERNAL_URL: 'https://core.internal/',
EPDS_INTERNAL_SECRET: 'secret123',
})
expect(requireInternalEnv().pdsUrl).toBe('https://core.internal')
})

it('throws when EPDS_INTERNAL_SECRET is missing', () => {
setEnv({
PDS_INTERNAL_URL: 'https://core.internal',
EPDS_INTERNAL_SECRET: undefined,
})
expect(() => requireInternalEnv()).toThrow(
'EPDS_INTERNAL_SECRET must be set',
)
})

it('throws when PDS_INTERNAL_URL is missing', () => {
setEnv({
PDS_INTERNAL_URL: undefined,
EPDS_INTERNAL_SECRET: 'secret123',
})
expect(() => requireInternalEnv()).toThrow(
'PDS_INTERNAL_URL is not set and no fallback URL was provided',
)
})

it('throws when PDS_INTERNAL_URL lacks a scheme', () => {
setEnv({
PDS_INTERNAL_URL: 'core.railway.internal',
EPDS_INTERNAL_SECRET: 'secret123',
})
expect(() => requireInternalEnv()).toThrow(
'PDS_INTERNAL_URL is missing the http:// or https:// scheme: "core.railway.internal"',
)
})
})
8 changes: 5 additions & 3 deletions packages/auth-service/src/better-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { emailOTP } from 'better-auth/plugins'
import Database from 'better-sqlite3'
import type { EmailSender } from './email/sender.js'
import { getDidByEmail } from './lib/get-did-by-email.js'
import { ensurePdsUrl } from './lib/pds-url.js'

export type BetterAuthInstance = ReturnType<typeof createBetterAuth>

Expand Down Expand Up @@ -187,9 +188,10 @@ export function createBetterAuth(
async sendVerificationOTP({ email, otp }, ctx) {
// Determine whether this is a first-time sign-up or a returning user
// by checking if a PDS account already exists for this email.
const pdsUrl =
process.env.PDS_INTERNAL_URL ||
`https://${process.env.PDS_HOSTNAME ?? 'localhost'}`
const pdsUrl = ensurePdsUrl(
process.env.PDS_INTERNAL_URL,
`https://${process.env.PDS_HOSTNAME ?? 'localhost'}`,
)
const internalSecret = process.env.EPDS_INTERNAL_SECRET ?? ''
const did = await getDidByEmail(email, pdsUrl, internalSecret)
const isNewUser = !did
Expand Down
6 changes: 5 additions & 1 deletion packages/auth-service/src/lib/auto-provision.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { AuthServiceContext } from '../context.js'
import { createLogger, generateRandomHandle } from '@certified-app/shared'
import { ensurePdsUrl } from './pds-url.js'

const logger = createLogger('auth:auto-provision')

Expand All @@ -15,7 +16,10 @@ export async function autoProvisionAccount(
email: string,
): Promise<string | null> {
// Use internal Docker URL to avoid going through Caddy
const pdsUrl = process.env.PDS_INTERNAL_URL || ctx.config.pdsPublicUrl
const pdsUrl = ensurePdsUrl(
process.env.PDS_INTERNAL_URL,
ctx.config.pdsPublicUrl,
)

const handle = generateRandomHandle(ctx.config.pdsHostname)

Expand Down
28 changes: 28 additions & 0 deletions packages/auth-service/src/lib/pds-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Validates and returns the internal PDS URL.
*
* Ensures the URL includes an HTTP(S) scheme — a missing scheme causes
* `fetch()` to throw `TypeError: Invalid URL` at runtime, which is hard
* to diagnose in production (see: Railway prod incident with
* `certified-apppds-core.railway.internal/…`).
*/
export function ensurePdsUrl(
raw: string | undefined,
fallback?: string,
): string {
const url = raw || fallback
if (!url) {
throw new Error(
'PDS_INTERNAL_URL is not set and no fallback URL was provided',
)
}

if (!/^https?:\/\//i.test(url)) {
throw new Error(
`PDS_INTERNAL_URL is missing the http:// or https:// scheme: "${url}"`,
)
}

// Strip trailing slash for consistent concatenation
return url.replace(/\/+$/, '')
}
18 changes: 8 additions & 10 deletions packages/auth-service/src/lib/require-internal-env.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
import { ensurePdsUrl } from './pds-url.js'

/**
* Validate that PDS_INTERNAL_URL and EPDS_INTERNAL_SECRET are set.
* Validate that PDS_INTERNAL_URL and EPDS_INTERNAL_SECRET are set,
* and that PDS_INTERNAL_URL includes an http(s) scheme.
*
* Called at router-creation time so the process fails fast at startup
* rather than at first request. The error message names exactly which
* variable(s) are missing.
* variable(s) are missing or malformed.
*/
export function requireInternalEnv(): {
pdsUrl: string
internalSecret: string
} {
const pdsUrl = process.env.PDS_INTERNAL_URL
const internalSecret = process.env.EPDS_INTERNAL_SECRET
if (!pdsUrl || !internalSecret) {
const missing = [
...(!pdsUrl ? ['PDS_INTERNAL_URL'] : []),
...(!internalSecret ? ['EPDS_INTERNAL_SECRET'] : []),
]
throw new Error(`${missing.join(' and ')} must be set`)
if (!internalSecret) {
throw new Error('EPDS_INTERNAL_SECRET must be set')
}
return { pdsUrl, internalSecret }
return { pdsUrl: ensurePdsUrl(process.env.PDS_INTERNAL_URL), internalSecret }
}
6 changes: 5 additions & 1 deletion packages/auth-service/src/routes/account-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '@certified-app/shared'
import { fromNodeHeaders } from 'better-auth/node'
import { getDidByEmail } from '../lib/get-did-by-email.js'
import { ensurePdsUrl } from '../lib/pds-url.js'

const logger = createLogger('auth:account-settings')

Expand Down Expand Up @@ -47,7 +48,10 @@ export function createAccountSettingsRouter(
const router = Router()
const requireAuth = requireBetterAuth(auth)

const pdsUrl = process.env.PDS_INTERNAL_URL || ctx.config.pdsPublicUrl
const pdsUrl = ensurePdsUrl(
process.env.PDS_INTERNAL_URL,
ctx.config.pdsPublicUrl,
)
const internalSecret = process.env.EPDS_INTERNAL_SECRET ?? ''

// GET /account - main settings page
Expand Down
7 changes: 5 additions & 2 deletions packages/auth-service/src/routes/login-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
resolveLoginHint,
fetchParLoginHint,
} from '../lib/resolve-login-hint.js'
import { ensurePdsUrl } from '../lib/pds-url.js'

const logger = createLogger('auth:login-page')

Expand Down Expand Up @@ -128,8 +129,10 @@ export function createLoginPageRouter(ctx: AuthServiceContext): Router {
// b) On the query string as a handle/DID (unlikely but possible)
// c) Only in the stored PAR request (third-party apps like sdsls.dev put
// the handle in the PAR body but don't duplicate it on the redirect URL)
const pdsInternalUrl =
process.env.PDS_INTERNAL_URL || ctx.config.pdsPublicUrl
const pdsInternalUrl = ensurePdsUrl(
process.env.PDS_INTERNAL_URL,
ctx.config.pdsPublicUrl,
)
const internalSecret = process.env.EPDS_INTERNAL_SECRET ?? ''

// If no login_hint on the query string, try to retrieve it from the PAR request
Expand Down
Loading