diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b15cf26..a6a742f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,6 @@ name: CI on: push: branches: [main] - pull_request: - branches: [main] concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} @@ -14,13 +12,13 @@ jobs: check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v5 with: version: 10.20.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: 22 cache: pnpm diff --git a/README.md b/README.md index dcab6a0..4eff669 100644 --- a/README.md +++ b/README.md @@ -91,15 +91,15 @@ Deploy using Railway's built-in `*.up.railway.app` HTTPS domain. A Cloudflare tu On the **app** service → **Variables**. Set these **before** the first successful deploy; the application will not start without them: -| Variable | Value | -| -------- | ----- | -| `APP_URL` | Your Railway domain (no trailing slash) | -| `NEXT_PUBLIC_APP_URL` | Same as `APP_URL` | -| `SPLITWISE_REDIRECT_URI` | `{APP_URL}/api/auth/splitwise/callback` | -| `SPLITWISE_CLIENT_ID` | From [secure.splitwise.com/apps](https://secure.splitwise.com/apps) | -| `SPLITWISE_CLIENT_SECRET` | From Splitwise | -| `SESSION_SECRET` | Output of `openssl rand -base64 32` | -| `DATABASE_URL` | `${{Postgres.DATABASE_URL}}` | +| Variable | Value | +| ------------------------- | ------------------------------------------------------------------- | +| `APP_URL` | Your Railway domain (no trailing slash) | +| `NEXT_PUBLIC_APP_URL` | Same as `APP_URL` | +| `SPLITWISE_REDIRECT_URI` | `{APP_URL}/api/auth/splitwise/callback` | +| `SPLITWISE_CLIENT_ID` | From [secure.splitwise.com/apps](https://secure.splitwise.com/apps) | +| `SPLITWISE_CLIENT_SECRET` | From Splitwise | +| `SESSION_SECRET` | Output of `openssl rand -base64 32` | +| `DATABASE_URL` | `${{Postgres.DATABASE_URL}}` | Do **not** set `PORT` — Railway injects the listen port automatically. Do **not** set `DEMO_MODE` in production. @@ -118,11 +118,11 @@ Do **not** set `PORT` — Railway injects the listen port automatically. Do **no **Troubleshooting** -| Symptom | Fix | -| ------- | --- | +| Symptom | Fix | +| -------------------------- | -------------------------------------------------------------------------------------------------------- | | Application fails to start | Verify **Variables**: `SESSION_SECRET` (32+ characters), `APP_URL`, and `SPLITWISE_REDIRECT_URI` are set | -| OAuth redirect mismatch | `SPLITWISE_REDIRECT_URI`, Splitwise app, and `APP_URL` must share the same origin | -| Database connection errors | Use `${{Postgres.DATABASE_URL}}` on the app service (not `DATABASE_PUBLIC_URL`) | +| OAuth redirect mismatch | `SPLITWISE_REDIRECT_URI`, Splitwise app, and `APP_URL` must share the same origin | +| Database connection errors | Use `${{Postgres.DATABASE_URL}}` on the app service (not `DATABASE_PUBLIC_URL`) | ### Cloudflare Tunnel (optional) diff --git a/docs/cloudflare-tunnel.md b/docs/cloudflare-tunnel.md index e060b8f..1b0a81a 100644 --- a/docs/cloudflare-tunnel.md +++ b/docs/cloudflare-tunnel.md @@ -52,12 +52,12 @@ flowchart TB style PG fill:#fff3e0 ``` -| Component | Public? | Role | -| --------- | ------- | ---- | -| Cloudflare DNS + tunnel | Yes | HTTPS entry; hides Railway IPs | -| `cloudflared` (Railway) | No inbound | Maintains outbound tunnel to Cloudflare | -| `open-splitwise` (Railway) | No | Next.js app; reachable only via `*.railway.internal` | -| Postgres (Railway) | **No** — disable TCP proxy | Data store; `DATABASE_URL` only, never `DATABASE_PUBLIC_URL` | +| Component | Public? | Role | +| -------------------------- | -------------------------- | ------------------------------------------------------------ | +| Cloudflare DNS + tunnel | Yes | HTTPS entry; hides Railway IPs | +| `cloudflared` (Railway) | No inbound | Maintains outbound tunnel to Cloudflare | +| `open-splitwise` (Railway) | No | Next.js app; reachable only via `*.railway.internal` | +| Postgres (Railway) | **No** — disable TCP proxy | Data store; `DATABASE_URL` only, never `DATABASE_PUBLIC_URL` | ### Setup sequence @@ -151,14 +151,14 @@ flowchart TB app --> data ``` -| Layer | What it does | Configured in | -| ----- | ------------ | ------------- | -| Tunnel | Outbound-only path to the app; blocks direct Railway/Docker exposure | Steps 1, 3–5 | -| Access (optional) | Login wall for humans | Step 6 | -| OAuth bypass | Lets Splitwise reach callback without Access JWT | Step 6b / `pnpm cloudflare:access-oauth-bypass` | -| App session | OAuth token in encrypted cookie; never sent to client JS | `SESSION_SECRET`, Settings → Connect | -| API middleware | Rejects unauthenticated API calls | Built into app | -| Sample data | Hides real expenses when mask icon is on | Header toggle / `DEMO_MODE` | +| Layer | What it does | Configured in | +| ----------------- | -------------------------------------------------------------------- | ----------------------------------------------- | +| Tunnel | Outbound-only path to the app; blocks direct Railway/Docker exposure | Steps 1, 3–5 | +| Access (optional) | Login wall for humans | Step 6 | +| OAuth bypass | Lets Splitwise reach callback without Access JWT | Step 6b / `pnpm cloudflare:access-oauth-bypass` | +| App session | OAuth token in encrypted cookie; never sent to client JS | `SESSION_SECRET`, Settings → Connect | +| API middleware | Rejects unauthenticated API calls | Built into app | +| Sample data | Hides real expenses when mask icon is on | Header toggle / `DEMO_MODE` | --- @@ -166,12 +166,12 @@ flowchart TB You will need: -| Item | Notes | -| ---- | ----- | +| Item | Notes | +| ---------------------------- | --------------------------------------------------------------------------------------------------------------- | | **Domain on Cloudflare DNS** | Nameservers pointed at Cloudflare ([full setup](https://developers.cloudflare.com/dns/zone-setups/full-setup/)) | -| **SSL/TLS mode** | Cloudflare dashboard → **SSL/TLS** → **Overview** → **Full** (not Flexible) | -| **Splitwise OAuth app** | [secure.splitwise.com/apps](https://secure.splitwise.com/apps) — you will set the redirect URI in step 8 | -| **Public hostname** | Example: `split.example.com` — used for `APP_URL` and the tunnel | +| **SSL/TLS mode** | Cloudflare dashboard → **SSL/TLS** → **Overview** → **Full** (not Flexible) | +| **Splitwise OAuth app** | [secure.splitwise.com/apps](https://secure.splitwise.com/apps) — you will set the redirect URI in step 8 | +| **Public hostname** | Example: `split.example.com` — used for `APP_URL` and the tunnel | Generate a session secret once: @@ -208,17 +208,17 @@ openssl rand -base64 32 **2b. Set environment variables** on the **open-splitwise** service: -| Variable | Value | -| -------- | ----- | -| `APP_URL` | `https://split.example.com` (your public hostname — no trailing slash) | -| `NEXT_PUBLIC_APP_URL` | same as `APP_URL` | -| `SPLITWISE_REDIRECT_URI` | `https://split.example.com/api/auth/splitwise/callback` | -| `SPLITWISE_CLIENT_ID` | from Splitwise | -| `SPLITWISE_CLIENT_SECRET` | from Splitwise | -| `SESSION_SECRET` | output of `openssl rand -base64 32` | -| `DATABASE_URL` | reference Postgres: `${{Postgres.DATABASE_URL}}` | -| `PORT` | `3000` | -| `HOSTNAME` | `::` | +| Variable | Value | +| ------------------------- | ---------------------------------------------------------------------- | +| `APP_URL` | `https://split.example.com` (your public hostname — no trailing slash) | +| `NEXT_PUBLIC_APP_URL` | same as `APP_URL` | +| `SPLITWISE_REDIRECT_URI` | `https://split.example.com/api/auth/splitwise/callback` | +| `SPLITWISE_CLIENT_ID` | from Splitwise | +| `SPLITWISE_CLIENT_SECRET` | from Splitwise | +| `SESSION_SECRET` | output of `openssl rand -base64 32` | +| `DATABASE_URL` | reference Postgres: `${{Postgres.DATABASE_URL}}` | +| `PORT` | `3000` | +| `HOSTNAME` | `::` | > **Why `PORT=3000`?** Railway injects `PORT=8080` by default, but the tunnel origin in step 4 uses port **3000**. If these do not match, you get **502** errors (`connection refused` in cloudflared logs). @@ -331,13 +331,13 @@ Back on the tunnel page from step 1 (connector should show **Healthy**): 1. **Configure** → **Public Hostname** → **Add a public hostname**. 2. Set: - | Field | Railway | Docker Compose | - | ----- | ------- | -------------- | - | **Subdomain** | `split` (or your choice) | same | - | **Domain** | your zone | same | - | **Path** | *(empty)* | *(empty)* | - | **Type** | **HTTP** | **HTTP** | - | **URL** | `open-splitwise.railway.internal:3000` | `app:3000` | + | Field | Railway | Docker Compose | + | ------------- | -------------------------------------- | -------------- | + | **Subdomain** | `split` (or your choice) | same | + | **Domain** | your zone | same | + | **Path** | _(empty)_ | _(empty)_ | + | **Type** | **HTTP** | **HTTP** | + | **URL** | `open-splitwise.railway.internal:3000` | `app:3000` | Use your actual `RAILWAY_PRIVATE_DOMAIN` value for Railway. **Type must be HTTP** — Cloudflare terminates HTTPS; the tunnel talks to the origin in plain HTTP on the private network. @@ -370,7 +370,7 @@ Skip this step if you do not want a login wall in front of the app. **6a. Protect the whole site** 1. [Zero Trust](https://one.dash.cloudflare.com/) → **Access** → **Applications** → **Add an application** → **Self-hosted**. -2. **Application domain:** `split.example.com`, **Path:** *(empty)* +2. **Application domain:** `split.example.com`, **Path:** _(empty)_ 3. Add an **Allow** policy (e.g. your email, Google, or OTP). 4. **Save**. @@ -399,9 +399,9 @@ pnpm cloudflare:access-oauth-bypass -- --verify The script creates (idempotent): -| App | Path | Policy | -| --- | ---- | ------ | -| `open-splitwise OAuth start` | `/api/auth/splitwise*` | Bypass + Everyone | +| App | Path | Policy | +| ------------------------------- | ------------------------------ | ----------------- | +| `open-splitwise OAuth start` | `/api/auth/splitwise*` | Bypass + Everyone | | `open-splitwise OAuth callback` | `/api/auth/splitwise/callback` | Bypass + Everyone | **Verify:** In a private window, `https://split.example.com/api/auth/splitwise/config` returns **JSON** (status 200), not a redirect to `cloudflareaccess.com`. The home page should still require Access login. @@ -434,28 +434,28 @@ The script creates (idempotent): ## Step 8 — Test end-to-end -| Check | Expected | -| ----- | -------- | -| `curl -sI https://split.example.com/api/health` | 200 or 302 to Access (if step 6 enabled) | +| Check | Expected | +| ------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| `curl -sI https://split.example.com/api/health` | 200 or 302 to Access (if step 6 enabled) | | `curl -s https://split.example.com/api/auth/splitwise/config` | JSON with `effective` redirect URI (200; no Access redirect if step 6b done) | -| App logs | `Network: http://[::]:3000` | -| cloudflared logs | `Registered tunnel connection`, no `connection refused` | -| In browser (after Access login) | **Settings** → **Connect Splitwise** completes OAuth | +| App logs | `Network: http://[::]:3000` | +| cloudflared logs | `Registered tunnel connection`, no `connection refused` | +| In browser (after Access login) | **Settings** → **Connect Splitwise** completes OAuth | --- ## Troubleshooting -| Symptom | Fix | -| ------- | --- | -| Tunnel connector offline | Check `TUNNEL_TOKEN` (no extra whitespace); cloudflared container running | -| **502** on public URL | Origin port mismatch — set `PORT=3000` on Railway app **or** change tunnel URL to `:8080`; confirm app logs show the same port | -| `connection refused` in cloudflared logs | Set `HOSTNAME=::` on Railway app; confirm `RAILWAY_PRIVATE_DOMAIN:3000` in tunnel hostname | -| OAuth redirect mismatch | `SPLITWISE_REDIRECT_URI`, Splitwise app, and `APP_URL` must all use the same hostname | -| OAuth fails with Access enabled | Run `pnpm cloudflare:access-oauth-bypass -- --verify` | -| Wrong service deployed to cloudflared | Redeploy with `railway up deploy/cloudflared --path-as-root` from repo root | -| Postgres exposed on host | Tunnel compose overlay removes public ports; dev compose binds Postgres to `127.0.0.1` only | -| Postgres reachable from internet | Railway **Postgres → Networking** — disable TCP proxy; use `DATABASE_URL` (internal), never `DATABASE_PUBLIC_URL` | +| Symptom | Fix | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| Tunnel connector offline | Check `TUNNEL_TOKEN` (no extra whitespace); cloudflared container running | +| **502** on public URL | Origin port mismatch — set `PORT=3000` on Railway app **or** change tunnel URL to `:8080`; confirm app logs show the same port | +| `connection refused` in cloudflared logs | Set `HOSTNAME=::` on Railway app; confirm `RAILWAY_PRIVATE_DOMAIN:3000` in tunnel hostname | +| OAuth redirect mismatch | `SPLITWISE_REDIRECT_URI`, Splitwise app, and `APP_URL` must all use the same hostname | +| OAuth fails with Access enabled | Run `pnpm cloudflare:access-oauth-bypass -- --verify` | +| Wrong service deployed to cloudflared | Redeploy with `railway up deploy/cloudflared --path-as-root` from repo root | +| Postgres exposed on host | Tunnel compose overlay removes public ports; dev compose binds Postgres to `127.0.0.1` only | +| Postgres reachable from internet | Railway **Postgres → Networking** — disable TCP proxy; use `DATABASE_URL` (internal), never `DATABASE_PUBLIC_URL` | **Logs:** Railway service logs, or `docker compose … logs cloudflared`. diff --git a/scripts/cloudflare-access-oauth-bypass.mjs b/scripts/cloudflare-access-oauth-bypass.mjs index f19111d..69f0571 100755 --- a/scripts/cloudflare-access-oauth-bypass.mjs +++ b/scripts/cloudflare-access-oauth-bypass.mjs @@ -83,7 +83,8 @@ async function cf(method, path, body) { }); const data = await res.json(); if (!data.success) { - const detail = data.errors?.map((e) => e.message).join("; ") ?? res.statusText; + const detail = + data.errors?.map((e) => e.message).join("; ") ?? res.statusText; throw new Error(`Cloudflare API ${method} ${path}: ${detail}`); } return data.result; @@ -159,7 +160,9 @@ async function ensureBypassApp(existingApps, spec) { if (existing) { if (hasBypassPolicy(existing)) { - console.log(`OK ${spec.name} (already bypasses ${publicUri(appHost, spec.path)})`); + console.log( + `OK ${spec.name} (already bypasses ${publicUri(appHost, spec.path)})`, + ); return existing; } console.log(`UPDATE ${spec.name} — adding bypass policy`); @@ -176,7 +179,11 @@ async function verifyBypass() { const url = `https://${appHost}/api/auth/splitwise/config`; const res = await fetch(url, { redirect: "manual" }); const location = res.headers.get("location") ?? ""; - if (res.status >= 300 && res.status < 400 && location.includes("cloudflareaccess.com")) { + if ( + res.status >= 300 && + res.status < 400 && + location.includes("cloudflareaccess.com") + ) { throw new Error( `${url} still redirects to Cloudflare Access (${res.status}). Bypass apps may need a minute to propagate.`, ); @@ -190,7 +197,9 @@ async function verifyBypass() { ); } if (!res.ok) { - throw new Error(`${url} returned ${res.status} (expected 200 JSON, not Access redirect)`); + throw new Error( + `${url} returned ${res.status} (expected 200 JSON, not Access redirect)`, + ); } const body = await res.json(); if (typeof body !== "object" || body === null) { diff --git a/src/app/api/auth/disconnect/route.ts b/src/app/api/auth/disconnect/route.ts index 54863a0..edbfbfe 100644 --- a/src/app/api/auth/disconnect/route.ts +++ b/src/app/api/auth/disconnect/route.ts @@ -1,4 +1,4 @@ -import { getAppSession, clearAppSession } from "@/lib/session"; +import { clearAppSession } from "@/lib/session"; import { NextResponse } from "next/server"; /** Ends the session only; synced data in Postgres is kept until explicitly deleted. */