diff --git a/package-lock.json b/package-lock.json index 3895842..72e8bce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@clerk/express": "^2.1.32", + "cors": "^2.8.5", "dompurify": "^3.2.6", "express": "^4.22.2", "markdown-it": "^14.1.0" @@ -2121,6 +2122,23 @@ "dev": true, "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -4776,6 +4794,15 @@ "dev": true, "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", diff --git a/package.json b/package.json index ca944c1..e4439d3 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ }, "dependencies": { "@clerk/express": "^2.1.32", + "cors": "^2.8.5", "dompurify": "^3.2.6", "express": "^4.22.2", "markdown-it": "^14.1.0" diff --git a/server.js b/server.js index 0769a7d..3e2ecba 100644 --- a/server.js +++ b/server.js @@ -8,9 +8,15 @@ // the repo root, so server code, package metadata and any .env stay private. const express = require('express'); const path = require('path'); +const cors = require('cors'); const { clerkMiddleware, getAuth, clerkClient } = require('@clerk/express'); const app = express(); +// Allow cross-origin calls so a statically-hosted frontend (e.g. the GitHub +// Pages build on dashban.com) can reach this API on its own Railway origin. +// Requests must still carry a valid Clerk session token to use the GitHub proxy, +// so opening CORS here does not by itself grant any access. +app.use(cors()); app.use(express.json()); const ROOT = __dirname; diff --git a/src/clerk-auth.js b/src/clerk-auth.js index c5e22a0..3ee2006 100644 --- a/src/clerk-auth.js +++ b/src/clerk-auth.js @@ -1,28 +1,36 @@ // Clerk-based "Sign in with GitHub" for Dashban — the only authentication method. // -// When the app is served by its backend (e.g. on Railway) the user signs in with -// their own GitHub account through Clerk, and GitHub API calls are routed through -// the server-side proxy, which attaches the user's own token. The browser never -// sees a GitHub token. +// The user signs in with their own GitHub account through Clerk, and GitHub API +// calls are routed through the backend proxy, which attaches the user's own +// token. The browser never sees a GitHub token. // -// When the app is served without a backend (the static GitHub Pages build), -// /api/config is unavailable and Clerk stays disabled — that build is then -// read-only (anonymous, public issues only). Initialization is driven by +// This works whether the app is served by its own backend (Railway) or as a +// static build on another host (e.g. GitHub Pages on dashban.com): config is +// fetched from the backend via getApiBase(), and if that is unreachable we fall +// back to the built-in publishable key so Clerk still initializes. Clerk-js is +// loaded cross-origin from Clerk's frontend API. Initialization is driven by // github.js so the module has no side effects at load time. (function () { 'use strict'; + // Browser-safe Clerk publishable key. Used as a fallback so Clerk can still + // initialize when the backend's /api/config is unreachable (e.g. on the + // static GitHub Pages build, or before the cross-origin request resolves). + const DEFAULT_CLERK_PUBLISHABLE_KEY = 'pk_test_YWJsZS1hbGJhY29yZS01Ny5jbGVyay5hY2NvdW50cy5kZXYk'; + const state = { available: false, // Clerk has loaded and is ready to use publishableKey: null, githubRepo: null }; - // Read the browser-safe config the backend exposes. Returns null when there - // is no backend (static hosting) or the request otherwise fails. + // Read the browser-safe config the backend exposes (via getApiBase() so the + // static build can reach the backend cross-origin). Returns null when the + // backend is unreachable or the request otherwise fails. async function fetchConfig() { try { - const response = await fetch('/api/config'); + const base = window.GitHubAuth?.getApiBase?.() || ''; + const response = await fetch(`${base}/api/config`); if (!response.ok) { return null; } @@ -128,19 +136,17 @@ } // Initialize Clerk. Resolves to true when Clerk is available for use, false - // when there is no backend/config or clerk-js fails to load. + // when clerk-js fails to load. async function initialize() { const config = await fetchConfig(); - if (!config || !config.clerkPublishableKey) { - state.available = false; - return false; - } - - state.publishableKey = config.clerkPublishableKey; - state.githubRepo = config.githubRepo || null; + // Fall back to the built-in key so Clerk still initializes when the + // backend config is unreachable (e.g. the static GitHub Pages build). + const publishableKey = (config && config.clerkPublishableKey) || DEFAULT_CLERK_PUBLISHABLE_KEY; + state.publishableKey = publishableKey; + state.githubRepo = (config && config.githubRepo) || null; try { - await loadClerkScript(config.clerkPublishableKey); + await loadClerkScript(publishableKey); } catch (error) { /* istanbul ignore next: console noise only runs outside the Jest test environment */ if (typeof jest === 'undefined') { diff --git a/src/github-auth.js b/src/github-auth.js index 6746ba2..e7aba8f 100644 --- a/src/github-auth.js +++ b/src/github-auth.js @@ -1,10 +1,12 @@ // GitHub Authentication for Dashban — Clerk "Sign in with GitHub" only. // // Authentication is handled by Clerk (see clerk-auth.js). When a user signs in, -// GitHub API calls are routed through the server-side proxy (/api/github), which +// GitHub API calls are routed through the backend proxy (/api/github), which // attaches the user's own GitHub token — so no token is ever stored in the -// browser. Unauthenticated visitors get read-only public access straight from -// api.github.com. +// browser. The proxy is same-origin when the app is served by its backend, and +// cross-origin (an absolute Railway URL, see getApiBase) when served as a static +// build such as GitHub Pages. Unauthenticated visitors get read-only public +// access straight from api.github.com. // GitHub configuration const GITHUB_CONFIG = { @@ -13,6 +15,22 @@ const GITHUB_CONFIG = { repo: 'dashban' }; +// Origin of the backend API. The frontend can be served three ways: +// • locally (localhost) — the dev server serves the API too, so relative paths. +// • by the backend itself on Railway — same origin, relative paths work. +// • as a static build on another host (e.g. GitHub Pages on dashban.com) — +// there is no co-located API, so call the Railway backend by its absolute +// URL (CORS is enabled server-side for this). +// `hostname` is injectable for testing; it defaults to the current page's host. +const BACKEND_ORIGIN = 'https://dashban-production.up.railway.app'; +function getApiBase(hostname) { + const host = hostname !== undefined ? hostname : window.location.hostname; + if (host === 'localhost' || host === '127.0.0.1') { + return ''; + } + return BACKEND_ORIGIN; +} + // GitHub authentication state. `mode` is 'clerk' when signed in via Clerk, else null. let githubAuth = { isAuthenticated: false, @@ -28,9 +46,10 @@ function isGitHubAuthed() { // Build the URL and headers for a GitHub REST request. // -// Authenticated calls go through the same-origin proxy with a short-lived Clerk -// session token (the proxy swaps in the user's real GitHub token). Anonymous -// calls go straight to GitHub for public, read-only access with no auth headers. +// Authenticated calls go through the backend proxy (getApiBase() picks the right +// origin) with a short-lived Clerk session token — the proxy swaps in the user's +// real GitHub token. Anonymous calls go straight to GitHub for public, read-only +// access with no auth headers. async function buildGitHubRequest(path, extraHeaders = {}) { const headers = { ...extraHeaders }; @@ -40,7 +59,7 @@ async function buildGitHubRequest(path, extraHeaders = {}) { headers['Authorization'] = `Bearer ${token}`; headers['Accept'] = 'application/vnd.github.v3+json'; } - return { url: `/api/github${path}`, headers }; + return { url: `${getApiBase()}/api/github${path}`, headers }; } return { url: `${GITHUB_CONFIG.apiBaseUrl}${path}`, headers }; @@ -209,6 +228,7 @@ window.GitHubAuth = { // Mode-aware request layer (used by github-api.js, repo.js and labels.js) isGitHubAuthed, + getApiBase, buildGitHubRequest, githubFetch, diff --git a/src/github.js b/src/github.js index bbf8022..a18954d 100644 --- a/src/github.js +++ b/src/github.js @@ -12,8 +12,9 @@ document.addEventListener('DOMContentLoaded', function() { // Render the initial (signed-out) auth UI. window.GitHubAuth.initializeGitHubAuth(); - // Initialize Clerk "Sign in with GitHub". It is a no-op on the static build - // where /api/config is absent (that build is read-only, public issues only). + // Initialize Clerk "Sign in with GitHub". This works on the static build too: + // config is fetched from the backend cross-origin (with a built-in fallback + // key), so sign-in is available wherever the app is served. if (window.ClerkAuth) { window.ClerkAuth.initialize(); } diff --git a/tests/clerk-auth.test.js b/tests/clerk-auth.test.js index 10ba7e3..b8c3536 100644 --- a/tests/clerk-auth.test.js +++ b/tests/clerk-auth.test.js @@ -39,6 +39,24 @@ describe('ClerkAuth', () => { global.fetch.mockRejectedValue(new Error('network')); await expect(ClerkAuth.fetchConfig()).resolves.toBeNull(); }); + + test('prefixes the API base from GitHubAuth (cross-origin static build)', async () => { + window.GitHubAuth = { getApiBase: () => 'https://dashban-production.up.railway.app' }; + global.fetch.mockResolvedValue({ ok: true, json: async () => ({ clerkPublishableKey: 'pk' }) }); + + await ClerkAuth.fetchConfig(); + + expect(global.fetch).toHaveBeenCalledWith('https://dashban-production.up.railway.app/api/config'); + }); + + test('uses a relative path when GitHubAuth exposes no getApiBase', async () => { + window.GitHubAuth = {}; + global.fetch.mockResolvedValue({ ok: true, json: async () => ({}) }); + + await ClerkAuth.fetchConfig(); + + expect(global.fetch).toHaveBeenCalledWith('/api/config'); + }); }); describe('frontendApiFromKey', () => { @@ -246,15 +264,27 @@ describe('ClerkAuth', () => { }); describe('initialize', () => { - test('returns false when there is no config (no backend)', async () => { + test('falls back to the built-in key when there is no backend config', async () => { + // No /api/config (e.g. the static GitHub Pages build). global.fetch.mockResolvedValue({ ok: false }); - await expect(ClerkAuth.initialize()).resolves.toBe(false); - expect(ClerkAuth.isAvailable()).toBe(false); + // Pre-set Clerk so loadClerkScript resolves immediately. + window.Clerk = { load: jest.fn().mockResolvedValue(), addListener: jest.fn(), user: null }; + + await expect(ClerkAuth.initialize()).resolves.toBe(true); + expect(ClerkAuth.isAvailable()).toBe(true); + expect(ClerkAuth.state.publishableKey) + .toBe('pk_test_YWJsZS1hbGJhY29yZS01Ny5jbGVyay5hY2NvdW50cy5kZXYk'); + expect(ClerkAuth.state.githubRepo).toBeNull(); }); - test('returns false when config has no publishable key', async () => { + test('falls back to the built-in key when config omits the publishable key', async () => { global.fetch.mockResolvedValue({ ok: true, json: async () => ({ githubRepo: 'a/b' }) }); - await expect(ClerkAuth.initialize()).resolves.toBe(false); + window.Clerk = { load: jest.fn().mockResolvedValue(), addListener: jest.fn(), user: null }; + + await expect(ClerkAuth.initialize()).resolves.toBe(true); + expect(ClerkAuth.state.publishableKey) + .toBe('pk_test_YWJsZS1hbGJhY29yZS01Ny5jbGVyay5hY2NvdW50cy5kZXYk'); + expect(ClerkAuth.state.githubRepo).toBe('a/b'); }); test('loads Clerk and wires up the listener on success', async () => { diff --git a/tests/github-auth.test.js b/tests/github-auth.test.js index 315df98..81d674e 100644 --- a/tests/github-auth.test.js +++ b/tests/github-auth.test.js @@ -82,6 +82,23 @@ describe('GitHub Authentication (Clerk-only)', () => { }); }); + describe('getApiBase', () => { + test('uses relative paths on localhost (the dev server serves the API too)', () => { + expect(window.GitHubAuth.getApiBase('localhost')).toBe(''); + expect(window.GitHubAuth.getApiBase('127.0.0.1')).toBe(''); + }); + + test('uses the absolute Railway backend URL for a static host (e.g. dashban.com)', () => { + expect(window.GitHubAuth.getApiBase('dashban.com')) + .toBe('https://dashban-production.up.railway.app'); + }); + + test('defaults to the current page hostname when none is given', () => { + // jsdom serves the tests from http://localhost, so this resolves to relative. + expect(window.GitHubAuth.getApiBase()).toBe(''); + }); + }); + describe('buildGitHubRequest', () => { test('routes through the proxy with a Bearer token when signed in', async () => { signInClerk(); @@ -338,7 +355,7 @@ describe('GitHub Authentication (Clerk-only)', () => { describe('Export API', () => { test('exposes the Clerk-only surface', () => { const api = window.GitHubAuth; - ['isGitHubAuthed', 'buildGitHubRequest', 'githubFetch', 'initializeGitHubAuth', + ['isGitHubAuthed', 'getApiBase', 'buildGitHubRequest', 'githubFetch', 'initializeGitHubAuth', 'signInWithGitHub', 'signOutGitHub', 'updateGitHubSignInUI', 'updateAddIssueButtonState', 'toggleUserDropdown', 'updateHeaderRepoName'] .forEach((fn) => expect(typeof api[fn]).toBe('function')); diff --git a/tests/server.test.js b/tests/server.test.js index cde5601..41dd84a 100644 --- a/tests/server.test.js +++ b/tests/server.test.js @@ -18,6 +18,13 @@ describe('Dashban server', () => { }); }); + describe('CORS', () => { + test('sends an allow-origin header so a static frontend can call the API', async () => { + const res = await request(app).get('/api/health'); + expect(res.headers['access-control-allow-origin']).toBe('*'); + }); + }); + describe('GET /api/config', () => { const ORIGINAL = { ...process.env };