|
1 | | -# budget-api |
| 1 | +# Budget Automation API (Actual Budget wrapper) |
| 2 | + |
| 3 | +A secure Node.js/Express REST API that wraps the Actual Budget SDK (`@actual-app/api`). It provides JWT-based auth, optional OAuth2 for n8n, Swagger documentation, and a hardened runtime (helmet, CORS, structured logging, rate limits per route). |
| 4 | + |
| 5 | +## Features |
| 6 | +- Authentication: JWT access/refresh tokens, session login for docs |
| 7 | +- Optional OAuth2: first-party flow for n8n (`/oauth/authorize`, `/oauth/token`) |
| 8 | +- Endpoints: accounts, transactions, budgets, categories, payees, rules, schedules, query |
| 9 | +- API Docs: protected Swagger UI at `/docs` with OpenAPI source in [src/docs/openapi.yml](src/docs/openapi.yml) |
| 10 | +- Security: helmet headers, request IDs, SQLite token revocation, minimal defaults |
| 11 | +- Docker: production image + dev `docker compose` stack (Actual Server + n8n) |
| 12 | + |
| 13 | +## Requirements |
| 14 | +- Node.js 22+ and npm |
| 15 | +- Actual Budget Server credentials (or use the dev `docker compose` stack) |
| 16 | +- For OAuth2 to n8n (optional): n8n instance and client credentials |
| 17 | +- For production: dotenvx CLI and encrypted `.env` file with `DOTENV_PRIVATE_KEY` |
| 18 | + |
| 19 | +## Development |
| 20 | +`npm run dev` works locally when Actual Server is running and all required env vars are set. Choose one approach below. |
| 21 | + |
| 22 | +### Option 1: Docker Dev Stack (Full-featured, Simplest for Local Testing) |
| 23 | +Bring up all services (API + Actual Server + n8n) with automatic rebuilds on code changes: |
| 24 | + |
| 25 | +```bash |
| 26 | +docker compose -f docker-compose.dev.yml up --build --force-recreate --remove-orphans |
| 27 | +``` |
| 28 | + |
| 29 | +First-run initialization (Actual Server): |
| 30 | +- On first startup, Actual Server is not configured. |
| 31 | +- Open http://localhost:5006 and set a password. |
| 32 | +- Create/open your budget and obtain the Sync ID: |
| 33 | + - In the Actual app, go to Settings → Advanced → Show Sync ID (or similar). |
| 34 | +- Add the following to `.env.local` (mounted as `/app/.env` in the API container): |
| 35 | + |
| 36 | +``` |
| 37 | +ADMIN_PW=ChangeMe_very_strong! |
| 38 | +JWT_SECRET=replace-with-64b-random |
| 39 | +JWT_REFRESH_SECRET=replace-with-64b-random |
| 40 | +
|
| 41 | +ACTUAL_SERVER_URL=http://actual-server-dev:5006 |
| 42 | +ACTUAL_PASSWORD=<the password you set in Actual Server> |
| 43 | +ACTUAL_SYNC_ID=<your budget sync id> |
| 44 | +
|
| 45 | +# Optional n8n OAuth2 |
| 46 | +N8N_CLIENT_ID=example-n8n |
| 47 | +N8N_CLIENT_SECRET=replace-with-long-secret |
| 48 | +N8N_OAUTH2_CALLBACK_URL=http://localhost:5678/rest/oauth2-credential/callback |
| 49 | +``` |
| 50 | + |
| 51 | +Then restart the API container (or re-run the compose command) so it picks up `.env.local`. |
| 52 | + |
| 53 | +Ports: |
| 54 | +- API: http://localhost:3000 |
| 55 | +- n8n: http://localhost:5678 |
| 56 | +- Actual Server: http://localhost:5006 |
| 57 | + |
| 58 | +### Option 2: Host Dev with Docker Actual Server (Hot Reload, Faster Iteration) |
| 59 | +Run Actual Server in Docker; run the API on your host with live reload (no rebuild needed): |
| 60 | + |
| 61 | +1. Start Actual Server only: |
| 62 | + |
| 63 | +```bash |
| 64 | +docker compose -f docker-compose.dev.yml up -d actual-server |
| 65 | +``` |
| 66 | + |
| 67 | +2. Set env vars locally and run the API: |
| 68 | + |
| 69 | +```bash |
| 70 | +export ADMIN_PW=ChangeMe_very_strong! |
| 71 | +export JWT_SECRET=replace-with-64b-random |
| 72 | +export JWT_REFRESH_SECRET=replace-with-64b-random |
| 73 | +export ACTUAL_SERVER_URL=http://localhost:5006 |
| 74 | +export ACTUAL_PASSWORD=<actual password> |
| 75 | +export ACTUAL_SYNC_ID=<budget sync id> |
| 76 | +export ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5678 |
| 77 | +npm ci |
| 78 | +npm run dev |
| 79 | +``` |
| 80 | + |
| 81 | +Benefits: code changes reload instantly via Node 22 `--watch`; no rebuilds needed. |
| 82 | + |
| 83 | +## Production |
| 84 | +Set the same Actual credentials via environment variables in `.env` (not `.env.local`). The production Actual Server provides the password and sync id values. For n8n in production: |
| 85 | +- Configure the same OAuth2 endpoint and credentials |
| 86 | +- Ensure `ALLOWED_ORIGINS` includes your n8n instance's URL |
| 87 | +- Use HTTPS for the callback URL |
| 88 | + |
| 89 | +### Environment Management with dotenvx |
| 90 | +This project uses **dotenvx** to manage encrypted environment files. In production, a `.env` file (or encrypted `.env.prod`) is required along with the decryption key. |
| 91 | + |
| 92 | +1. Create a `.env` file with production values or use dotenvx to encrypt it: |
| 93 | + |
| 94 | +```bash |
| 95 | +# Initialize dotenvx (generates .env.keys) |
| 96 | +dotenvx new |
| 97 | + |
| 98 | +# Encrypt your production .env |
| 99 | +dotenvx set ADMIN_PW "your-production-password" |
| 100 | +dotenvx set JWT_SECRET "your-production-jwt-secret" |
| 101 | +# ... repeat for other required vars |
| 102 | +``` |
| 103 | + |
| 104 | +2. Securely store the private key: |
| 105 | + - The key is in `.env.keys` (never commit this) |
| 106 | + - Provide `DOTENV_PRIVATE_KEY` to the runtime (e.g., as a secret in CI/CD or container env) |
| 107 | + - The Docker container reads `DOTENV_PRIVATE_KEY` from the environment or compose file |
| 108 | + |
| 109 | +3. Run with dotenvx: |
| 110 | + |
| 111 | +```bash |
| 112 | +# With the private key set: |
| 113 | +export DOTENV_PRIVATE_KEY="$(grep DOTENV_PRIVATE_KEY .env.keys | cut -d '=' -f2 | tail -n1)" |
| 114 | + |
| 115 | +# Or in Docker: |
| 116 | +docker run \ |
| 117 | + -v ./data/actual-api:/app/.actual-cache \ |
| 118 | + -p 3000:3000 \ |
| 119 | + actual-api-wrapper:latest |
| 120 | + |
| 121 | +# Or with compose: |
| 122 | +docker compose up -d --force-recreate --build |
| 123 | +``` |
| 124 | + |
| 125 | +See the [docker-compose.yml](docker-compose.yml) for how it's wired up in containers. |
| 126 | + |
| 127 | +## Environment Variables |
| 128 | +- ADMIN_USER: admin username (default `admin`) |
| 129 | +- ADMIN_PW: required; admin password (validated for complexity) |
| 130 | +- SESSION_SECRET: required in production (random in dev if omitted) |
| 131 | +- JWT_SECRET: required; HMAC secret for access tokens |
| 132 | +- JWT_REFRESH_SECRET: required; HMAC secret for refresh tokens |
| 133 | +- JWT_ACCESS_TTL: optional, default `1h` (supports `30m`, `3600`, etc.) |
| 134 | +- JWT_REFRESH_TTL: optional, default `24h` |
| 135 | +- PORT: server port (default `3000`) |
| 136 | +- ALLOWED_ORIGINS: CSV of allowed origins for CORS |
| 137 | +- TRUST_PROXY: set `true` if running behind a reverse proxy |
| 138 | +- LOG_LEVEL: winston log level (default `info`) |
| 139 | +- DATA_DIR: Actual data directory (default `/app/.actual-cache`); stores `auth.db` |
| 140 | +- ACTUAL_SERVER_URL: Actual server URL (e.g., `http://localhost:5006`) |
| 141 | +- ACTUAL_PASSWORD: Actual server password |
| 142 | +- ACTUAL_SYNC_ID: Budget sync ID |
| 143 | +- N8N_CLIENT_ID / N8N_CLIENT_SECRET / N8N_OAUTH2_CALLBACK_URL: enable OAuth2 endpoints when all are present |
| 144 | + |
| 145 | +## API Docs & Validation |
| 146 | +- OpenAPI source: [src/docs/openapi.yml](src/docs/openapi.yml) |
| 147 | +- Local docs (auth required): GET `/docs` |
| 148 | +- Validate OpenAPI: |
| 149 | + |
| 150 | +```bash |
| 151 | +npm run validate:openapi |
| 152 | +``` |
| 153 | + |
| 154 | +## Auth Flows |
| 155 | +- Local login (session for docs): |
| 156 | + - GET `/login` → render form |
| 157 | + - POST `/login` → create session, then access `/docs` |
| 158 | +- JWT login: |
| 159 | + - POST `/auth/login` with `{ "username": "admin", "password": "..." }` |
| 160 | + - Response contains `access_token`, `refresh_token`, `expires_in` |
| 161 | + - Send `Authorization: Bearer <access_token>` to protected routes |
| 162 | +- n8n OAuth2 (optional): |
| 163 | + - Configure env vars listed above |
| 164 | + - Endpoints available: `/oauth/authorize`, `/oauth/token` |
| 165 | + - See [Connecting n8n](#connecting-n8n) for setup details. |
| 166 | + |
| 167 | +## Connecting n8n |
| 168 | +n8n can integrate with the API via OAuth2 for secure token-based workflows. Use either built-in session/JWT auth or the OAuth2 flow. |
| 169 | + |
| 170 | +### Option 1: Basic Auth or Bearer Token (Quick Start) |
| 171 | +For development, you can use manual session login or JWT bearer tokens: |
| 172 | + |
| 173 | +1. Log in via basic auth: |
| 174 | + - Enter credentials in n8n HTTP node with basic auth. |
| 175 | + |
| 176 | +2. Or, obtain a bearer token: |
| 177 | + - POST to `/auth/login` with `{ "username": "admin", "password": "..." }` |
| 178 | + - Copy the `access_token` from the response |
| 179 | + - In n8n, create a credential of type "Generic Credential Type" or similar HTTP auth |
| 180 | + - Set header: `Authorization: Bearer <access_token>` |
| 181 | + |
| 182 | +### Option 2: OAuth2 Flow (Production Recommended) |
| 183 | +Set up OAuth2 for secure, refreshable tokens: |
| 184 | + |
| 185 | +1. Configure env vars (already in `.env.local` example above): |
| 186 | + - `N8N_CLIENT_ID`: a unique identifier (e.g., `example-n8n`) |
| 187 | + - `N8N_CLIENT_SECRET`: a long random secret (32+ chars) |
| 188 | + - `N8N_OAUTH2_CALLBACK_URL`: n8n's OAuth callback URL (e.g., `http://localhost:5678/rest/oauth2-credential/callback`) |
| 189 | + |
| 190 | +2. In n8n, add a new credential: |
| 191 | + - Select **OAuth2** type |
| 192 | + - **Authorization URL**: `http://localhost:3000/oauth/authorize` |
| 193 | + - **Token URL**: `http://actual-api-wrapper-dev:3000/oauth/token` |
| 194 | + - **Client ID**: same as `N8N_CLIENT_ID` |
| 195 | + - **Client Secret**: same as `N8N_CLIENT_SECRET` |
| 196 | + - **Redirect URL**: same as `N8N_OAUTH2_CALLBACK_URL` |
| 197 | + - Authorize and use in n8n workflows |
| 198 | + |
| 199 | +3. Test in n8n: |
| 200 | + - Add an HTTP request node |
| 201 | + - Set URL to an API endpoint (e.g., `http://actual-api-wrapper-dev:3000/accounts`) |
| 202 | + - In authentication, select the OAuth2 credential you just created |
| 203 | + - Execute the node |
| 204 | + |
| 205 | +Benefits of OAuth2: |
| 206 | +- Tokens are refreshed automatically |
| 207 | +- No passwords are stored in n8n |
| 208 | +- Tokens can be revoked from the API |
| 209 | + |
| 210 | +### Troubleshooting |
| 211 | +- **"Resource not accessible" in n8n**: Check that the API and n8n are on the same network (both in docker-compose). |
| 212 | +- **Token expired**: OAuth2 automatically refreshes; session tokens may need manual re-login. |
| 213 | +- **CORS error**: Verify `ALLOWED_ORIGINS` includes n8n's origin (e.g., `http://localhost:5678`). |
| 214 | + |
| 215 | +## CLI Commands |
| 216 | +- Lint: `npm run lint` |
| 217 | +- Audit: `npm run audit` |
| 218 | +- Pre-commit hooks: see [PRECOMMIT_SETUP.md](PRECOMMIT_SETUP.md) |
| 219 | + |
| 220 | +## Docker (Production) |
| 221 | +Build and run the image: |
| 222 | + |
| 223 | +```bash |
| 224 | +docker build -t actual-api-wrapper:latest . |
| 225 | +docker run --rm -p 3000:3000 \ |
| 226 | + -v $(pwd)/data/actual-api:/app/.actual-cache \ |
| 227 | + --env-file ./.env \ |
| 228 | + actual-api-wrapper:latest |
| 229 | +``` |
| 230 | + |
| 231 | +Or use the compose file: |
| 232 | + |
| 233 | +```bash |
| 234 | +docker compose up -d --build |
| 235 | +``` |
| 236 | + |
| 237 | +## Data & Persistence |
| 238 | +- SQLite auth DB: `${DATA_DIR}/auth.db` (persist the `DATA_DIR` volume) |
| 239 | +- Actual SDK cache and budget data are managed by `@actual-app/api` using `DATA_DIR` |
| 240 | + |
| 241 | +## Observability |
| 242 | +- Structured logs via winston (JSON in production), respect `LOG_LEVEL` and `NODE_ENV` |
| 243 | +- Each request includes an `X-Request-ID` for traceability |
| 244 | + |
| 245 | +## CI / Security |
| 246 | +GitHub Actions run dependency and image security checks: |
| 247 | +- npm audit, ESLint, Docker build test |
| 248 | +- Snyk (requires `SNYK_TOKEN` secret) |
| 249 | +- Container scan via Trivy (SARIF uploaded to code scanning) |
| 250 | +- Secret scanning via Gitleaks |
| 251 | +- OWASP Dependency-Check (SARIF upload) |
| 252 | + |
| 253 | +Workflow tips: |
| 254 | +- SARIF uploads require `permissions: { security-events: write, actions: read }` |
| 255 | +- Forked PRs skip uploads to avoid permission errors |
| 256 | + |
| 257 | +## Project Structure |
| 258 | +- App: [src](src) |
| 259 | +- Routes: [src/routes](src/routes) |
| 260 | +- Auth: [src/auth](src/auth) |
| 261 | +- Config: [src/config](src/config) |
| 262 | +- Docs: [src/docs](src/docs) |
| 263 | +- Logging: [src/logging](src/logging) |
0 commit comments