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/.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 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`). 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/__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__/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/__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/config/env.ts b/src/config/env.ts index 2abb836..ca028ad 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -81,15 +81,44 @@ 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; 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:'); @@ -115,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/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..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,17 +27,40 @@ 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. -app.get('/login', (req, res) => { - if (!isAuthEnabled()) { - res.redirect('/runs'); +// 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) => { + const oauthOn = isOAuthEnabled(); + const passwordOn = isAuthEnabled(); + if (!oauthOn && !passwordOn) { + 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.
' : ''; + const githubButton = oauthOn + ? /* html */ `Sign in with GitHub` + : ''; + const passwordForm = passwordOn + ? /* html */ `${oauthOn ? '
or
' : ''} +
+ +
+ + +
+ +
` + : ''; res.send(/* html */ ` @@ -50,21 +74,15 @@ app.get('/login', (req, res) => {

📊 DevNotion

${error} -
- -
- - -
- -
+ ${githubButton} + ${passwordForm}
`); }); -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 +104,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 { @@ -117,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}.`); } }); } 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)}

- + 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());