Skip to content
Merged
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
8 changes: 3 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
Expand All @@ -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
Expand Down
26 changes: 13 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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)

Expand Down
114 changes: 57 additions & 57 deletions docs/cloudflare-tunnel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -151,27 +151,27 @@ 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` |

---

## Before you start

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:

Expand Down Expand Up @@ -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).

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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**.

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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`.

Expand Down
17 changes: 13 additions & 4 deletions scripts/cloudflare-access-oauth-bypass.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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`);
Expand All @@ -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.`,
);
Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/auth/disconnect/route.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down