Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.<your-domain>
# - 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.<your-domain>/admin/auth/github/callback
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
243 changes: 243 additions & 0 deletions docs/plans/secure-public-deploy.md
Original file line number Diff line number Diff line change
@@ -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.<domain> │
│ (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.<domain> │
│ (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.<domain>`
- Callback: `https://admin.devnotion.<domain>/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 `<sessionId>.<HMAC(SESSION_SECRET, sessionId)>`.
- 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 `<input type="hidden" name="_csrf">`.
- 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.<domain>` → Vercel (public)
- `admin.devnotion.<domain>` → Render (owner)

### Phase 6 — Cut over
1. Deploy Phase 5 Vercel project to a preview URL, verify all published runs render.
2. Point `devnotion.<domain>` DNS to Vercel.
3. Repoint `admin.devnotion.<domain>` 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.<something>` 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`).
11 changes: 10 additions & 1 deletion render.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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://<this-render-url>/admin/auth/github/callback
sync: false
- key: GITHUB_TOKEN
sync: false
Expand Down
Loading
Loading