Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
86a645c
refactor(worker): removes refresh and logout endpoints
wgordon17 Mar 24, 2026
5be4d48
refactor(auth): switches to localStorage token persistence for OAuth App
wgordon17 Mar 24, 2026
fd0f9e7
docs: updates documentation for OAuth App migration
wgordon17 Mar 24, 2026
3b6ded3
fix: addresses review findings from security/QA/structural review
wgordon17 Mar 24, 2026
5e3eb3a
fix: adds scope comment and clearAuth reentrancy guard
wgordon17 Mar 24, 2026
f206675
fix(auth): wraps clearAuth reentrancy guard in try/finally
wgordon17 Mar 24, 2026
885a783
refactor: removes void wrapper, adds try/finally guard
wgordon17 Mar 24, 2026
8736043
refactor(poll): updates stale comments for OAuth App model
wgordon17 Mar 24, 2026
301e7b3
fix(e2e): adds missing fullName to config seed repos
wgordon17 Mar 24, 2026
1f0c070
fix: addresses PR review findings from code review
wgordon17 Mar 24, 2026
686864e
fix(test): adds onAuthCleared test, migrates to vi.doMock
wgordon17 Mar 24, 2026
80479bc
fix(dashboard): preserves existing data during poll refresh
wgordon17 Mar 24, 2026
81f332a
feat(dashboard): caches data in localStorage for instant reload
wgordon17 Mar 24, 2026
f4e59dd
fix(poll): eagerly creates Octokit client for background refresh
wgordon17 Mar 24, 2026
351ff2a
fix(ui): restores refresh spinner and fades update label
wgordon17 Mar 24, 2026
7ef7f3f
fix(dashboard): calls destroy() on coordinator in onCleanup
wgordon17 Mar 24, 2026
631728e
fix: addresses domain review findings from quality gate
wgordon17 Mar 24, 2026
175d5e6
refactor: moves dashboard storage key to auth, dedupes client
wgordon17 Mar 24, 2026
f33735e
fix: tightens read-only guard, removes dead fork fields
wgordon17 Mar 24, 2026
95b67af
fix(test): adds DASHBOARD_STORAGE_KEY to auth mock
wgordon17 Mar 24, 2026
87d918b
fix(test): uses workspace config for local and CI parity
wgordon17 Mar 24, 2026
9da96e2
fix(test): clears localStorage in beforeEach for CI parity
wgordon17 Mar 24, 2026
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
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# GitHub App client ID — embedded into client-side bundle at build time by Vite.
# GitHub OAuth App client ID — embedded into client-side bundle at build time by Vite.
# This is public information (visible in the OAuth authorize URL).
# Set this as a GitHub Actions variable (not a secret) for CI/CD.
VITE_GITHUB_CLIENT_ID=your_github_app_client_id_here
VITE_GITHUB_CLIENT_ID=your_oauth_app_client_id_here
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: CI
on:
pull_request:
permissions:
contents: read
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run typecheck
- run: pnpm test
- name: Verify CSP hash
run: node scripts/verify-csp-hash.mjs
- name: Install Playwright browsers
run: npx playwright install chromium --with-deps
- name: Run E2E tests
run: pnpm test:e2e
env:
VITE_GITHUB_CLIENT_ID: ${{ vars.VITE_GITHUB_CLIENT_ID }}
101 changes: 0 additions & 101 deletions .github/workflows/preview.yml

This file was deleted.

134 changes: 52 additions & 82 deletions DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,63 +20,38 @@
### Variables (GitHub repo → Settings → Secrets and variables → Actions → Variables)

**`VITE_GITHUB_CLIENT_ID`**
- This is the GitHub App Client ID (not a secret — it is embedded in the built JS bundle)
- This is the GitHub OAuth App Client ID (not a secret — it is embedded in the built JS bundle)
- Add it as an Actions **variable** (not a secret)
- See GitHub App setup below for how to obtain it
- See OAuth App setup below for how to obtain it

## GitHub App Setup
## GitHub OAuth App Setup

1. Go to GitHub → Settings → Developer settings → GitHub Apps → **New GitHub App**
2. Fill in the basic details:
- **App name**: your app name (e.g. `gh-tracker-yourname`)
- **Description**: `Personal dashboard for tracking GitHub issues, PRs, and Actions runs across repos and orgs.`
1. Go to GitHub → Settings → Developer settings → OAuth Apps → **New OAuth App**
2. Fill in the details:
- **Application name**: your app name (e.g. `gh-tracker-yourname`)
- **Homepage URL**: `https://gh.gordoncode.dev`
3. Under **Identifying and authorizing users**:
- **Callback URLs** — register all three:
- `https://gh.gordoncode.dev/oauth/callback` (production)
- `https://github-tracker.<account>.workers.dev/oauth/callback` (preview — GitHub's subdomain matching should allow per-branch preview aliases like `alias.github-tracker.<account>.workers.dev` to work; verify after first preview deploy)
- `http://localhost:5173/oauth/callback` (local dev)
- ✅ **Expire user authorization tokens** — check this. The app uses short-lived access tokens (8hr) with HttpOnly cookie-based refresh token rotation.
- ✅ **Request user authorization (OAuth) during installation** — check this. Streamlines the install + authorize flow into one step.
4. Under **Post installation**:
- Leave **Setup URL** blank
- Leave **Redirect on update** unchecked
5. Under **Webhook**:
- ❌ Uncheck **Active** — the app polls; it does not use webhooks.
6. Under **Permissions**:

**Repository permissions** (read-only):

| Permission | Access | Used for |
|------------|--------|----------|
| **Actions** | Read-only | `GET /repos/{owner}/{repo}/actions/runs` — workflow run list |
| **Checks** | Read-only | `GET /repos/{owner}/{repo}/commits/{ref}/check-runs` — PR check status (REST fallback) |
| **Commit statuses** | Read-only | `GET /repos/{owner}/{repo}/commits/{ref}/status` — legacy commit status (REST fallback) |
| **Issues** | Read-only | `GET /search/issues?q=is:issue` — issue search |
| **Metadata** | Read-only | Automatically granted when any repo permission is set. Required for basic repo info. |
| **Pull requests** | Read-only | `GET /search/issues?q=is:pr`, `GET /repos/{owner}/{repo}/pulls/{pull_number}`, `/reviews` — PR search, detail, and reviews |

**Organization permissions:**

| Permission | Access | Used for |
|------------|--------|----------|
| **Members** | Read-only | `GET /user/orgs` — list user's organizations for the org selector |

**Account permissions:**

| Permission | Access | Used for |
|------------|--------|----------|
| _(none required)_ | | |

7. Under **Where can this GitHub App be installed?**:
- **Any account** — the app uses OAuth authorization (not installation tokens), so any GitHub user needs to be able to authorize via the login flow
8. Click **Create GitHub App**
9. Note the **Client ID** — this is your `VITE_GITHUB_CLIENT_ID`
10. Click **Generate a new client secret** and save it for the Worker secrets below

### Notifications API limitation

The GitHub Notifications API (`GET /notifications`) does not support GitHub App user access tokens — only classic personal access tokens. The app uses notifications as a polling optimization gate (skip full fetch when nothing changed). When the notifications endpoint returns 403, the gate **auto-disables** and the app falls back to time-based polling. No functionality is lost; polling is just slightly less efficient.
- **Authorization callback URL**: `https://gh.gordoncode.dev/oauth/callback`
3. Click **Register application**
4. Note the **Client ID** — this is your `VITE_GITHUB_CLIENT_ID`
5. Click **Generate a new client secret** and save it for the Worker secrets below

### Scopes

The login flow requests `scope=repo read:org notifications`:

| Scope | Used for |
|-------|----------|
| `repo` | Read issues, PRs, check runs, workflow runs (includes private repos) |
| `read:org` | `GET /user/orgs` — list user's organizations for the org selector |
| `notifications` | `GET /notifications` — polling optimization gate (304 = skip full fetch) |

**Note:** The `repo` scope grants write access to repositories, but this app never performs write operations (POST/PUT/PATCH/DELETE on repo endpoints). It is read-only by design.

### Local development OAuth App

Create a second OAuth App for local development:
- **Authorization callback URL**: `http://localhost:5173/oauth/callback`
- Set its Client ID and Secret in `.dev.vars` (see Local Development below)

## Cloudflare Worker Secrets

Expand All @@ -91,41 +66,30 @@ wrangler secret put ALLOWED_ORIGIN
```

- `GITHUB_CLIENT_ID`: same value as `VITE_GITHUB_CLIENT_ID`
- `GITHUB_CLIENT_SECRET`: the Client Secret from your GitHub App
- `GITHUB_CLIENT_SECRET`: the Client Secret from your GitHub OAuth App
- `ALLOWED_ORIGIN`: `https://gh.gordoncode.dev`

### Preview versions

Preview deployments use `wrangler versions upload` (not a separate environment), so they inherit production secrets automatically. No additional secret configuration is needed.

CORS note: Preview URLs are same-origin (SPA and API share the same `*.workers.dev` host), so the `ALLOWED_ORIGIN` strict-equality check is irrelevant — browsers don't enforce CORS on same-origin requests.

**Migration note:** If you previously deployed with `wrangler deploy --env preview`, an orphaned `github-tracker-preview` worker may still exist. Delete it via `wrangler delete --name github-tracker-preview` or through the Cloudflare dashboard.

## Worker API Endpoints

| Endpoint | Method | Auth | Purpose |
|----------|--------|------|---------|
| `/api/oauth/token` | POST | none | Exchange OAuth code for access token. Refresh token set as HttpOnly cookie. |
| `/api/oauth/refresh` | POST | cookie | Refresh expired access token. Reads `github_tracker_rt` HttpOnly cookie. Sets rotated cookie. |
| `/api/oauth/logout` | POST | none | Clears the `github_tracker_rt` HttpOnly cookie (`Max-Age=0`). |
| `/api/health` | GET | none | Health check. Returns `OK`. |
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/api/oauth/token` | POST | Exchange OAuth authorization code for permanent access token. |
| `/api/health` | GET | Health check. Returns `OK`. |

### Refresh Token Security
### Token Storage Security

The refresh token (6-month lifetime) is stored as an **HttpOnly cookie** — never in `localStorage` or the response body. This protects the high-value long-lived credential from XSS:
The OAuth App access token is a permanent credential (no expiry). It is stored in `localStorage` under the key `github-tracker:auth-token`:

- Production cookie: `__Host-github_tracker_rt` with `HttpOnly; Secure; SameSite=Strict; Path=/`
- Local dev: `github_tracker_rt` with `HttpOnly; SameSite=Lax; Path=/` (no `Secure` — localhost is HTTP; no `__Host-` prefix — requires `Secure`)
- The short-lived access token (8hr) is held in-memory only (never persisted to `localStorage`); on page reload, `refreshAccessToken()` obtains a fresh token via the cookie
- On logout, the client calls `POST /api/oauth/logout` to clear the cookie
- GitHub rotates the refresh token on each use; the Worker sets the new value as a cookie
- **CSP protects against XSS token theft**: `script-src 'self'` prevents injection of unauthorized scripts that could read `localStorage`
- On page load, `validateToken()` calls `GET /user` to verify the token is still valid
- On 401, the app immediately clears auth and redirects to login (token is revoked, not expired)
- On logout, the token is removed from `localStorage` and all local state is cleared
- Transient network errors do NOT clear the token (permanent tokens survive connectivity issues)

### CORS

- `Access-Control-Allow-Origin`: exact match against `ALLOWED_ORIGIN` (no wildcards)
- `Access-Control-Allow-Credentials: true`: enables cookie-based refresh for cross-origin preview deploys
- Same-origin requests (production, local dev) send cookies automatically without CORS
- No `Access-Control-Allow-Credentials` header (OAuth App uses no cookies)

## Local Development

Expand All @@ -138,9 +102,15 @@ pnpm run build
wrangler deploy
```

For preview (uploads a version without promoting to production):
## Migration from GitHub App

```sh
pnpm run build
wrangler versions upload --preview-alias my-feature
```
If you previously deployed with the GitHub App model (HttpOnly cookie refresh tokens), follow these steps:

1. **Update GitHub Actions variable**: change `VITE_GITHUB_CLIENT_ID` to your OAuth App's Client ID
2. **Update Cloudflare secrets**: re-run `wrangler secret put GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` with OAuth App values
3. **Update `ALLOWED_ORIGIN`** if it changed (usually unchanged)
4. **Redeploy** the Worker: `pnpm run build && wrangler deploy`
5. **Existing users** will be logged out on next page load (their refresh cookie is no longer valid; they will be prompted to log in again via the new OAuth App flow)
6. **Delete the old GitHub App** (optional): GitHub → Settings → Developer settings → GitHub Apps → your app → Advanced → Delete

The old `POST /api/oauth/refresh` and `POST /api/oauth/logout` endpoints no longer exist and return 404.
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,14 @@ src/
github.ts # Octokit client factory with ETag caching and rate limit tracking
poll.ts # Poll coordinator with visibility-aware auto-refresh
stores/
auth.ts # OAuth token management with auto-refresh
auth.ts # OAuth token management (localStorage persistence, validateToken)
cache.ts # IndexedDB cache with TTL eviction and ETag support
config.ts # Zod v4-validated config with localStorage persistence
view.ts # View state (tabs, sorting, ignored items, filters)
lib/
notifications.ts # Desktop notification permission, detection, and dispatch
worker/
index.ts # OAuth token exchange/refresh endpoint, CORS, security headers
index.ts # OAuth token exchange endpoint, CORS, security headers
tests/
fixtures/ # GitHub API response fixtures (orgs, repos, issues, PRs, runs)
services/ # API service, Octokit client, and poll coordinator tests
Expand All @@ -74,10 +74,11 @@ tests/
- Strict CSP: `script-src 'self'` (SHA-256 exception for dark mode script only)
- OAuth CSRF protection via `crypto.getRandomValues` state parameter
- CORS locked to exact origin (strict equality, no substring matching)
- Access token in-memory only (never persisted); refresh token in `__Host-` HttpOnly cookie
- Auto-refresh on 401 and on page load via HttpOnly cookie
- Access token stored in `localStorage` under app-specific key; CSP prevents XSS token theft
- Token validation on page load via `GET /user`; 401 clears auth immediately (no silent refresh)
- All GitHub API strings auto-escaped by SolidJS JSX (no innerHTML)
- `repo` scope granted (required for private repos) — app never performs write operations

## Deployment

See [DEPLOY.md](./DEPLOY.md) for Cloudflare, GitHub App, and CI/CD setup.
See [DEPLOY.md](./DEPLOY.md) for Cloudflare, OAuth App, and CI/CD setup.
28 changes: 12 additions & 16 deletions e2e/settings.spec.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
import { test, expect, type Page } from "@playwright/test";

/**
* Register API route interceptors and inject config BEFORE any navigation.
* The app calls refreshAccessToken() on load, which POSTs to /api/oauth/refresh
* (HttpOnly cookie-based). We intercept that to return a valid access token.
* Register API route interceptors and inject auth + config into localStorage BEFORE navigation.
* OAuth App uses permanent tokens stored in localStorage — no refresh endpoint needed.
* The app calls validateToken() on load, which GETs /user to verify the token.
*/
async function setupAuth(page: Page) {
await page.route("**/api/oauth/refresh", (route) =>
route.fulfill({
status: 200,
json: { access_token: "ghu_fake", expires_in: 86400 },
})
);
await page.route("https://api.github.com/user", (route) =>
route.fulfill({
status: 200,
Expand All @@ -36,11 +30,12 @@ async function setupAuth(page: Page) {
);

await page.addInitScript(() => {
localStorage.setItem("github-tracker:auth-token", "ghu_fake");
localStorage.setItem(
"github-tracker:config",
JSON.stringify({
selectedOrgs: ["testorg"],
selectedRepos: [{ owner: "testorg", name: "testrepo" }],
selectedRepos: [{ owner: "testorg", name: "testrepo", fullName: "testorg/testrepo" }],
onboardingComplete: true,
})
);
Expand Down Expand Up @@ -114,16 +109,17 @@ test("sign out clears auth and redirects to login", async ({ page }) => {
const signOutBtn = page.getByRole("button", { name: /^sign out$/i });
await expect(signOutBtn).toBeVisible();

// Intercept the logout cookie-clearing request
await page.route("**/api/oauth/logout", (route) =>
route.fulfill({ status: 200, json: { ok: true } })
);

await signOutBtn.click();

// clearAuth() clears in-memory token and navigates to /login
// clearAuth() removes the localStorage token and navigates to /login
await expect(page).toHaveURL(/\/login/);

// Verify auth token was cleared from localStorage
const authToken = await page.evaluate(() =>
localStorage.getItem("github-tracker:auth-token")
);
expect(authToken).toBeNull();

// Verify config was reset (SDR-016 data leakage prevention).
// The persistence effect may re-write defaults, so check that user-specific
// data (selectedOrgs, onboardingComplete) was cleared rather than checking null.
Expand Down
Loading
Loading