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
27 changes: 27 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 6 additions & 0 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
44 changes: 25 additions & 19 deletions src/clerk-auth.js
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down Expand Up @@ -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') {
Expand Down
34 changes: 27 additions & 7 deletions src/github-auth.js
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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,
Expand All @@ -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 };

Expand All @@ -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 };
Expand Down Expand Up @@ -209,6 +228,7 @@ window.GitHubAuth = {

// Mode-aware request layer (used by github-api.js, repo.js and labels.js)
isGitHubAuthed,
getApiBase,
buildGitHubRequest,
githubFetch,

Expand Down
5 changes: 3 additions & 2 deletions src/github.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
40 changes: 35 additions & 5 deletions tests/clerk-auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
19 changes: 18 additions & 1 deletion tests/github-auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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'));
Expand Down
7 changes: 7 additions & 0 deletions tests/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down
Loading