Commit Graph

22 Commits

Author SHA1 Message Date
Jesús Mejías Leiva df9d62f76c
fix(docker): keep the client port in the URLs the app generates (#739)
## The bug

Self-hosted production image published on a non-default port (`docker
run -p 8080:80`, the default `APP_PORT` in
`docker-compose.production.yml`) serves the page but with **no CSS and
no JS**: every Vite asset is requested from
`http://<host>/build/assets/...` — port 80, where nothing listens —
while the page itself is on `:8080`.

## Root cause

Debian's nginx package now ships `/etc/nginx/fastcgi_params` with a
security workaround:

```nginx
# !!! Security workaround !!!
# Do not use HTTP_HOST as "$http_host".
...
# Note: this changes behaviour compared to previous versions, because "$host"
# does not preserve the client-supplied port [...] Existing deployments that
# rely on "$http_host" containing a port number may therefore break.
fastcgi_param  HTTP_HOST        $host;
```

`docker/nginx/nginx.conf` does `include fastcgi_params;`, so PHP
receives `HTTP_HOST=192.168.20.46` instead of `192.168.20.46:8080`.
`Request::getPort()` then falls back to the scheme default,
`$request->root()` loses the port, and every absolute URL Laravel builds
— Vite assets, the `Link: rel=preload` header, redirects, mail links,
OAuth redirect URIs — points at port 80.

Deployments behind a reverse proxy on 80/443 don't see it: the proxy
sends `X-Forwarded-Port`, which takes precedence.

## The fix

Ship our own `docker/nginx/fastcgi_params` with `HTTP_HOST $http_host`,
so the value no longer depends on what the base image happens to
install.

## Verification

Reproduced in a minimal container (`php:8.4-fpm` + apt nginx + this
repo's `nginx.conf`, repo bind-mounted at `/app`), printing
`$_SERVER['HTTP_HOST']` and `app('url')->asset('build/assets/app.css')`:

| fastcgi_params | `HTTP_HOST` | `asset()` |
| --- | --- | --- |
| from the base image | `127.0.0.1` |
`http://127.0.0.1/build/assets/app.css`  |
| this PR | `127.0.0.1:8096` |
`http://127.0.0.1:8096/build/assets/app.css`  |

`nginx -t` passes, and a request with a domain `Host` (no port) still
generates `http://whispermoney.example/build/...` unchanged.

Guarded by `tests/Unit/ProductionNginxConfigTest.php`.

## Note

Restoring `$http_host` re-exposes the case Debian's workaround targets
(a raw client `Host` that differs from an absolute-form request target).
The image already forwards the client `Host` — `server_name _` accepts
anything and there is no `TrustHosts` middleware — so this doesn't widen
the surface, but closing it properly would mean an explicit
`server_name` plus a `default_server` that rejects unknown hosts.

---------

Co-authored-by: Víctor Falcón <victor.falcon@factorial.co>
2026-08-09 14:25:19 +00:00
Jesús Mejías Leiva 24fcdef098
feat(docker): run the Laravel scheduler in the production image (#732)
## Problem

The production image runs the queue workers via supervisor but **nothing
runs the Laravel scheduler** (there is no `schedule:work` program and no
cron running `schedule:run`). As a result, none of the commands in
`routes/console.php` ever fire automatically — most notably
`banking:sync` (every 6h), so self-hosted users have to trigger bank
synchronization by hand. The manual "sync" button works because it
dispatches the same job to the queue, which the existing workers pick
up; only the scheduled trigger was missing.

## Changes

- **`docker/supervisor/supervisord.conf`**: add a `[program:scheduler]`
running `php artisan schedule:work`, mirroring the existing worker
programs (autostart/autorestart, dedicated log). This is what makes
every scheduled command fire on its own cadence.
- **Gate the operator-only stats reports** (`stats:daily-report`,
`stats:subscription-funnel`, `stats:ai-cohort-report`,
`stats:experiment-funnel`) behind `config('subscriptions.enabled')` with
an early return. These are subscription-conversion telemetry destined
for the admin Discord; on a self-hosted instance
(`subscriptions.enabled=false`) `stats:daily-report` would otherwise
make an unnecessary live Stripe API call, and the others would build
reports for an unconfigured Discord channel. On the SaaS (subscriptions
enabled) behavior is unchanged.

The guard lives **inside each command** (not in `routes/console.php`) so
it stays unit-testable. `banking:cancel-free-enablebanking` was left
alone (already a safe no-op via `hasProPlan()` when subscriptions are
off), as were the drip-email commands (already gated by
`mail.drip_emails_enabled`).

## Testing

- `vendor/bin/pint --test` passes.
- New Pest test per gated command asserting it skips and makes no
external call when subscriptions are disabled; existing tests updated to
enable subscriptions so they still exercise the real path.
- Full suite green except 5 unrelated pre-existing environment failures
(timezone alias, dashboard 409, Passport OAuth key) that fail
identically on `main`.
- Verified end-to-end with a real supervisor run: the `scheduler`
program reaches `RUNNING`, `schedule:work` boots, and autorestart
resurrects it; `php artisan schedule:test --name=banking:sync`
dispatches successfully.

> Takes effect after rebuilding the production image.
2026-07-24 13:30:33 +02:00
Víctor Falcón 41ac3e90e1
fix(mcp): keep Passport signing keys at 600 in production (#693)
## Problem
Production logs a warning on every `/oauth/authorize` and `/mcp/oauth`
request (Sentry PHP-LARAVEL-4N / PHP-LARAVEL-4M):

> Key file "file:///app/storage/oauth-private.key" permissions are not
correct, recommend changing to 600 or 660 instead of 775

The keys exist (the Docker entrypoint generates them), but the
entrypoint's recursive `chmod -R 775 /app/storage` — which runs *after*
key generation to fix storage ownership — widens
`oauth-private.key`/`oauth-public.key` to 775. league/oauth2-server
(used by Passport) checks the key-file mode when it loads the key and
rejects anything but 600/660, emitting this warning. A 775 private
signing key is also world/group-readable, which it should never be.

## Fix
Re-tighten the two Passport key files to `600` in
`docker/entrypoint.sh`, right after the recursive storage `chmod`. It
runs on every boot, so it also repairs keys already sitting at 775 on a
persisted `storage/` volume.

## Notes
- No app-code change — deploy script only.
- CI (`passport:keys`) and local/worktree key generation already produce
600 keys and never run the recursive 775 chmod, so this is
production-entrypoint-only.
- Follow-up to #691 (OAuth 2.1 for Claude Desktop & ChatGPT).
2026-07-17 21:20:00 +02:00
Víctor Falcón 6d5f440727
feat(mcp): add OAuth 2.1 for Claude Desktop & ChatGPT connectors (Phase 3) (#691)
## MCP Phase 3 — OAuth 2.1 for Claude Desktop/web & ChatGPT connectors

Phase 1 shipped a read-only MCP server (#689); Phase 2 added write tools
+ the read/read_write token scope (#690). This phase adds **OAuth 2.1
(Authorization Code + PKCE)** so Anthropic's Claude Desktop/web custom
connectors and OpenAI's ChatGPT connectors can authenticate — those
clients sign in with OAuth rather than pasting a static bearer token, so
until now they only saw a "coming soon" note.

It reuses `laravel/mcp`'s built-in OAuth support (inert until Passport
is installed) wired to `laravel/passport ^13`. We do not hand-write the
authorization server, discovery endpoints, DCR endpoint, or the
`WWW-Authenticate` challenge — the package provides all of it.

### What's in it
- **`laravel/passport ^13`** + an `api` (passport) guard alongside the
existing session `web` guard; Passport migrations (UUID user columns),
config, and signing keys.
- **`Mcp::oauthRoutes()`** — RFC 8414/9728 discovery, RFC 7591 DCR
(`oauth/register`), and the `mcp:use` scope.
- **A second MCP endpoint `POST /mcp/oauth`** guarded by `auth:api`. The
existing Sanctum `/mcp` endpoint (Claude Code static PAT) is left 100%
unchanged.
- **On-brand OAuth consent screen** (Blade, light + dark, localized)
naming the connecting client and its redirect host, and stating plainly
what the connection can do (read/analyse + make changes, bank-connected
data excepted).
- **Settings UI**: the "Claude Desktop & ChatGPT" block now shows real
connect instructions (the `/mcp/oauth` URL to add as a custom connector,
no token needed) instead of "coming soon".

## Decision #1 — OAuth connections have read + write access

`laravel/mcp` advertises and uses a single `mcp:use` scope; it has no
read/write granularity, so there is no per-connection scope choice over
OAuth. **OAuth connections get full read + write access**, gated by the
user explicitly approving the connection on the Whisper Money consent
screen. (An earlier revision made them read-only; that restriction has
been lifted per request.)

`WriteTool` (`app/Mcp/Tools/WriteTool.php`) grants writes when the
request resolves through the `api` (Passport) guard **or** carries a
Sanctum `mcp:write` ability; a read-only Sanctum PAT is still rejected.
Bank-connected accounts and their transactions remain read-only for
every caller (only manual data can be created/edited/deleted; any
transaction can still be categorised/labelled). The consent screen and
settings copy state the read + write capability and the bank-connected
exception.

Possible follow-up: a consent-time read-only/read-write toggle, if
per-connection granularity is wanted (not offered by the standard MCP
OAuth flow's single scope).

## Other locked decisions
- **Route topology**: a separate `/mcp/oauth` endpoint rather than
multi-guarding `/mcp`. Keeps the Claude Code path unchanged (its
`abilities:mcp:read` gate would 403 an OAuth `mcp:use` token) and gives
each client type a clean documented URL. The package's nested discovery
`/.well-known/oauth-protected-resource/mcp/oauth` returns `resource =
url('/mcp/oauth')`.
- **Registration**: ship DCR (`oauth/register`). Redirect allowlist
tightened to `https://claude.ai` and `https://chatgpt.com` only — no
wildcard. CIMD is a possible later enhancement; both clients accept DCR.

## Deviation from the original plan — the User model is untouched
The plan proposed aliasing Passport's `HasApiTokens` trait alongside
Sanctum's (with `insteadof`/`as`) and implementing `OAuthenticatable`.
**Both are impossible here and, it turns out, unnecessary:**
- The two `HasApiTokens` traits declare an **incompatible `$accessToken`
property** (Sanctum untyped vs Passport `?ScopeAuthorizable`), which is
a hard PHP fatal that `insteadof` cannot resolve (it only resolves
methods).
- `OAuthenticatable::tokens(): HasMany` is incompatible with Sanctum's
canonical `tokens(): MorphMany`, and the Claude Code PAT suite depends
on Sanctum's `tokens()`. The interface is never enforced at runtime by
Passport (docblock-only).
- Passport's resource guard only calls `$user->withAccessToken()`, which
Sanctum already provides (untyped, so it accepts the Passport
`AccessToken`); and Passport's `AccessToken::can()` makes
`tokenCan('mcp:write')` behave correctly for OAuth tokens. So Sanctum
stays canonical and the Claude Code PAT path is genuinely unchanged.

## Signing keys (deploy note)
Passport signs OAuth tokens with a key pair. This PR provisions it
everywhere it's needed: CI (`passport:keys` before tests), the
production Docker entrypoint (generates into the persisted `storage/`
volume unless provided via `PASSPORT_PRIVATE_KEY`/`PASSPORT_PUBLIC_KEY`
env), `worktree.sh`, and a documented `.env.example` entry. **For a
multi-instance deployment, set `PASSPORT_*` env** so every instance
validates tokens with the same key.

## Tests (`tests/Feature/Mcp/McpOAuthTest.php`)
Discovery metadata (RFC 9728/8414), the mandatory **401 bootstrap**
challenge + `WWW-Authenticate` header, DCR (allowed + rejected redirect
URIs), the full **Authorization Code + PKCE** flow reaching a read tool,
and **write access over OAuth** (an OAuth connection calling
`create_label` succeeds and the row is created). The existing
`McpTokenTest` / `Mcp/*` suites (incl. the read-only Sanctum PAT
guardrail) and `LocalizationTest` still pass unchanged.

## QA
- **Protocol** (curl, over HTTPS): both discovery endpoints return the
exact required JSON; unauthenticated `POST /mcp/oauth` returns `401` +
`WWW-Authenticate: Bearer …
resource_metadata="…/.well-known/oauth-protected-resource/mcp/oauth"`;
DCR accepts `claude.ai`/`chatgpt.com` callbacks and rejects others with
`400 invalid_redirect_uri`.
- **Browser**: consent screen verified in light and dark mode (client
name, signed-in email, redirect host, read + write capability +
bank-connected read-only note, Cancel/Connect); updated settings page
verified. No JS errors.
- Full PKCE token exchange + a write tool call is covered by the green
Pest e2e test.

## Fast-follows (not in this PR)
- **"Connected apps" revoke UI** — `McpTokenController` manages only
Sanctum PATs today, so there's no in-app revoke for OAuth grants yet.
The consent copy says "disconnect from the connected app" for now; a
Passport-grant list + revoke is the top follow-up (more important now
that OAuth grants can write).
- CIMD registration; optional consent-time read-only/read-write toggle.

## Stacking
Was developed stacked on `mcp-write-tools` (#690), itself on #689.
**Both have since merged to `main`**, so this branch was rebased onto
`main` (`git rebase --onto origin/main mcp-write-tools`) and targets
`main` directly.
2026-07-17 19:10:48 +02:00
Víctor Falcón f191d74031
fix(queue): add supervisor worker for the ai queue (#546)
## Problema

Los jobs de auto-categorización con IA (\`CategorizeTransactionWithAi\`)
se encolan en la cola dedicada \`ai\` (\`config/ai_categorization.php\`
→ \`'queue' => 'ai'\`), pero **ningún programa de supervisor consumía
esa cola en producción**.

Resultado observado en prod:
- **10.878 jobs pendientes** en la cola \`ai\`, todos
\`CategorizeTransactionWithAi\`, el más antiguo del 2026-06-15 (cuando
se activó el flag).
- **0 transacciones** con \`category_source = 'ai'\` en toda la base de
datos. La IA nunca categorizó nada en producción.
- Ningún \`failed_job\` en la cola \`ai\`: los jobs no fallaban,
simplemente nunca se ejecutaban.

## Fix

Añade un programa \`queue-worker-ai\` en
\`docker/supervisor/supervisord.conf\`, replicando el patrón de los
workers \`emails\` y \`default\`. Se mantiene la cola \`ai\` aislada
(worker propio) a propósito, para que su backlog no retrase los syncs
bancarios.

Tras el deploy el worker arranca solo (\`autostart=true\`) y drena el
backlog acumulado.
2026-06-17 07:08:25 +00:00
Víctor Falcón 741dc49d53
fix(logging): keep laravel.log writable across container UIDs (#451)
Fixes the largest production noise cluster — **8 duplicate issues**, all
`UnexpectedValueException: The stream or file
"/app/storage/logs/laravel.log" could not be opened in append mode:
Failed to open stream: Permission denied`:

`PHP-LARAVEL-G`, `PHP-LARAVEL-Z`, `PHP-LARAVEL-12`, `PHP-LARAVEL-2P`,
`PHP-LARAVEL-2Q`, `PHP-LARAVEL-2R`, `PHP-LARAVEL-2S`, `PHP-LARAVEL-2T`.

## Root cause

`docker/entrypoint.sh` runs `migrate` / `config:cache` / etc. **as
root** before supervisor starts. With the default `umask 022`, the first
log line written during boot creates `laravel.log` as `root:root 0644`.
The persisted `whisper-storage` named volume
(`docker-compose.production.yml`) keeps that stale, non-group-writable
file across deploys — so the `www-data` php-fpm and queue workers can't
append to it and every code path that logs throws. 8 code paths → 8
Sentry issues.

## Fix

- **entrypoint**: `umask 0002`, pre-create `laravel.log` group-writable
before any artisan command, and normalize it to `0664` in the
post-artisan re-apply step.
- **config/logging.php**: `permission => 0664` on the `single` and
`daily` channels, so files Laravel creates itself (including daily
rotation) stay group-writable.

Net effect: whoever writes first (root at boot or www-data at runtime),
the file is owned `www-data` and group-writable `0664` — no more
`EACCES`.

## Tests

- `single` and `daily` channels expose `permission => 0664`.
- `bash -n docker/entrypoint.sh` passes.

## Follow-up

Once deployed and confirmed quiet, the 8 issues should be merged into
one canonical group in Sentry.

Fixes PHP-LARAVEL-G, PHP-LARAVEL-Z, PHP-LARAVEL-12, PHP-LARAVEL-2P,
PHP-LARAVEL-2Q, PHP-LARAVEL-2R, PHP-LARAVEL-2S, PHP-LARAVEL-2T.
2026-05-29 15:10:50 +02:00
Víctor Falcón 0eca002856
fix(docker): ensure www-data owns storage after artisan commands (#329)
## Problem

Sentry reporting permission-denied errors on `/open-banking/callback`:

> UnexpectedValueException: The stream or file
"/app/storage/logs/laravel.log" could not be opened in append mode:
Failed to open stream: Permission denied

4 issues, 14 events (PHP-LARAVEL-Z, 10, 11, 12).

## Root cause

Entrypoint runs as root. After the initial `chown www-data`, artisan
commands (`config:cache`, `route:cache`, `view:cache`, `event:cache`,
`migrate`) execute and can create/touch `storage/logs/laravel.log` as
root. Later, php-fpm (www-data) tries to append and fails.

Queue workers + inertia-ssr also run as root via supervisor — same
failure mode from worker side.

## Fix

- `docker/entrypoint.sh`: re-chown `/app/storage` and
`/app/bootstrap/cache` to www-data **after** artisan cache warm-up, just
before `exec supervisord`.
- `docker/supervisor/supervisord.conf`: add `user=www-data` to
`queue-worker`, `queue-worker-emails`, `inertia-ssr`. Nginx and php-fpm
master keep root (need port 80 / pool forking).

## Verification

Deploy and confirm Sentry issues stop firing on next
`/open-banking/callback` hits.
2026-04-24 18:21:15 +01:00
Víctor Falcón 259a9a9712
chore: replace Caddy with Portless for local HTTPS proxy (#258)
## Summary

- Replace Caddy reverse proxy with [Portless](https://portless.sh) for
local HTTPS, eliminating manual cert generation and `/etc/hosts` editing
- Two URL strategies coexist: `composer run dev` uses worktree-aware
URLs (`https://<branch>.dev.whisper.money.localhost`), `whispermoney
start` uses a fixed alias (`https://whisper.money.localhost`)
- Remove Caddyfile, `docker/caddy/` directory, caddy service from
`compose.yaml`, and cert-related `.gitignore` entries
- Simplify `vite.config.ts` by removing caddy cert detection block (Vite
stays on plain HTTP since browsers treat localhost as secure context)
- Overhaul `public/setup.sh` to use `portless trust`, `portless proxy
start`, and `portless alias` instead of SSL cert generation and hosts
file editing
2026-04-02 16:39:44 +01:00
Víctor Falcón ab21a33e6f chore: Fix caddy container 2026-01-17 20:02:14 +01:00
Víctor Falcón 797cb06f86 fix: auto-regenerate APP_KEY if invalid format (missing base64: prefix) 2025-12-30 07:22:19 +01:00
Víctor Falcón a7ee776af7 fix: use direct PDO connection test for MySQL readiness check 2025-12-30 07:22:19 +01:00
Víctor Falcón 39ef165655 Add production Docker setup for easy self-hosting with the CI-built image (#42)
- Add production Docker setup for easy self-hosting with the CI-built
image
- Build multi-architecture Docker images (amd64 + arm64)

### Changes

  Docker Production Setup
- Add docker-compose.production.yml for running the production image
locally
- Add docker/entrypoint.sh with automatic setup (migrations, caching,
storage, APP_KEY generation)
  - Add HEALTHCHECK to Dockerfile for container health monitoring
  - Add .env.production.example with documented production defaults

### CI/CD
  - Add QEMU for multi-arch builds
  - Build Docker images for both linux/amd64 and linux/arm64

### Coolify / WIP
  - Add templates/coolify/whisper-money.yaml one-click template
  - Includes MySQL service with health checks
  - Auto-generates credentials using Coolify's SERVICE_* variables

### Documentation
  - Update README with production Docker instructions
  - Add environment variable reference table
2025-12-30 07:22:19 +01:00
Víctor Falcón 2662c1f873 Add retry mechanism to email mailables and update supervisor config 2025-12-30 07:22:18 +01:00
Víctor Falcón 6b0ce387d7 Reapply "swap horizon -> queue:work on mysql"
This reverts commit 43d615869c.
2025-12-30 07:22:18 +01:00
Víctor Falcón 03880ca492 Revert "swap horizon -> queue:work on mysql"
This reverts commit 214ad684a8.
2025-12-30 07:22:18 +01:00
Víctor Falcón c79222870d swap horizon -> queue:work on mysql 2025-12-30 07:22:18 +01:00
Víctor Falcón cdeed06ea5 production: remove redis config 2025-12-30 07:22:18 +01:00
Víctor Falcón 46c5b13739 feat: Implement drip email campaign system (#35)
## Summary

- Implement event-driven drip email campaign system for new user signups
- Add 5 targeted emails based on user journey (welcome, onboarding
reminder, promo code, import help, feedback)
- Configure Laravel Horizon with Redis queue for reliable job processing
- Track sent emails in `user_mail_logs` table to prevent duplicates

## Email Schedule

| Email | Timing | Condition |
|-------|--------|-----------|
| Welcome | 30 min after signup | All users |
| Onboarding Reminder | 1 day after signup | Not onboarded |
| Promo Code (FOUNDER) | 1 day after signup | Onboarded + has
transactions + not subscribed |
| Import Help | 1 day after signup | Onboarded + no transactions + not
subscribed |
| Feedback | 5 days after signup | Not subscribed |

## Architecture

```
User Registers → ScheduleDripEmailsListener
    ├─> SendWelcomeEmailJob (30 min delay)
    ├─> SendOnboardingReminderEmailJob (1 day delay)
    ├─> SendPromoCodeEmailJob (1 day delay)
    ├─> SendImportHelpEmailJob (1 day delay)
    └─> SendFeedbackEmailJob (5 days delay)
```

Each job checks conditions at execution time and either sends the email
+ logs it, or silently completes.

## Test plan

- [x] All 23 drip email tests pass
- [x] Migration creates `user_mail_logs` table
- [x] Jobs dispatch with correct delays
- [x] Emails only sent when conditions are met
- [x] Duplicate emails prevented via UserMailLog check
- [ ] Verify Horizon processes jobs in production
2025-12-30 07:22:18 +01:00
Víctor Falcón a87b36de3f fix: increase nginx buffer sizes 2025-12-08 19:36:24 +01:00
Víctor Falcón 9e6688e8b3 Set --runtime=bun parameter on inertia:start-ssr command 2025-12-08 19:04:44 +01:00
Víctor Falcón 4c41d76ebe Add Inertia SSR program to supervisord.conf 2025-11-25 11:55:05 +01:00
Víctor Falcón d2f992493d Add Nginx and Supervisor configurations for Docker environment 2025-11-24 16:09:37 +01:00