From 1bd94206ae7c2ecc445661fa71df8867dc4d848b Mon Sep 17 00:00:00 2001 From: Yash Kumar Saini Date: Mon, 29 Jun 2026 09:44:26 +0530 Subject: [PATCH 1/6] fix(harvest): retry transient network errors on GitHub GraphQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The top-level fetchWeeklyContributions had zero retry, so one TCP hiccup during the response gzip-decode (ERR_STREAM_PREMATURE_CLOSE) killed the whole weekly workflow — that's what knocked out run 28319315389. Wrap client.request in p-retry (already a dep). Retry network-class codes (ECONNRESET, ETIMEDOUT, ENOTFOUND, EAI_AGAIN, ERR_STREAM_PREMATURE_CLOSE, UND_ERR_SOCKET) and HTTP 5xx. Abort via AbortError on 4xx so a bad token doesn't burn the workflow's 10-min budget on three pointless retries. 3 attempts, exponential backoff (1s → 10s). Per-commit enrichment path already retries via githubLimiter; this just protects the top. --- src/__tests__/github-retry.test.ts | 51 ++++++++++++++++++++++++++ src/tools/github.tool.ts | 58 +++++++++++++++++++++++++++--- 2 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/github-retry.test.ts diff --git a/src/__tests__/github-retry.test.ts b/src/__tests__/github-retry.test.ts new file mode 100644 index 0000000..51dc41e --- /dev/null +++ b/src/__tests__/github-retry.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { isRetryableNetworkError } from '../tools/github.tool.js'; + +describe('isRetryableNetworkError', () => { + it('retries on the exact failure mode from CI run 28319315389 (gzip stream cut)', () => { + const err = Object.assign(new Error('Invalid response body while trying to fetch ...: Premature close'), { + code: 'ERR_STREAM_PREMATURE_CLOSE', + name: 'FetchError', + }); + expect(isRetryableNetworkError(err)).toBe(true); + }); + + it.each([ + ['ECONNRESET'], + ['ETIMEDOUT'], + ['ENOTFOUND'], + ['EAI_AGAIN'], + ['UND_ERR_SOCKET'], + ])('retries on transient network code %s', (code) => { + const err = Object.assign(new Error('socket hangup'), { code }); + expect(isRetryableNetworkError(err)).toBe(true); + }); + + it('retries on graphql-request ClientError with HTTP 5xx', () => { + const err = Object.assign(new Error('GraphQL server error'), { + response: { status: 503 }, + }); + expect(isRetryableNetworkError(err)).toBe(true); + }); + + it('does NOT retry on 4xx (bad token, missing user) — avoids burning the workflow budget on a hard failure', () => { + const err = Object.assign(new Error('Bad credentials'), { response: { status: 401 } }); + expect(isRetryableNetworkError(err)).toBe(false); + }); + + it('does NOT retry on unknown error codes', () => { + const err = Object.assign(new Error('Something else'), { code: 'EUNKNOWN' }); + expect(isRetryableNetworkError(err)).toBe(false); + }); + + it('does NOT retry on a plain Error with no code or response', () => { + expect(isRetryableNetworkError(new Error('boom'))).toBe(false); + }); + + it('handles non-Error inputs without throwing', () => { + expect(isRetryableNetworkError(null)).toBe(false); + expect(isRetryableNetworkError(undefined)).toBe(false); + expect(isRetryableNetworkError('string error')).toBe(false); + expect(isRetryableNetworkError(42)).toBe(false); + }); +}); diff --git a/src/tools/github.tool.ts b/src/tools/github.tool.ts index b8353cb..79b540b 100644 --- a/src/tools/github.tool.ts +++ b/src/tools/github.tool.ts @@ -1,4 +1,5 @@ import { GraphQLClient, gql } from 'graphql-request'; +import pRetry, { AbortError } from 'p-retry'; import { env } from '../config/env.js'; import { WeeklyDataSchema, type WeeklyData } from '../types/github.types.js'; import { @@ -13,6 +14,51 @@ const client = new GraphQLClient('https://api.github.com/graphql', { headers: { Authorization: `bearer ${env.GITHUB_TOKEN}` }, }); +// Network-class errors that survive a retry (TCP reset, DNS blip, gzip stream cut +// mid-decode, edge 5xx). Anything else — bad token, malformed query, 4xx — is a +// real problem and retrying just wastes the workflow's 10-minute budget. +const RETRYABLE_NETWORK_CODES = new Set([ + 'ECONNRESET', + 'ETIMEDOUT', + 'ENOTFOUND', + 'EAI_AGAIN', + 'ERR_STREAM_PREMATURE_CLOSE', + 'UND_ERR_SOCKET', +]); + +export function isRetryableNetworkError(err: unknown): boolean { + if (!err || typeof err !== 'object') return false; + const code = (err as { code?: string }).code; + if (typeof code === 'string' && RETRYABLE_NETWORK_CODES.has(code)) return true; + const status = (err as { response?: { status?: number } }).response?.status; + if (typeof status === 'number' && status >= 500 && status < 600) return true; + return false; +} + +async function requestWithRetry(label: string, op: () => Promise): Promise { + return pRetry( + async () => { + try { + return await op(); + } catch (err) { + if (isRetryableNetworkError(err)) throw err; + throw new AbortError(err as Error); + } + }, + { + retries: 3, + factor: 2, + minTimeout: 1000, + maxTimeout: 10_000, + onFailedAttempt: (err) => { + console.warn( + `${label}: attempt ${err.attemptNumber} failed (${err.message}). ${err.retriesLeft} retries left.`, + ); + }, + }, + ); +} + const WEEKLY_CONTRIBUTIONS_QUERY = gql` query WeeklyContributions($username: String!, $from: DateTime!, $to: DateTime!) { user(login: $username) { @@ -122,11 +168,13 @@ export async function fetchWeeklyContributions(weekStart: string): Promise + client.request(WEEKLY_CONTRIBUTIONS_QUERY, { + username: env.GITHUB_USERNAME, + from: from.toISOString(), + to: to.toISOString(), + }), + ); const raw = transformGitHubResponse(data, weekStart, to.toISOString().split('T')[0]!); const enriched = await enrichWithCommitDetails(data, raw, from.toISOString(), to.toISOString()); From c4686a9a96e607af2aa10d958a66ec6de7134bc4 Mon Sep 17 00:00:00 2001 From: Yash Kumar Saini Date: Mon, 29 Jun 2026 09:44:35 +0530 Subject: [PATCH 2/6] docs: secure public deploy plan (Vercel-read + Render-write split) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6-phase plan to ship a public read-only blog viewer on Vercel while keeping the long-running pipeline + owner-only admin on Render. Replaces the scrypt PIN with GitHub OAuth + OWNER_GITHUB_ID check — no credential exists to brute-force; GitHub's 2FA/passkey stack handles the auth. Pipeline can't move to Vercel (60s function budget vs. ~30-90s narration + harvest + publish), so the split keeps the right host for each job. --- docs/plans/secure-public-deploy.md | 243 +++++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 docs/plans/secure-public-deploy.md diff --git a/docs/plans/secure-public-deploy.md b/docs/plans/secure-public-deploy.md new file mode 100644 index 0000000..1d029f6 --- /dev/null +++ b/docs/plans/secure-public-deploy.md @@ -0,0 +1,243 @@ +# Secure public deploy — Vercel-read + Render-write split + +**Status**: plan, awaiting signoff +**Author**: yks (with Claude) +**Date**: 2026-06-29 + +## Goal + +Let anyone read the published blogs (Vercel, no auth), let only the owner run / publish / delete +(Render, GitHub-OAuth-gated). No password to be cracked — auth piggybacks on GitHub's full +2FA/passkey stack via OAuth, plus an owner-ID check at the application layer. + +## Non-goals + +- Moving the long-running pipeline (Gemini narration + GitHub harvest + Notion/DEV.to publish) + onto Vercel functions. The pipeline regularly exceeds Vercel's 60s function budget; pushing + it serverless would force an Inngest/QStash rewrite that we don't need. +- Migrating the run-store to a real DB. The committed `.devnotion-runs.json` is the source of + truth for both sides until we outgrow it. +- Multi-user. This stays a single-owner system. + +## Architecture + +``` + ┌──────────────────────────────────────┐ + │ Vercel — devnotion. │ + │ (PUBLIC, READ-ONLY) │ + │ │ + │ / landing │ + │ /blog run history │ + │ /blog/[week] blog viewer │ + │ /api/runs.json public run feed │ + │ │ + │ Reads from: this repo's │ + │ .devnotion-runs.json (committed) │ + │ assets/generated/*.png (committed)│ + │ │ + │ No secrets. No mutations. │ + └──────────────┬───────────────────────┘ + │ + │ owner-only link + ▼ + ┌──────────────────────────────────────┐ + │ Render — admin.devnotion. │ + │ (OWNER ONLY) │ + │ │ + │ /auth/github OAuth start │ + │ /auth/github/callback OAuth back │ + │ /admin/runs dashboard │ + │ /admin/run/* run/publish │ + │ /admin/preview/* edit/review │ + │ │ + │ Gated by: │ + │ 1. GitHub OAuth session │ + │ 2. session.githubId === OWNER_ID │ + │ 3. CSRF token on mutations │ + │ 4. Origin / Referer check │ + │ │ + │ Long-running pipeline lives here. │ + │ All secrets stay here. │ + └──────────────────────────────────────┘ +``` + +## Why this is the secure answer + +| Risk | Old (PIN + scrypt) | New (split + OAuth) | +|-----------------------------------------|----------------------------------|----------------------------------| +| Brute force on dashboard credential | mitigated (lockout) but exists | **eliminated** — no credential | +| Credential phishing | possible | reduced (OAuth flow + 2FA) | +| Stolen secrets via Vercel/Render breach | full pipeline access | Vercel breach = nothing; Render breach = same as today | +| Open redirect / CSRF on `/login` | mitigated, still a target | OAuth handler + per-mutation CSRF | +| Lockout DoS (global 50/15min cap) | real risk under attack | **gone** — no failed-attempt counter | +| Forgetting to rotate the PIN | yes | no PIN exists | + +The unbreakable part is *cryptographic*, not policy: a passkey-backed GitHub login submits +a public-key challenge response. There is no shared secret on the wire and nothing on our +server worth stealing other than a non-secret `githubId` number. + +## Phased migration + +Each phase is its own commit, testable in isolation, reversible. + +### Phase 0 — CI retry fix (DONE in this session, awaiting commit) +Wrap `fetchWeeklyContributions` in `p-retry` with network-class classifier. Unblocks the +weekly dispatch from transient `ERR_STREAM_PREMATURE_CLOSE` failures. + +### Phase 1 — Route reshape on the existing server +Move all gated routes under `/admin/*` on the current Express app: +- `/runs` → `/admin/runs` +- `/run/*` → `/admin/run/*` +- `/preview/*` → `/admin/preview/*` +- `/login`, `/logout` → `/admin/login`, `/admin/logout` (still scrypt-backed for now) + +Keep `/` (landing), `/health`, and `/generated/*` (static images) public. + +Add 301 redirects from old paths to new for ~1 week so any saved tabs keep working. + +**Outcome**: nothing functional changes. The split is just in the URL space, and the auth +middleware now only fires for `/admin/*`. This isolates the OAuth swap in the next phase. + +### Phase 2 — GitHub OAuth replaces the scrypt password +Add an OAuth app on github.com: +- Homepage: `https://admin.devnotion.` +- Callback: `https://admin.devnotion./auth/github/callback` + +Implement: +- `src/server/auth-github.ts` — OAuth start (random `state` cookie, redirect to GitHub), + callback (exchange `code` for token, fetch `/user`, check `id === OWNER_GITHUB_ID`, + mint a session cookie). +- `src/server/auth.ts` — sessions stay as-is (in-memory map, 10-min TTL). Remove + `verifyPassword`, `hashPassword`, brute-force counters. Keep `createSession` / + `isSessionValid` / `safeInternalPath` / `readCookie`. +- `src/server/middleware/auth.ts` — drop the Bearer path entirely (the scripted/API path) + OR keep it backed by a long-random API key (`DEVNOTION_API_TOKEN`, generated once, + never reused as a UI password). Recommendation: **keep Bearer** for CI/scripts but + decouple from the UI session — different secret, different rotation policy. + +New env vars on Render: +- `GITHUB_OAUTH_CLIENT_ID` +- `GITHUB_OAUTH_CLIENT_SECRET` +- `OWNER_GITHUB_ID` (your numeric GitHub user id — `gh api /user --jq .id`) +- `SESSION_SECRET` (32+ random bytes for HMAC-signing the session cookie value) +- `DEVNOTION_API_TOKEN` (optional, only if keeping Bearer for scripts) + +Retire: +- `DASHBOARD_PASSWORD` +- `DASHBOARD_PASSWORD_HASH` + +### Phase 3 — Harden the session cookie +Today's session is opaque (just a random hex id, server holds a Map of expiry). It works +because Render is a single instance. For belt-and-suspenders: +- Sign the cookie value as `.`. +- Reject any cookie whose HMAC doesn't verify before even looking it up in the Map. +- Result: a leaked cookie can't be forged or extended without the secret. + +Cookie flags: `HttpOnly; Secure; SameSite=Lax; Path=/admin; Max-Age=600`. (Lax not Strict +so the OAuth callback redirect works — the callback is a top-level GET from github.com.) + +### Phase 4 — CSRF on mutations +Even with OAuth + same-site cookie, add a double-submit CSRF token on every mutation form +(`POST /admin/run`, `POST /admin/preview/:id/approve`, `DELETE /admin/run/:id`): +- Generate a per-session `csrf` token alongside the session cookie. +- Embed in every form as ``. +- Middleware: for unsafe methods on `/admin/*`, require `req.body._csrf === session.csrf` + AND `req.get('origin')` (or `referer`) starts with the admin host. + +### Phase 5 — Vercel project +Create `apps/web/` (Next.js app router) or just a static `public/` site, whichever you +prefer. Recommend Next.js because: +- ISR for `/blog/[week]` so adding a run doesn't require a redeploy. +- Built-in image optimization for the stats-card PNGs. +- Trivial to deploy. + +Routes: +- `/` — landing (copy/paste from `src/server/routes/landing.ts`). +- `/blog` — list of runs from `.devnotion-runs.json` filtered to `status === 'published'`. +- `/blog/[week]` — single blog viewer; pulls `narration.body` + cover image. +- `/api/runs.json` — public JSON feed (for syndication / RSS later). + +Data flow: Vercel build pulls `.devnotion-runs.json` from the GitHub repo (the file is +committed every dispatch). Each push to `master` triggers a Vercel rebuild → blog is live. +No runtime call from Vercel to Render. + +`vercel.json`: +```json +{ + "buildCommand": "pnpm install --frozen-lockfile && pnpm run build:web", + "outputDirectory": "apps/web/.next", + "framework": "nextjs" +} +``` + +Custom domain split: +- `devnotion.` → Vercel (public) +- `admin.devnotion.` → Render (owner) + +### Phase 6 — Cut over +1. Deploy Phase 5 Vercel project to a preview URL, verify all published runs render. +2. Point `devnotion.` DNS to Vercel. +3. Repoint `admin.devnotion.` to Render (was the root domain before). +4. Update `README.md` links — the public-facing "view my blogs" goes to Vercel. + +## Threat model after migration + +| Attacker capability | Outcome | +|---------------------------------------------|-------------------------------------------------| +| Full Vercel project compromise | Reads public data only. Nothing to leak. | +| Steal Render server secrets via 0-day | Same as today (GitHub/Gemini/Notion/DEV.to keys). Mitigation: separate Phase-2 work to put secrets behind a vault. Out of scope here. | +| Phish my GitHub login | Blocked by my 2FA/passkey on github.com. | +| Replay a stolen admin session cookie | 10-min TTL + HMAC; useless after expiry. | +| Forge a session cookie | Blocked by HMAC over `SESSION_SECRET`. | +| CSRF on a mutation | Blocked by per-session token + Origin check. | +| Brute force admin login | No login endpoint to brute force. | +| Distributed brute force | Same as above. | +| MITM on admin domain | Blocked by HSTS + Secure cookie. (Add HSTS header.) | + +## Test plan + +Per phase: +- **Phase 1**: existing test suite (78 tests) must pass, plus a new test that asserts + every gated route is now under `/admin/*` and `/` + `/health` + `/generated/*` are not. +- **Phase 2**: new `auth-github.test.ts` covering: + - state cookie mismatch → 403 + - missing code → 400 + - non-owner GitHub id → 403 (even with a valid OAuth token) + - happy path → session cookie set with HMAC +- **Phase 3**: tampering with a session cookie (flip one bit) → unauthorized. +- **Phase 4**: mutation without `_csrf` → 403; with wrong Origin → 403. +- **Phase 5**: Vercel build smoke test — `pnpm run build:web` must succeed locally. +- **Phase 6**: end-to-end smoke: sign in to admin, run a dispatch, watch it appear on the + Vercel public site within ~60s of the next push. + +## Open questions for you + +1. **Domain**: do you have `devnotion.` or will the Vercel URL be the canonical + public address? Affects OAuth callback URL. +2. **Bearer API token**: keep it for scripts (you can `curl -H "Authorization: Bearer ..."` + to trigger a publish locally) or drop it entirely (all writes go through the OAuth UI)? +3. **Vercel framework**: Next.js (recommended for ISR + image opt) or static export from + the existing Express HTML views (zero rewrite, less feature)? Static is faster to ship, + Next.js is more durable. +4. **Phase 3 (HMAC session)**: do it as part of Phase 2 or defer? Defer is fine — the OAuth + redirect already prevents most attacks; HMAC is hardening for if Render ever scales out. + +## Effort estimate + +- Phase 0 (CI fix): done locally, ~10 LoC + tests. **Ship today.** +- Phase 1 (route reshape): ~1 hour, mostly mechanical. +- Phase 2 (OAuth swap): ~2–3 hours including the GitHub app + tests. +- Phase 3 (HMAC session): ~30 min, deferrable. +- Phase 4 (CSRF): ~1 hour. +- Phase 5 (Vercel project): ~2–4 hours (Next.js) or ~1 hour (static). +- Phase 6 (cutover): ~30 min DNS + smoke. + +**Total**: ~half a day to a full day, ~5 commits. + +## What I will NOT do without explicit signoff + +- Delete `DASHBOARD_PASSWORD_HASH` from Render env until Phase 6 is live and verified. +- Remove the scrypt code path until at least one full week of OAuth-only operation passes. +- Rename the public domain. +- Touch the weekly GitHub Action's secrets (it doesn't need OAuth — it already authenticates + to GitHub via `GH_TOKEN`). From c72e7eb83aee6d519f5d5da7a9c932806752cfbc Mon Sep 17 00:00:00 2001 From: Yash Kumar Saini Date: Mon, 29 Jun 2026 10:04:48 +0530 Subject: [PATCH 3/6] ci: add test+lint+typecheck workflow for PRs and master pushes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the Node 22 + pnpm setup used by weekly-dispatch.yml. Runs: - pnpm lint - pnpm exec tsc --noEmit (catches type errors in non-test code) - pnpm test concurrency cancels in-flight runs on a rapid push so we don't queue stale jobs. Read-only permissions (no need to write anything from CI). Closes the gap where PRs got zero automated feedback — every PR from here on gets a green/red signal before merge. --- .github/workflows/ci.yml | 44 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cfa2110 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + pull_request: + push: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm lint + + - name: Typecheck + run: pnpm exec tsc --noEmit + + - name: Test + run: pnpm test From eec89dc64e8a05165335b9c80fecdee483a56014 Mon Sep 17 00:00:00 2001 From: Yash Kumar Saini Date: Mon, 29 Jun 2026 10:14:42 +0530 Subject: [PATCH 4/6] refactor(routes): reshape gated routes to /admin/* (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per docs/plans/secure-public-deploy.md Phase 1: move every authenticated route under /admin/* so the public/private boundary is purely a URL prefix. Sets up Phase 2 (GitHub OAuth) to swap auth at one place without touching route definitions. Path changes: - /runs → /admin/runs - /run → /admin/run - /preview → /admin/preview - /login → /admin/login - /logout → /admin/logout Kept public: /, /health, /generated/* Adds a regex-based 301 redirect middleware for the legacy paths (~1 week grace period — remove after 2026-07-06). GET/HEAD only; POST callers (Bearer scripts) need to update to the new paths explicitly. Updates every internal link (forms, redirects, header nav, tokenGate), safeInternalPath default, auth-middleware login redirect, and the existing tests that reference the old paths. No auth-scheme changes in this phase — scrypt + session cookie still gate /admin/*. Verified: pnpm test (78/78), pnpm exec tsc --noEmit clean, pnpm lint (0 errors), manual smoke confirmed legacy 301s and new /admin/* routes respond correctly with auth disabled. --- src/__tests__/auth.test.ts | 6 ++-- src/__tests__/landing.test.ts | 2 +- src/server/auth.ts | 2 +- src/server/index.ts | 50 +++++++++++++++++++++++++--------- src/server/middleware/auth.ts | 2 +- src/server/routes/dashboard.ts | 16 +++++------ src/server/routes/landing.ts | 2 +- src/server/routes/preview.ts | 4 +-- src/server/routes/run.ts | 10 +++---- src/server/views/layout.ts | 6 ++-- 10 files changed, 62 insertions(+), 38 deletions(-) diff --git a/src/__tests__/auth.test.ts b/src/__tests__/auth.test.ts index 22c7363..df1eb75 100644 --- a/src/__tests__/auth.test.ts +++ b/src/__tests__/auth.test.ts @@ -66,14 +66,14 @@ describe('session lifecycle (10-minute TTL)', () => { describe('safeInternalPath (open-redirect guard)', () => { it('accepts genuine internal paths', () => { - expect(safeInternalPath('/runs')).toBe('/runs'); - expect(safeInternalPath('/preview/abc-123?x=1')).toBe('/preview/abc-123?x=1'); + expect(safeInternalPath('/admin/runs')).toBe('/admin/runs'); + expect(safeInternalPath('/admin/preview/abc-123?x=1')).toBe('/admin/preview/abc-123?x=1'); }); it('rejects absolute, protocol-relative, and backslash-escaped targets', () => { // backslash matters: browsers normalize "\" to "/", so "/\evil.com" → "//evil.com" for (const bad of ['//evil.com', '/\\evil.com', '/\\/evil.com', 'https://evil.com', 'http://x', 'evil.com', '/', '', undefined, null, 42]) { - expect(safeInternalPath(bad)).toBe('/runs'); + expect(safeInternalPath(bad)).toBe('/admin/runs'); } }); }); diff --git a/src/__tests__/landing.test.ts b/src/__tests__/landing.test.ts index df011f5..ba76696 100644 --- a/src/__tests__/landing.test.ts +++ b/src/__tests__/landing.test.ts @@ -6,7 +6,7 @@ describe('buildLandingPage', () => { it('renders real links: repo, dashboard CTA, and socials', () => { const html = buildLandingPage(); expect(html).toContain(repoUrl); - expect(html).toContain('href="/runs"'); + expect(html).toContain('href="/admin/runs"'); expect(html).toContain('rel="noopener noreferrer"'); expect(html).not.toContain('href="#"'); expect(html).toContain('Turn your GitHub week'); diff --git a/src/server/auth.ts b/src/server/auth.ts index dcb13b2..3e3d884 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -75,7 +75,7 @@ export function destroySession(id: string | undefined): void { * Must be `/` followed by a non-slash, non-backslash char. Browsers normalize `\` to `/`, * so `//evil.com` AND `/\evil.com` (and absolute URLs) all fall back to the safe default. */ -export function safeInternalPath(redirect: unknown, fallback = '/runs'): string { +export function safeInternalPath(redirect: unknown, fallback = '/admin/runs'): string { return typeof redirect === 'string' && /^\/[^/\\]/.test(redirect) ? redirect : fallback; } diff --git a/src/server/index.ts b/src/server/index.ts index e63d567..a344b7c 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -27,13 +27,13 @@ app.use('/generated', express.static(join(process.cwd(), 'assets', 'generated')) app.use('/', landingRouter); // Login / logout — reachable without auth; active only when a password is configured. -app.get('/login', (req, res) => { +app.get('/admin/login', (req, res) => { if (!isAuthEnabled()) { - res.redirect('/runs'); + res.redirect('/admin/runs'); return; } const esc = (s: string) => s.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); - const redirect = esc((req.query.redirect as string) ?? '/runs'); + const redirect = esc((req.query.redirect as string) ?? '/admin/runs'); const error = req.query.error ? '
Wrong password — try again.
' : ''; @@ -50,7 +50,7 @@ app.get('/login', (req, res) => {

📊 DevNotion

${error} -
+
@@ -64,7 +64,7 @@ app.get('/login', (req, res) => { `); }); -app.post('/login', (req, res) => { +app.post('/admin/login', (req, res) => { const ip = req.ip ?? 'unknown'; // Throttle brute force — the unlock token is a short, guessable PIN. if (!loginAllowed(ip)) { @@ -86,30 +86,54 @@ app.post('/login', (req, res) => { res.redirect(target); } else { recordLoginFailure(ip); - res.redirect(`/login?redirect=${encodeURIComponent(target)}&error=1`); + res.redirect(`/admin/login?redirect=${encodeURIComponent(target)}&error=1`); } }); -app.get('/logout', (req, res) => { +app.get('/admin/logout', (req, res) => { destroySession(readCookie(req.headers.cookie, SESSION_COOKIE)); res.clearCookie(SESSION_COOKIE); - res.redirect('/login'); + res.redirect('/admin/login'); +}); + +// Temporary 301 redirects from the pre-/admin URLs (~1 week grace period). +// Remove after 2026-07-06 once any external bookmarks have been updated. +// Only GET/HEAD — POST callers (Bearer scripts) should be updated explicitly. +const LEGACY_PREFIXES: Array<[RegExp, string]> = [ + [/^\/runs(\/.*)?$/, '/admin/runs'], + [/^\/run(\/.*)?$/, '/admin/run'], + [/^\/preview(\/.*)?$/, '/admin/preview'], + [/^\/login(\/.*)?$/, '/admin/login'], + [/^\/logout(\/.*)?$/, '/admin/logout'], +]; +app.use((req, res, next) => { + if (req.method !== 'GET' && req.method !== 'HEAD') return next(); + for (const [pattern, replacement] of LEGACY_PREFIXES) { + const m = req.path.match(pattern); + if (m) { + const tail = m[1] ?? ''; + const query = req.url.slice(req.path.length); + res.redirect(301, replacement + tail + query); + return; + } + } + next(); }); // Apply auth to all routes below app.use(authMiddleware); -// Routes -app.use('/runs', dashboardRouter); -app.use('/run', runRouter); -app.use('/preview', previewRouter); +// Routes — all gated under /admin/* +app.use('/admin/runs', dashboardRouter); +app.use('/admin/run', runRouter); +app.use('/admin/preview', previewRouter); // Health check app.get('/health', (_req, res) => res.json({ status: 'ok', version: '2.0.0' })); // 404 fallback app.use((_req, res) => { - res.status(404).send('

404 — Page not found

← Back to Dashboard

'); + res.status(404).send('

404 — Page not found

← Back to landing

'); }); export function startServer(): void { diff --git a/src/server/middleware/auth.ts b/src/server/middleware/auth.ts index 8833aff..5e08727 100644 --- a/src/server/middleware/auth.ts +++ b/src/server/middleware/auth.ts @@ -55,7 +55,7 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction): } if (req.accepts('html') && !req.path.startsWith('/api')) { - res.redirect(`/login?redirect=${encodeURIComponent(req.originalUrl)}`); + res.redirect(`/admin/login?redirect=${encodeURIComponent(req.originalUrl)}`); } else { res.status(401).json({ error: 'Unauthorized' }); } diff --git a/src/server/routes/dashboard.ts b/src/server/routes/dashboard.ts index b0cad54..71e2581 100644 --- a/src/server/routes/dashboard.ts +++ b/src/server/routes/dashboard.ts @@ -30,7 +30,7 @@ dashboardRouter.get('/', (req, res) => { const previewLink = run.status === 'preview' - ? `${unlocked ? 'Review & Publish →' : 'View preview →'}` + ? `${unlocked ? 'Review & Publish →' : 'View preview →'}` : ''; const headline = run.result?.headline @@ -45,14 +45,14 @@ dashboardRouter.get('/', (req, res) => { ${headline} ${statusBadge[run.status] ?? escapeHtml(run.status)} ${links || previewLink || '—'} - ${unlocked ? `` : ''} + ${unlocked ? `` : ''} `; }) .join(''); const banner = unlocked - ? '
🔓 Unlocked — you can review, publish & delete (session expires after 10 min) Lock
' - : '
🔒 View-only — anyone can browse; publishing & deleting require a token Enter token
'; + ? '
🔓 Unlocked — you can review, publish & delete (session expires after 10 min) Lock
' + : '
🔒 View-only — anyone can browse; publishing & deleting require a token Enter token
'; const table = ` @@ -72,9 +72,9 @@ dashboardRouter.get('/', (req, res) => { const historyBlock = runs.length === 0 - ? `
No runs yet${unlocked ? ' — start your first run' : ''}
` + ? `
No runs yet${unlocked ? ' — start your first run' : ''}
` : unlocked - ? `${table}
` + ? `${table}
` : table; const body = ` @@ -92,11 +92,11 @@ dashboardRouter.post('/delete', (req, res) => { const raw = (req.body as { ids?: string | string[] }).ids; const ids = Array.isArray(raw) ? raw : raw ? [raw] : []; deleteRuns(ids); - res.redirect('/runs'); + res.redirect('/admin/runs'); }); // POST /runs/:jobId/delete — delete a single run dashboardRouter.post('/:jobId/delete', (req, res) => { deleteRun(req.params.jobId!); - res.redirect('/runs'); + res.redirect('/admin/runs'); }); diff --git a/src/server/routes/landing.ts b/src/server/routes/landing.ts index 078a410..ab69a5d 100644 --- a/src/server/routes/landing.ts +++ b/src/server/routes/landing.ts @@ -74,7 +74,7 @@ export function buildLandingPage(): string {

Turn your GitHub week into a published blog post.

A Mastra pipeline of specialist agents harvests your week, narrates it in your voice, generates the artwork, and publishes to Notion, DEV.to & Hashnode — after you review it.

diff --git a/src/server/routes/preview.ts b/src/server/routes/preview.ts index a0adeb9..2025a15 100644 --- a/src/server/routes/preview.ts +++ b/src/server/routes/preview.ts @@ -86,14 +86,14 @@ previewRouter.get('/:jobId', (req, res) => { cover` : ''} ${unlocked ? ` - + ` : ` -
${tokenGate(`/preview/${run.jobId}`, 'Enter the token to edit and publish this draft.')}
`} +
${tokenGate(`/admin/preview/${run.jobId}`, 'Enter the token to edit and publish this draft.')}
`}
diff --git a/src/server/routes/run.ts b/src/server/routes/run.ts index 2d72bd1..ea5be14 100644 --- a/src/server/routes/run.ts +++ b/src/server/routes/run.ts @@ -27,7 +27,7 @@ runRouter.get('/', (req, res) => {

Start New Run

Triggering a run harvests GitHub activity and calls the LLM, so it's token-gated.

- ${tokenGate('/run', 'Enter the token to trigger a run.')}`; + ${tokenGate('/admin/run', 'Enter the token to trigger a run.')}`; res.send(page({ title: 'New Run · DevNotion', activeNav: 'run', body: lockedBody })); return; } @@ -36,7 +36,7 @@ runRouter.get('/', (req, res) => { ${info}

Start New Run

-
+
@@ -93,7 +93,7 @@ runRouter.post('/', async (req, res) => { }); // Redirect immediately to a status page - res.redirect(`/preview/${record.jobId}`); + res.redirect(`/admin/preview/${record.jobId}`); }); async function runGenerateBackground( @@ -155,7 +155,7 @@ runRouter.post('/publish/:jobId', async (req, res) => { const jobId = req.params.jobId!; const run = getRun(jobId); if (!run || run.status !== 'preview' || !run.result || !run.weeklyData) { - res.status(400).send('

Run not ready to publish

← Back

'); + res.status(400).send('

Run not ready to publish

← Back

'); return; } @@ -171,7 +171,7 @@ runRouter.post('/publish/:jobId', async (req, res) => { }); }); - res.redirect(`/preview/${jobId}`); + res.redirect(`/admin/preview/${jobId}`); }); async function publishApprovedBackground(jobId: string): Promise { diff --git a/src/server/views/layout.ts b/src/server/views/layout.ts index 9b4ecc5..bdc32fb 100644 --- a/src/server/views/layout.ts +++ b/src/server/views/layout.ts @@ -142,8 +142,8 @@ export function page(opts: PageOpts): string {
`}
${opts.body}
@@ -171,7 +171,7 @@ export function tokenGate(redirect: string, message = 'Enter the token to unlock

🔒 View-only

${escapeHtml(message)}

- + From 3bdbc15446d757910d20c3f2c5e5ce28fe335613 Mon Sep 17 00:00:00 2001 From: Yash Kumar Saini Date: Mon, 29 Jun 2026 10:18:50 +0530 Subject: [PATCH 5/6] fix(env): make boot-time validation vitest-safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI on PR #3 failed because validateEnv() runs at module import time and calls process.exit(1) when required env vars are missing. CI has no .env.local, so every test file that transitively imports config/env.ts crashed at the import phase — vitest reported these as "(0 tests)" rather than failures, which made the symptom easy to miss locally. Same pattern bit narrator.agent.ts: it constructs a Google model at module load via createGoogleModel → nextKey, which throws on empty GOOGLE_API_KEYS. Fix: when process.env.VITEST is set (auto by the runner), short-circuit validation with stub values for the five required fields. Real env vars from process.env override the stubs via spread order, so .env.local values still take effect locally. The strict production path is unchanged — failed validation still exits. Verified: pnpm test passes 78/78 both with AND without .env.local (simulating the CI runner). tsc clean, lint clean. --- src/config/env.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/config/env.ts b/src/config/env.ts index 2abb836..b7772ad 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -90,6 +90,25 @@ const EnvSchema = z.object({ export type Env = z.infer; function validateEnv(): Env { + // Under vitest (set automatically by the runner), modules that transitively import + // this file load *before* any test can stub env. A missing required var would call + // process.exit() and kill the whole worker, masquerading as "(0 tests)" in the + // vitest output. Fill the four required strings with stubs so the schema parses, + // but let any real values from process.env (e.g. local .env.local) override them. + if (process.env.VITEST) { + return EnvSchema.parse({ + GITHUB_TOKEN: 'test', + GITHUB_USERNAME: 'test', + NOTION_TOKEN: 'test', + NOTION_PARENT_PAGE_ID: '0'.repeat(32), + // GOOGLE_API_KEYS is consumed at agent-module load (narrator.agent.ts → + // createGoogleModel → nextKey throws on empty). Stub it so import-time + // module evaluation doesn't blow up under vitest. + GOOGLE_API_KEYS: 'test-google-key', + ...process.env, + }); + } + const result = EnvSchema.safeParse(process.env); if (!result.success) { console.error('Environment validation failed:'); From 20cfd35791f63544c635cd9bb95593c0ef65fa16 Mon Sep 17 00:00:00 2001 From: Yash Kumar Saini Date: Mon, 29 Jun 2026 10:28:44 +0530 Subject: [PATCH 6/6] feat(auth): GitHub OAuth owner login (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds passwordless owner authentication via GitHub OAuth + an owner-id gate. Strictly additive — scrypt password stays as a fallback so the existing Render deploy keeps working until OAuth env vars are wired up. Flow: GET /admin/auth/github → mint random state, set state cookie, redirect to github.com authorize GET /admin/auth/github/callback → verify state (constant-time), exchange code for token, fetch /user, check id === OWNER_GITHUB_ID, mint session New env vars (all four required together, or none): GITHUB_OAUTH_CLIENT_ID GITHUB_OAUTH_CLIENT_SECRET OWNER_GITHUB_ID — numeric (gh api /user --jq .id) OAUTH_CALLBACK_URL — must match the OAuth app's callback URL exactly Cross-field check in validateEnv() refuses partial config. .env.example and render.yaml updated with the new section. Login page now shows a "Sign in with GitHub" button when OAuth is on; both schemes mint the same in-memory session cookie via createSession(). Security: - read:user scope only (just enough to fetch the user id) - state cookie HttpOnly + Secure + SameSite=Lax + Path=/admin/auth + 5min TTL - constant-time state compare via safeEqual() - non-owner user id → 403 (credentials valid, just not authorized) - no PKCE — server-side confidential client with a real client_secret - token exchange + /user responses parsed via zod at the boundary - token-exchange internals never leak to the browser Tests: 9 new (87 total). Verified end-to-end via smoke: - with no OAuth env: /admin/auth/* → 404, login page falls back to password - with OAuth env: login shows GitHub button, /admin/auth/github → 302 to github.com, callback with bad state → 400. Phase 3 (HMAC session cookie) and Phase 4 (CSRF) follow. --- .env.example | 13 +++ render.yaml | 11 ++- src/__tests__/auth-github.test.ts | 91 ++++++++++++++++++ src/config/env.ts | 31 +++++- src/server/auth-github.ts | 153 ++++++++++++++++++++++++++++++ src/server/index.ts | 47 ++++++--- 6 files changed, 330 insertions(+), 16 deletions(-) create mode 100644 src/__tests__/auth-github.test.ts create mode 100644 src/server/auth-github.ts diff --git a/.env.example b/.env.example index 6424d59..2344037 100644 --- a/.env.example +++ b/.env.example @@ -72,6 +72,19 @@ DASHBOARD_PORT=3000 # Optional: password to protect the dashboard (leave blank = no auth). # Plaintext is hashed (scrypt) at startup; login issues a 10-minute session. +# Prefer GitHub OAuth below — no password to brute-force. # DASHBOARD_PASSWORD=your-strong-password # Or store a pre-computed salt:hash so the plaintext never lives in a file: # DASHBOARD_PASSWORD_HASH=salt:derivedKeyHex + +# ─── GitHub OAuth (recommended) ───────────────────────────────────────────── +# Owner-only login via "Sign in with GitHub". Set ALL FOUR vars or none. +# 1. Register an OAuth app: https://github.com/settings/developers +# - Homepage URL: https://admin. +# - Authorization callback URL: must match OAUTH_CALLBACK_URL exactly. +# 2. Find your numeric GitHub user id: `gh api /user --jq .id` +# 3. Only that user id can mint a session — every other GitHub user gets 403. +# GITHUB_OAUTH_CLIENT_ID=Iv1.xxxxxxxxxxxxxxxx +# GITHUB_OAUTH_CLIENT_SECRET=ghs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +# OWNER_GITHUB_ID=12345678 +# OAUTH_CALLBACK_URL=https://admin./admin/auth/github/callback diff --git a/render.yaml b/render.yaml index 2df45f1..6a90626 100644 --- a/render.yaml +++ b/render.yaml @@ -14,7 +14,16 @@ services: value: production # enables trust-proxy + Secure session cookie - key: PUBLISH_TARGETS value: notion,devto - - key: DASHBOARD_PASSWORD_HASH # scrypt of your unlock token (e.g. of "19012004") + - key: DASHBOARD_PASSWORD_HASH # scrypt of your unlock token (kept as fallback) + sync: false + # GitHub OAuth (recommended — passwordless, owner-only). Set ALL FOUR or none. + - key: GITHUB_OAUTH_CLIENT_ID + sync: false + - key: GITHUB_OAUTH_CLIENT_SECRET + sync: false + - key: OWNER_GITHUB_ID # your numeric GitHub user id (gh api /user --jq .id) + sync: false + - key: OAUTH_CALLBACK_URL # https:///admin/auth/github/callback sync: false - key: GITHUB_TOKEN sync: false diff --git a/src/__tests__/auth-github.test.ts b/src/__tests__/auth-github.test.ts new file mode 100644 index 0000000..d13b56c --- /dev/null +++ b/src/__tests__/auth-github.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { buildAuthorizeUrl, safeEqual } from '../server/auth-github.js'; + +describe('safeEqual (constant-time string comparison)', () => { + it('returns true for identical strings', () => { + expect(safeEqual('hello', 'hello')).toBe(true); + expect(safeEqual('', '')).toBe(true); + }); + + it('returns false for different strings of the same length', () => { + expect(safeEqual('hello', 'world')).toBe(false); + // Single-bit flip — must not match. + expect(safeEqual('abcdef0123456789', 'abcdef0123456788')).toBe(false); + }); + + it('returns false for different lengths without throwing', () => { + // The naive timingSafeEqual API throws on length mismatch — our wrapper must NOT. + expect(safeEqual('short', 'longer-string')).toBe(false); + expect(safeEqual('a', '')).toBe(false); + expect(safeEqual('', 'b')).toBe(false); + }); + + it('handles multi-byte UTF-8 (emoji, accents) by byte length', () => { + // '🚀' is 4 bytes in UTF-8; 'aaaa' is also 4 bytes — distinct bytes, same length. + expect(safeEqual('🚀', 'aaaa')).toBe(false); + expect(safeEqual('🚀', '🚀')).toBe(true); + }); +}); + +describe('buildAuthorizeUrl', () => { + it('builds a github.com authorize URL with the expected params', () => { + const url = buildAuthorizeUrl( + 'Iv1.client_id_here', + 'https://admin.example.com/admin/auth/github/callback', + 'state-abc-123', + ); + expect(url).toMatch(/^https:\/\/github\.com\/login\/oauth\/authorize\?/); + const params = new URL(url).searchParams; + expect(params.get('client_id')).toBe('Iv1.client_id_here'); + expect(params.get('redirect_uri')).toBe('https://admin.example.com/admin/auth/github/callback'); + expect(params.get('state')).toBe('state-abc-123'); + expect(params.get('scope')).toBe('read:user'); // we only need the user id + expect(params.get('allow_signup')).toBe('false'); + }); + + it('URL-encodes special characters in the state', () => { + const url = buildAuthorizeUrl('id', 'https://x/cb', 'a b/c+d'); + expect(new URL(url).searchParams.get('state')).toBe('a b/c+d'); + }); +}); + +describe('isOAuthEnabled', () => { + const ORIGINAL = { ...process.env }; + beforeEach(() => { + // Clear OAuth-related vars; safe under VITEST (env.ts won't exit on validation). + delete process.env.GITHUB_OAUTH_CLIENT_ID; + delete process.env.GITHUB_OAUTH_CLIENT_SECRET; + delete process.env.OWNER_GITHUB_ID; + delete process.env.OAUTH_CALLBACK_URL; + // Reset module cache so env.ts re-evaluates with the cleared values. + vi.resetModules(); + }); + afterEach(() => { + // Restore original process.env so other test files don't see our deletions. + for (const k of ['GITHUB_OAUTH_CLIENT_ID', 'GITHUB_OAUTH_CLIENT_SECRET', 'OWNER_GITHUB_ID', 'OAUTH_CALLBACK_URL']) { + if (ORIGINAL[k] !== undefined) process.env[k] = ORIGINAL[k]; + } + }); + + it('is false when nothing is set', async () => { + const mod = await import('../server/auth-github.js'); + expect(mod.isOAuthEnabled()).toBe(false); + }); + + it('is false when only some vars are set (partial config is rejected)', async () => { + process.env.GITHUB_OAUTH_CLIENT_ID = 'cid'; + process.env.GITHUB_OAUTH_CLIENT_SECRET = 'secret'; + // missing OWNER_GITHUB_ID and OAUTH_CALLBACK_URL + const mod = await import('../server/auth-github.js'); + expect(mod.isOAuthEnabled()).toBe(false); + }); + + it('is true only when all four vars are set', async () => { + process.env.GITHUB_OAUTH_CLIENT_ID = 'cid'; + process.env.GITHUB_OAUTH_CLIENT_SECRET = 'secret'; + process.env.OWNER_GITHUB_ID = '12345'; + process.env.OAUTH_CALLBACK_URL = 'https://admin.example.com/admin/auth/github/callback'; + const mod = await import('../server/auth-github.js'); + expect(mod.isOAuthEnabled()).toBe(true); + }); +}); diff --git a/src/config/env.ts b/src/config/env.ts index b7772ad..ca028ad 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -81,10 +81,20 @@ const EnvSchema = z.object({ // Dashboard (optional) DASHBOARD_PORT: z.coerce.number().int().min(1024).max(65535).default(3000), // Dashboard auth (optional — unset means the dashboard is public). Set a password - // to protect /runs, /run, /preview. Plaintext is hashed (scrypt) at boot; or supply - // a pre-computed `salt:hash` via DASHBOARD_PASSWORD_HASH so plaintext never hits disk. + // to protect /admin/*. Plaintext is hashed (scrypt) at boot; or supply a pre-computed + // `salt:hash` via DASHBOARD_PASSWORD_HASH so plaintext never hits disk. DASHBOARD_PASSWORD: z.string().optional(), DASHBOARD_PASSWORD_HASH: z.string().optional(), + + // GitHub OAuth (optional — enables passwordless owner login). When all four are set, + // the login page shows "Sign in with GitHub" and only the owner whose numeric GitHub + // user id equals OWNER_GITHUB_ID can mint a session. Register an OAuth app at: + // https://github.com/settings/developers + // Callback URL on that app must match OAUTH_CALLBACK_URL exactly. + GITHUB_OAUTH_CLIENT_ID: z.string().optional(), + GITHUB_OAUTH_CLIENT_SECRET: z.string().optional(), + OWNER_GITHUB_ID: z.coerce.number().int().positive().optional(), + OAUTH_CALLBACK_URL: z.string().url().optional(), }); export type Env = z.infer; @@ -134,6 +144,23 @@ function validateEnv(): Env { process.exit(1); } + // GitHub OAuth: any-one-set means all-four-must-be-set. Otherwise the login page + // would advertise a flow that 500s at runtime. + const oauthFields = [ + env.GITHUB_OAUTH_CLIENT_ID, + env.GITHUB_OAUTH_CLIENT_SECRET, + env.OWNER_GITHUB_ID, + env.OAUTH_CALLBACK_URL, + ]; + const oauthSet = oauthFields.filter((v) => v !== undefined && v !== '').length; + if (oauthSet > 0 && oauthSet < 4) { + console.error( + ' GitHub OAuth is partially configured. Set ALL of GITHUB_OAUTH_CLIENT_ID, ' + + 'GITHUB_OAUTH_CLIENT_SECRET, OWNER_GITHUB_ID, OAUTH_CALLBACK_URL — or none.', + ); + process.exit(1); + } + // Hashnode validation if (env.PUBLISH_TARGETS.includes('hashnode')) { if (!env.HASHNODE_TOKEN) { diff --git a/src/server/auth-github.ts b/src/server/auth-github.ts new file mode 100644 index 0000000..5f0c5b8 --- /dev/null +++ b/src/server/auth-github.ts @@ -0,0 +1,153 @@ +/** + * GitHub OAuth for owner login — additive to the scrypt password path. + * + * Flow: + * 1. GET /admin/auth/github → mint random `state`, set state cookie, + * redirect to github.com/login/oauth/authorize. + * 2. (user authorizes on github.com) + * 3. GET /admin/auth/github/callback → verify state cookie (constant-time), + * exchange `code` for an access token, + * fetch /user, check id === OWNER_GITHUB_ID, + * mint a session via createSession(). + * + * No PKCE — we are a confidential (server-side) client with a real client_secret. + * State cookie is scoped to /admin/auth so it isn't sent on every request. + */ +import type { Request, Response } from 'express'; +import { randomBytes, timingSafeEqual } from 'node:crypto'; +import { z } from 'zod'; +import { env } from '../config/env.js'; +import { createSession, readCookie, SESSION_COOKIE, SESSION_TTL_MS } from './auth.js'; + +const STATE_COOKIE = 'oauth_state'; +const STATE_TTL_MS = 5 * 60 * 1000; // 5 minutes — generous round-trip budget +const GITHUB_AUTHORIZE_URL = 'https://github.com/login/oauth/authorize'; +const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; +const GITHUB_USER_URL = 'https://api.github.com/user'; + +/** True only when ALL four GitHub OAuth env vars are configured. */ +export function isOAuthEnabled(): boolean { + return Boolean( + env.GITHUB_OAUTH_CLIENT_ID && + env.GITHUB_OAUTH_CLIENT_SECRET && + env.OWNER_GITHUB_ID && + env.OAUTH_CALLBACK_URL, + ); +} + +/** Constant-time string equality. Returns false on length mismatch instead of throwing. */ +export function safeEqual(a: string, b: string): boolean { + const aBuf = Buffer.from(a, 'utf8'); + const bBuf = Buffer.from(b, 'utf8'); + if (aBuf.length !== bBuf.length) return false; + return timingSafeEqual(aBuf, bBuf); +} + +/** Build the github.com authorize URL — pure for unit testability. */ +export function buildAuthorizeUrl(clientId: string, callbackUrl: string, state: string): string { + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: callbackUrl, + state, + scope: 'read:user', // we only need the user id; nothing more + allow_signup: 'false', + }); + return `${GITHUB_AUTHORIZE_URL}?${params.toString()}`; +} + +/** GET /admin/auth/github — kick off the OAuth dance. */ +export function oauthStartHandler(req: Request, res: Response): void { + if (!isOAuthEnabled()) { + res.status(404).send('GitHub OAuth is not configured on this deployment.'); + return; + } + const state = randomBytes(32).toString('hex'); + res.cookie(STATE_COOKIE, state, { + httpOnly: true, + sameSite: 'lax', // Lax not Strict — the redirect back from github.com is a top-level GET + secure: process.env.NODE_ENV === 'production', + maxAge: STATE_TTL_MS, + path: '/admin/auth', + }); + res.redirect(buildAuthorizeUrl(env.GITHUB_OAUTH_CLIENT_ID!, env.OAUTH_CALLBACK_URL!, state)); +} + +// GitHub returns `{access_token, token_type, scope}` on success or +// `{error, error_description}` on failure. zod-parse both shapes. +const TokenResponseSchema = z.union([ + z.object({ access_token: z.string().min(1), token_type: z.string(), scope: z.string() }), + z.object({ error: z.string(), error_description: z.string().optional() }), +]); +const UserResponseSchema = z.object({ id: z.number().int().positive(), login: z.string() }); + +async function exchangeCodeForToken(code: string): Promise { + const res = await fetch(GITHUB_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + client_id: env.GITHUB_OAUTH_CLIENT_ID, + client_secret: env.GITHUB_OAUTH_CLIENT_SECRET, + code, + redirect_uri: env.OAUTH_CALLBACK_URL, + }), + }); + if (!res.ok) throw new Error(`token exchange failed: HTTP ${res.status}`); + const parsed = TokenResponseSchema.safeParse(await res.json()); + if (!parsed.success) throw new Error('token exchange: unrecognized response shape'); + if ('error' in parsed.data) throw new Error(`token exchange: ${parsed.data.error}`); + return parsed.data.access_token; +} + +async function fetchUserId(accessToken: string): Promise { + const res = await fetch(GITHUB_USER_URL, { + headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/vnd.github+json' }, + }); + if (!res.ok) throw new Error(`/user fetch failed: HTTP ${res.status}`); + const parsed = UserResponseSchema.safeParse(await res.json()); + if (!parsed.success) throw new Error('/user: unrecognized response shape'); + return parsed.data.id; +} + +/** GET /admin/auth/github/callback — verify state, exchange code, gate on owner id. */ +export async function oauthCallbackHandler(req: Request, res: Response): Promise { + if (!isOAuthEnabled()) { + res.status(404).send('GitHub OAuth is not configured on this deployment.'); + return; + } + + // Always clear the state cookie — single-use, win or lose. + res.clearCookie(STATE_COOKIE, { path: '/admin/auth' }); + + const code = typeof req.query.code === 'string' ? req.query.code : ''; + const returnedState = typeof req.query.state === 'string' ? req.query.state : ''; + const cookieState = readCookie(req.headers.cookie, STATE_COOKIE) ?? ''; + + if (!code || !returnedState || !cookieState || !safeEqual(returnedState, cookieState)) { + res.status(400).send('OAuth callback rejected: state mismatch or missing code.'); + return; + } + + try { + const token = await exchangeCodeForToken(code); + const userId = await fetchUserId(token); + if (userId !== env.OWNER_GITHUB_ID) { + // 403 not 401 — credentials were valid, the user is just not authorized. + console.warn(`OAuth: rejected non-owner user id ${userId} (expected ${env.OWNER_GITHUB_ID})`); + res.status(403).send('This dashboard is owner-only.'); + return; + } + const sessionId = createSession(); + res.cookie(SESSION_COOKIE, sessionId, { + httpOnly: true, + sameSite: 'lax', // matches the OAuth redirect-back flow + secure: process.env.NODE_ENV === 'production', + maxAge: SESSION_TTL_MS, + path: '/', + }); + res.redirect('/admin/runs'); + } catch (err) { + // Don't leak token-exchange internals to the browser; log full detail server-side. + console.error('OAuth callback error:', err); + res.status(502).send('OAuth callback failed — see server logs.'); + } +} diff --git a/src/server/index.ts b/src/server/index.ts index a344b7c..68cd9d7 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -4,6 +4,7 @@ import { env } from '../config/env.js'; import { STYLES } from './views/layout.js'; import { authMiddleware } from './middleware/auth.js'; import { isAuthEnabled, verifyPassword, createSession, destroySession, readCookie, safeInternalPath, loginAllowed, recordLoginFailure, clearLoginAttempts, SESSION_COOKIE, SESSION_TTL_MS } from './auth.js'; +import { isOAuthEnabled, oauthStartHandler, oauthCallbackHandler } from './auth-github.js'; import { landingRouter } from './routes/landing.js'; import { dashboardRouter } from './routes/dashboard.js'; import { runRouter } from './routes/run.js'; @@ -26,9 +27,18 @@ app.use('/generated', express.static(join(process.cwd(), 'assets', 'generated')) // Public landing page (no auth) app.use('/', landingRouter); -// Login / logout — reachable without auth; active only when a password is configured. +// GitHub OAuth start + callback — public routes (login flow). Mounted before +// authMiddleware so /admin/auth/* doesn't require an existing session. +app.get('/admin/auth/github', oauthStartHandler); +app.get('/admin/auth/github/callback', oauthCallbackHandler); + +// Login / logout — reachable without auth; active when EITHER OAuth is configured +// or a password is set. Both schemes mint the same session cookie; OAuth is the +// preferred path (no password to brute-force). app.get('/admin/login', (req, res) => { - if (!isAuthEnabled()) { + const oauthOn = isOAuthEnabled(); + const passwordOn = isAuthEnabled(); + if (!oauthOn && !passwordOn) { res.redirect('/admin/runs'); return; } @@ -37,6 +47,20 @@ app.get('/admin/login', (req, res) => { const error = req.query.error ? '
Wrong password — try again.
' : ''; + const githubButton = oauthOn + ? /* html */ `Sign in with GitHub` + : ''; + const passwordForm = passwordOn + ? /* html */ `${oauthOn ? '
or
' : ''} + + +
+ + +
+ + ` + : ''; res.send(/* html */ ` @@ -50,14 +74,8 @@ app.get('/admin/login', (req, res) => {

📊 DevNotion

${error} -
- -
- - -
- - + ${githubButton} + ${passwordForm}
@@ -141,10 +159,13 @@ export function startServer(): void { const port = Number(process.env.PORT) || env.DASHBOARD_PORT; app.listen(port, () => { console.log(`✓ DevNotion Dashboard running at http://localhost:${port}`); - if (!isAuthEnabled()) { - console.warn('⚠ No DASHBOARD_PASSWORD/HASH set — dashboard is view-only and ALL mutations (run, publish, delete) are blocked (fail-closed). Set DASHBOARD_PASSWORD_HASH to enable them.'); + const oauthOn = isOAuthEnabled(); + const passwordOn = isAuthEnabled(); + if (!oauthOn && !passwordOn) { + console.warn('⚠ No auth configured — dashboard is view-only and ALL mutations (run, publish, delete) are blocked (fail-closed). Set GitHub OAuth env vars (preferred) or DASHBOARD_PASSWORD_HASH.'); } else { - console.log('🔒 Public view enabled; mutations (run/publish/delete) require the dashboard token.'); + const modes = [oauthOn && 'GitHub OAuth (owner-only)', passwordOn && 'scrypt password'].filter(Boolean).join(' + '); + console.log(`🔒 Public view enabled; mutations require auth via: ${modes}.`); } }); }