## 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.
## Problem
The standalone `pr-title.yml` workflow produced a `Conventional PR
title` status check, but that context was **not** in `main`'s
branch-protection required list (`tests`, `linter`, `static-analysis`,
`browser-tests`, `performance-tests`). Auto-merge only waits on required
checks, so a **failing** title check never blocked a merge — which is
exactly what happened.
## Fix
Fold the same validation into the `linter` job, which is already a
required check. A bad PR title now fails `linter` → blocks the merge
(and auto-merge waits). The standalone workflow is removed as redundant.
Since required checks live in branch protection (not in the repo — no
config-as-code here), hanging the validation off an already-required job
is the only way to enforce it via a PR.
## Trade-off
The title re-validates on `push`/`synchronize` rather than on the
`edited` event, so correcting a bad title after opening a PR needs a new
push (a re-run won't pick up the edited title). Acceptable for the
simpler, single-mechanism setup.
Adds a `schedule` trigger to the existing Release workflow so it runs
automatically.
- **Cron**: `0 8 * * 1` — Mondays 08:00 UTC (= 10:00 Madrid in
summer/CEST, 09:00 in winter/CET).
- **Increment**: defaults to `patch` on scheduled runs
(`workflow_dispatch` still lets you pick patch/minor/major).
- **Empty-week guard**: skips the run when there are no commits since
the last tag, avoiding empty releases.
Note: the release PR is still opened by `github-actions[bot]` with
`GITHUB_TOKEN`, which does not trigger required checks — merging it
stays a manual step unless a PAT/App token is wired in.
## Summary
Wave 1 hardening: privacy/security quick wins on the Inertia boundary, a
CI safety net, and stricter test isolation. Four focused changes plus
two review fixes, each in its own commit. No dependency or
product-behavior changes.
## Changes (by commit)
1. **Hide sensitive User fields from serialization** — `User::$hidden`
only covered password / 2FA / remember_token, leaving Cashier billing
columns (`stripe_id`, `pm_type`, `pm_last_four`, `trial_ends_at`) and
the legacy `encryption_salt` exposed in every serialized User, including
the Inertia-shared `auth.user` prop. None are read by the frontend and
Cashier keeps reading them server-side, so hiding them is invisible to
the UI and billing.
2. **Advisory frontend type-check in CI + duplicate `currency_code`
fix** — the CI linter job never ran `tsc`, so type errors accumulated
unseen. Adds a `Type Check Frontend` step running `bun run types`. It is
`continue-on-error: true` on purpose: the codebase already carries ~157
pre-existing `tsc --noEmit` errors, so gating on it now would turn CI
red on unrelated code. It surfaces type output today and should be
flipped to blocking once the backlog is cleared. Also removes a
duplicate `currency_code` member on the `User` TS interface (declared
twice, `CurrencyCode` and `string | null`); the TS language server flags
it, `tsc` masks it under `skipLibCheck`.
3. **Move residual-encryption cleanup out of Inertia `share()` into a
queued job** — `share()` is a shared-data provider and must be
read-only, but it ran a `DELETE`+`UPDATE` against the user on every
non-API web GET to purge the leftover encryption salt /
`EncryptedMessage` once a user had no encrypted data left. The existing
`encryption:*` commands do not cover this case (both target users who
*still* have encrypted data; this finalizes users who *finished*
decrypting). The work now goes to a new idempotent
`PurgeResidualEncryptionArtifactsJob` dispatched from `share()`,
preserving the eventual-cleanup semantics without writing during the
render.
4. **Block stray HTTP in the Feature test suite** — adds
`Http::preventStrayRequests()` in a Feature-scoped `beforeEach` so any
unfaked outbound request fails loudly instead of hitting the network. A
representative HTTP-touching subset (open banking,
exchange-rate/currency, AI categorization + AI/stats reports, analytics,
Discord, Stripe, bank logos) was run with the guard active; no test
relied on a real request, so no fakes had to be added.
### Review fixes (after the two-reviewer pass)
5. **Purge check uses `name_iv` source of truth, not the stale
`encrypted` flag** — the destructive purge decided "no encrypted
accounts" from `accounts.encrypted`, a flag the codebase already treats
as unreliable (see the
`align_accounts_encrypted_flag_with_plaintext_names` migration and
`FindsUsersWithLegacyEncryption`, which key off `name_iv`). An account
with an encrypted name but a stale `encrypted=false` flag could have its
key material destroyed. The job's guard now matches the canonical `*_iv`
predicate exactly; the loose flag gate in `share()` is kept only as a
cheap dispatch filter, and the job re-verifies with the safe predicate
before touching anything.
6. **Deduplicate purge dispatches with `ShouldBeUnique`** — `share()`
runs on every web GET, so an affected user re-enqueued the job on each
page load until a worker cleared the salt. The job is now
`ShouldBeUnique` keyed by user id, collapsing repeat dispatches into one
pending job.
## Test plan
- New/updated tests, all green:
- `InertiaSharedDataTest`: `auth.user` omits all sensitive fields; a web
GET no longer mutates the user inline and instead queues the cleanup
(and does not queue it when there is no salt).
- `PurgeResidualEncryptionArtifactsJobTest`: clears salt + message when
no `*_iv` data remains; keeps them when an encrypted transaction, an
encrypted account name, or a stale-flag-but-encrypted-name account
exists; no-op when salt already null.
- `StrayHttpRequestGuardTest`: an unfaked request throws
`StrayRequestException`; a matched fake still resolves.
- Green locally: `vendor/bin/pint --test`, `bun run lint` (0 errors),
`bun run format:check`, `bun run test` (254 frontend tests), targeted
backend
`--filter=InertiaSharedData|PurgeResidualEncryptionArtifactsJob|StrayHttpRequestGuard|Encryption`
(24 tests). Ran ~700 HTTP-touching Feature tests with the stray-request
guard active with no guard-induced failures.
- `bun run types` still reports the ~157 pre-existing errors (unchanged
set; this PR adds none) — that is exactly why the CI step is advisory
for now.
## Reviewer findings — addressed vs deferred
**Addressed**
- 🟠 Destructive purge keyed off the stale `accounts.encrypted` flag
instead of the `name_iv` source of truth → fixed in commit 5 (job now
mirrors `FindsUsersWithLegacyEncryption`; added a regression test for
the stale-flag case).
- 🟡 Per-request dispatch amplification with no dedup → fixed in commit 6
via `ShouldBeUnique`.
**Deferred (with rationale)**
- 🟠 "Three divergent copies of the legacy-encryption query" —
substantively resolved: the job now matches
`FindsUsersWithLegacyEncryption` exactly. The only remaining
`encrypted`-flag use is the `hasEncryptedAccounts` **UI prop** in
`share()`, which is a separate, pre-existing frontend concern; changing
it would alter which accounts the UI treats as encrypted and is out of
scope here. A full extraction into one shared scope would require
restructuring the trait (it builds a `User` query, not a per-model
boolean) and is not a Wave 1 quick win.
- 🟢 Redundant `->fresh()` / null guard in the job — kept deliberately:
it is the idempotency guard that makes the re-check read committed state
on the sync path (the second reviewer credited it as what makes repeat
dispatches safe).
- 🟢 `continue-on-error` shows the type-check step green — acknowledged;
flipping to blocking (or failing on an increase over a committed
baseline) is the follow-up once the ~157-error backlog is cleared.
- 🟢 (Product review) Theoretical one-request SSR/client
`hasEncryptionSetup` diff from async salt clearing — invisible in
practice (the lock button gates on `hasEncryptedAccounts ||
hasEncryptedTransactions`, false in both SSR and client), and `ssr.tsx`
is untouched. No action.
Product-bug reviewer verdict: no user-facing regressions. Hiding the 5
fields does not affect Cashier (raw attribute access),
`EncryptionController`, notifications, or any API/JSON path; the
`currency_code` dedup is runtime-identical; the cleanup timing change is
client-absorbed.
Removes the `deploy` job from CI. Deployment is no longer triggered from
GitHub Actions.
The job handled the Coolify webhook trigger and Sentry release tracking
on pushes to `main`. Build/test/lint/static-analysis and the image build
jobs are untouched.
## What
Replace the ~60-line bash retry loop in the deploy step with native curl
flags.
## Why
The old block reimplemented in shell what curl does natively (timeouts,
retries, error handling), plus manual body/timing parsing with a marker
separator.
- `--max-time 15` caps each attempt so the job can't hang for ~20 min on
an unreachable box.
- `--retry-all-errors` turns a timed-out attempt into the next retry (a
plain `--retry` doesn't always treat exit 28 as retryable).
- `--fail` keeps a webhook error (4xx/5xx) from passing the step
silently.
## Notes
- Now uses the `COOLIFY_WEBHOOK_URL` secret instead of
`DEPLOYMENT_TOKEN` + hardcoded uuid URL. **This secret must exist in
GitHub Actions before merge or the deploy breaks.**
- Drops the 10–20 min retry waits that gave a prior rebuild time to
finish. Fine if the webhook queues the deploy; add back if it rejects
requests during an in-progress rebuild.
## Summary
Removes the custom `automerge.yml` workflow and its `automerge` label
flow. GitHub now provides native auto-merge that merges a PR
automatically once required CI checks pass, making the custom workflow
redundant.
## Changes
- Delete `.github/workflows/automerge.yml`
## Follow-up (manual, outside this PR)
- Enable **Settings → General → Allow auto-merge**.
- Add a branch protection rule requiring the CI check so native
auto-merge waits for it.
- Remove the now-unused `MERGE_TOKEN` secret if nothing else uses it.
- Use **Enable auto-merge** per PR (or `gh pr merge --auto --squash`).
Reduce deploy webhook retries to 3 attempts with longer waits so a slow
Coolify rebuild can finish before giving up.
- Attempt 1 → fail → wait 10 min
- Attempt 2 → fail → wait 20 min
- Attempt 3 → fail → give up
Total window ~30 min (was 10 attempts / ~7 min). Connection-level
timeouts (curl exit 28) were exhausting retries before the box came
back.
## Problem
The deploy step intermittently fails on CI with only:
```
Response:
HTTP Status: 000
Deployment request failed with status 000
```
`000` means curl never completed a connection — the Coolify box (which
rebuilds the image from git on deploy) has its API unreachable while a
prior build runs. But the step gave no way to confirm that: `-s`
silenced curl's error message and `|| true` swallowed the exit code.
## Changes
- **Surface the real cause** — `-sS` plus `--write-out
%{exitcode}`/`%{errormsg}`/timing, so failures print the curl exit code
(7 refused, 28 timeout, …) and message instead of a blind `000`.
- **Robust parsing** — body/metadata split on an explicit
`===CURL_META===` marker instead of fragile `tail`/`sed` line counting.
- **No false timeouts** — `--max-time` raised 120→300s so a
slow-but-alive box doesn't abort mid-trigger and report a phantom `000`.
- **Wider retry window** — 10 attempts with backoff capped at 60s (~8
min) instead of 5 with a 240s dead sleep, to ride out a multi-minute
rebuild.
- **Fail fast on 4xx** — a bad token/uuid is not retryable, so don't
waste the window on it.
## Note
This makes the trigger robust and observable; the structural fix (let
Coolify pull the prebuilt ghcr image instead of rebuilding on the box)
is out of scope here.
## Summary
- add --ignore-missing to Sentry set-commits so build-image does not
fail when previous release SHA is unavailable
- cover the workflow command in Sentry config test
## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/SentryConfigTest.php
Continues the work merged in #341 with two focused commits.
## Baseline vs result (real numbers from runs on this PR)
| Job | Before (last run on #341) | After | Notes |
|---|---|---|---|
| build-assets | 70s (gates 3 jobs) | 74s | unchanged |
| tests | 215s | 243s | now starts at t=0 (no gate) |
| performance-tests | 72s | 64s | now starts at t=0 (no gate) |
| browser-tests longest shard | 341s | 323s | -18s |
| linter | 61s | 63s | same |
| static-analysis | 29s | 23s | -6s |
| **Wall (critical path)** | **~7m** | **~6.6m** | **-25s on the
critical path** |
The critical path is still `build-assets → browser-tests-matrix`. Real
wall-time wins for that path require either smarter shard balancing or a
Playwright Docker container — both deliberately deferred (see below).
## Tier 1 — wall-time wins (commit 1)
- **Pest:** `withoutVite()` is now applied globally in the `Feature` and
`Performance` suites via `pest()->beforeEach(...)->in(...)`. Those
suites never assert on the JS bundle, so they no longer need a compiled
Vite manifest.
- **Drop `needs: build-assets`** from `tests` and `performance-tests`.
Both now start at t=0 instead of waiting ~70s for the artifact. Browser
tests still gate on it (real Playwright session needs the manifest).
- **Playwright:** install only `chromium` (no firefox/webkit) since
that's all pest-plugin-browser uses. Saves ~15s of system-dep install
per shard.
- **Drop `actions/setup-node`** from browser-tests-matrix;
`oven-sh/setup-bun` already provides node for `npx playwright`.
I also tried bumping browser shards 4 → 6 but reverted (commit 3):
pest-plugin-browser's `--shard=N/M` distributes very unevenly at M=6 in
this codebase — shard 6 ended up running the entire 106-test suite
(13m26s) while other shards ran 23 tests each. Reverted to 4 shards for
predictable balance. Better balancing needs a test-time-aware split
(follow-up).
## Tier 2 — caching + setup dedup (commit 2)
- New composite actions:
- `.github/actions/setup-php-deps`: PHP + composer cache + `vendor/`
cache. On a `vendor/` cache hit, `composer install` is skipped entirely.
- `.github/actions/setup-bun-deps`: Bun + bun cache + `node_modules/`
cache. On a hit, `bun install` is skipped entirely.
- All 6 jobs refactored to use the composite actions. Removes ~150 lines
of duplicated YAML.
- **Bug fix: Pint cache key** dropped `${{ github.sha }}` from the
primary key so it actually reuses across runs (was effectively 0%
reuse).
## Deliberately not included
- **Pest `--parallel`**: paratest's per-process DB suffix conflicts with
Testcontainers user grants (see revert in 82e9a77 on the parent branch).
Worth revisiting in a follow-up that either widens grants or overrides
`ParallelTesting::setUpProcess`.
- **Playwright Docker container** (`mcr.microsoft.com/playwright`):
would eliminate the 34s system-deps install entirely but couples PHP
setup to a custom container; risky for one PR.
- **Test-time-aware browser shard split**: only real way to actually
shrink the critical path further given pest-plugin-browser's current
sharding behavior.
## Verification
- Final CI run on this PR is green (one flaky `Create Category` browser
timeout that passed on rerun — pre-existing flake unrelated to these
changes).
- `php artisan test --filter=InertiaSharedDataTest` and
`--filter=BudgetPeriodDateTest` pass with `public/build` deleted,
confirming the global `withoutVite()` covers feature tests that
previously rendered Inertia pages.
- All YAML files parse cleanly.
- `vendor/bin/pint --dirty` passes.
## Why
Last CI run on a PR took **~16m 36s** wall (browser-tests dominated).
Total CPU ~34 min. Several easy wins identified.
## Baseline (run 25169327223)
| Job | Wall | Critical step |
|---|---|---|
| browser-tests | 16m 36s | Browser Tests 718s, Build 166s, Playwright
50s |
| tests | 9m 38s | Tests 334s, Build 173s |
| performance-tests | 4m 19s | Build 173s |
| linter | 2m 54s | Pint 109s |
| static-analysis | 36s | — |
## Changes (one commit each)
**A. Shard browser tests 4×** — `pest --shard=N/4` matrix. ~12 min → ~3
min critical path.
**B. Build assets once, share via artifact** — new `build-assets` job
uploads `public/build`; tests/browser-tests/performance-tests download
it. Saves ~340s of duplicated CPU.
**C. Disable xdebug in tests job** — `coverage: xdebug` was set but
`--coverage` never passed. Silent ~30-50% slowdown.
**D. Parallel Pest with Testcontainers** — `pest --parallel
--processes=4`, `TESTCONTAINERS=true` so each worker gets isolated
MySQL. Drops job-level mysql service.
**E. Cache composer + bun deps** — `actions/cache` for composer cache
dir and `~/.bun/install/cache` keyed on lockfiles. Saves 30-60s/job
warm.
**F. Cache Playwright browsers** — cache `~/.cache/ms-playwright` keyed
on bun.lock; on hit only runs `install-deps`. Saves ~50s.
**G. Pint parallel + cache** — `--parallel --cache-file=.pint.cache`
with persisted cache. ~109s → ~5-10s warm.
## Expected wall time after
Browser shards become critical path: **~6-7 min** (down from 16-17 min).
## Risks
- **D**: 4 paratest workers each spawn a MySQL container — watch
RAM/runner load. Drop to `--processes=2` if flaky.
- **A**: more concurrent runners. Adjust shard count if MySQL service
init becomes the new bottleneck per shard.
- **B**: `build-assets` is now a hard dep for
tests/browser-tests/performance-tests.
## Test plan
CI itself is the test. Watch this PR run, compare timings to baseline.
## Why
Pushes to `main` that only modify workflows, README, or docs were
triggering production deploys.
## What
- Adds a `changes` job using `dorny/paths-filter@v3` to detect whether
the push touched application code.
- Gates `build-image` and `deploy` on `needs.changes.outputs.code ==
'true'`.
## Code paths considered
`app/`, `bootstrap/`, `config/`, `database/`, `public/`, `resources/`,
`routes/`, `tests/`, `storage/`, `artisan`, `composer.{json,lock}`,
`package*.json`, `bun.lock*`, `vite.config.*`, `tsconfig*.json`,
`eslint.config.*`, `.prettierrc*`, `phpstan.neon*`, `phpunit.xml*`,
`pint.json`, `Dockerfile`, `docker/**`, `.dockerignore`, `.env.example`.
Anything else (e.g. `.github/**`, `*.md`, `docs/**`) is treated as
non-deployable.
Adds a manually-triggered release workflow using `release-it`.
## How it works
1. Go to **Actions → Release → Run workflow**
2. Pick `patch` / `minor` / `major`
3. Workflow:
- checks out `main`, creates `release/run-<id>` branch
- runs `release-it --ci <increment>`: bumps `package.json`, updates
`CHANGELOG.md`, commits, tags `vX.Y.Z`, pushes branch + tag, creates the
GitHub release
- opens a PR back to `main` with the version bump + changelog
No direct push to `main` required. Tag and release are published
immediately; the PR just lands the version bump.
## Changes
- `.github/workflows/release.yml` — new workflow
- `.release-it.json` — allow non-main branches, ensure branch push
## Caveats
- Squash-merging the release PR leaves the tag on a dangling commit (tag
still valid). Use merge or rebase to keep the tag reachable from `main`.
Manual `Release` workflow failed before `release-it` ran because it
installed dependencies with `npm ci` while repository dependency state
is maintained with Bun. This change aligns the release job with the
repo’s actual package manager so manual releases can install
dependencies from the committed lockfile.
- **Problem**
- `release.yml` used `npm ci`, which requires `package.json` and
`package-lock.json` to be perfectly in sync.
- Repo changes had moved dependency resolution forward via Bun, causing
the manual release job to fail during install instead of reaching
release creation.
- **Workflow change**
- Keep Node setup for `release-it`.
- Add Bun setup in the release job.
- Replace `npm ci` with `bun install --frozen-lockfile`.
- **Result**
- Manual release job now follows same package manager path as rest of
repo/workflows.
- Dependency installation uses committed Bun lock state, avoiding
`package-lock.json` drift as release blocker.
```yaml
- uses: actions/setup-node@v4
with:
node-version: '20'
- uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --frozen-lockfile
```
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: victor-falcon <238766+victor-falcon@users.noreply.github.com>
Adds a manually-triggered workflow to cut releases from the GitHub
Actions UI.
## What
New workflow: \`.github/workflows/release.yml\`
- Trigger: \`workflow_dispatch\` with a \`bump\` input (\`patch\` /
\`minor\` / \`major\`, default \`patch\`)
- Runs \`npx release-it <bump> --ci\` → reuses existing
\`.release-it.json\`:
- angular conventional-changelog preset
- updates \`CHANGELOG.md\`
- commits \`chore: release vX.Y.Z\`
- tags \`vX.Y.Z\`
- creates GitHub release with grouped Features / Bug Fixes notes
## Auth
Uses the default \`GITHUB_TOKEN\` (no PAT). Requires:
- \`permissions: contents: write\` (push commit/tag, create release)
- Repo setting: Settings → Actions → General → Workflow permissions →
**Read and write permissions**
If \`main\` has branch protection blocking direct pushes, the bot token
must be allowed to bypass, otherwise the push step will fail and a PAT
will be needed instead.
## How to run
Actions tab → Release → Run workflow → pick bump type.
## Summary
- make the `deploy` job wait for `build-image` so Sentry release
creation finishes before deploy tracking runs
- keep the Sentry deploy marker on the same release name while removing
the race that caused `Release not found`
- add a focused regression test that asserts the workflow keeps this
dependency in place
## Testing
- php artisan test --compact tests/Feature/SentryConfigTest.php
## Summary
- create and finalize Sentry releases in CI using
`whisper-money@<git-sha>`
- bake the release into the production image so Laravel tags backend
events with the deployed version
- mark production deploys in Sentry and add a focused test covering the
release binding
## Testing
- php artisan test --compact tests/Feature/SentryConfigTest.php
## Summary
- move bank transaction sync emails from per-connection inline sends to
a unique per-user daily job
- send at most one bank sync email per user per day while still
including all unreported enable-banking transactions since last reported
mail
- keep first-ever connection sync silent and add coverage for same-day
suppression and next-day catch-up emails
## Testing
- php artisan test --compact
tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
## Summary
- Adds `|| true` after the `curl` command so `bash -e` does not kill the
script on curl exit code 28 (timeout) before the retry loop can continue
- Guards the HTTP status integer comparison against an empty value,
which occurs when curl times out and produces no output
## Summary
- Replaces curl's built-in `--retry` with a shell loop that retries up
to 5 times on any non-2xx response (not just connection failures)
- Delays increase with each attempt: 10s → 20s → 30s → 40s between
retries
- Exits immediately on success so successful deploys are unaffected
## 🚪 Why?
### Problem
PHPStan was running with a baseline of 56 suppressed errors, meaning
static analysis was not enforcing type safety across a significant
portion of the codebase. These errors were real type mismatches,
redundant null-safety operators, and incorrect PHPDoc annotations that
could mask bugs and make the code harder to reason about.
## 🔑 What?
### Changes
- Add `@property` PHPDoc annotations to `Account`, `BankingConnection`,
`ExchangeRate`, and `Transaction` models so Enum casts and typed columns
are visible to PHPStan
- Add `instanceof User` guards in `ScheduleDripEmailsListener`,
`SyncUserToResendListener`, and `FortifyServiceProvider` to properly
narrow `Authenticatable` to `App\Models\User`
- Remove redundant `?? false` and unnecessary nullsafe `?->value` in
`HandleInertiaRequests`
- Fix `SyncBankingConnectionJob`: use `->name` instead of `?->name` on
an always-loaded `bank` relation
- Remove `is_countable()` guard in `BalanceLookup` (parameter is always
`Collection|array`, both countable)
- Remove `?? []` / `?? default` fallbacks on fully-typed array keys
across `BalanceSyncService`, `BinanceBalanceSyncService`,
`BinanceClient`, `BitpandaBalanceSyncService`, `BitpandaClient`,
`IndexaCapitalClient`, `IndexaCapitalBalanceSyncService`, and
`AuthorizationController`
- Fix `BinanceClient::publicClient()` `retry()` call: use `when:` named
argument and `\Throwable` type hint to match `PendingRequest::retry()`
signature
- Update `IndexaCapitalClient::getPerformance()` `@return` to include
`portfolios` and `net_amounts` keys; simplify sync service to remove
dead null checks
- Replace nullsafe chain with ternary in `BudgetPeriodService`
- Replace `match` statement in `SetupMainUser` with `if/else` to
eliminate always-true comparison
- Clear `phpstan-baseline.neon` entirely (was 56 suppressed errors, now
0)
## ✅ Verification
### Tests
- Existing tests pass: PHPStan level 5 reports 0 errors with empty
baseline
## Summary
- Add `testcontainers/testcontainers` to `require-dev` to spin up an
ephemeral MySQL 8.0 container per test process, eliminating the need for
a local database or Docker Compose stack
- Create `tests/bootstrap.php` that starts a `MySQLContainer`, sets
`DB_*` env vars dynamically, and auto-generates `APP_KEY` when missing
(supports fresh worktrees and CI)
- Update `phpunit.xml` to use the new bootstrap file and remove the
hardcoded `DB_DATABASE` env var
## How it works
Running `php artisan test` now automatically starts a short-lived MySQL
container on a random port. Each test process gets its own isolated
database, so multiple agents, worktrees, or CI jobs can run in parallel
without conflicts.
Set `TESTCONTAINERS=false` to bypass containers and use the database
configured in `.env` instead.
## Why
### Problem
As new features are added, dashboard and settings pages have been
accumulating extra database queries — risking N+1 regressions and
degraded response times. There was no automated way to detect these
regressions before they reach production.
## What
### Changes
- **New `Performance` test suite** (`tests/Performance/`) with 25 tests
that enforce query count ceilings on every page and API endpoint
accessible from the sidebar
- `PageQueryCountTest.php` — 15 tests covering Dashboard, Accounts,
Transactions, Budgets, Cashflow, and all Settings pages, plus scaling
tests that prove query count stays constant as data volume grows
- `ApiQueryCountTest.php` — 10 tests covering all Dashboard and Cashflow
analytics API endpoints, plus scaling tests
- **Separate CI job** (`performance-tests`) that runs in parallel with
`tests` and `linter`, and gates both `build-image` and `deploy`
- **Excluded from the main `tests` job** to avoid running them twice
(`--exclude-testsuite=Performance`)
- Helper functions (`performanceSeedUser`, `countQueries`,
`assertMaxQueries`) added to `tests/Pest.php` for reuse
- `phpunit.xml` updated with the new `Performance` testsuite entry
### How it works
Each test seeds a user with realistic data (3 accounts, 30 transactions,
15 balances, 5 categories, 3 labels, 1 budget) and asserts the endpoint
executes **at most N queries**. Thresholds have a small buffer (~3
queries) above current counts — tight enough to catch N+1 regressions
(which add dozens of queries) but loose enough to not break on minor
legitimate changes. On failure, every executed SQL query is dumped for
easy debugging.
### Run locally
```bash
php artisan test --testsuite=Performance
```
## Verification
### Tests
All 25 performance tests pass. Existing Feature (633) and Unit (22)
suites unaffected.
## Summary
- Add `GET /api/transactions?encrypted=true` endpoint for paginated
listing of encrypted transactions
- Add `PATCH /api/transactions/bulk` endpoint for batch-updating
decrypted transaction data (max 50 per request, bypasses model events)
- Add `useDecryptTransactions` hook that mirrors the existing account
name decryption flow: fetches encrypted transactions page-by-page,
decrypts `description`/`notes` client-side via Web Crypto API, sends
plaintext back and clears IVs
- Wire the hook into `AppSidebarLayout` so decryption runs automatically
when the user unlocks their encryption key
- Once all transactions (and accounts) are decrypted, the encryption key
button in the header disappears automatically
## Test plan
- [x] 11 Pest tests covering encrypted/plaintext filtering, pagination,
user scoping, bulk update, authorization, validation, nullable notes,
guest access, and no-model-events behavior
- [x] Manual: create encrypted transactions, unlock encryption key,
verify transactions get decrypted and the key button disappears
## Summary
- Fixes a race condition where the automerge workflow merged PR #94
despite `browser-tests` failing
- Adds SHA verification to ensure the CI run matches the PR's latest
commit (prevents stale merges from earlier pushes)
- Adds a failed-checks guard that inspects all PR check statuses before
merging
## What happened
PR #94 had two CI runs: an earlier one that passed and a later one where
`browser-tests` failed. The automerge workflow triggered on the first
successful run and merged the PR before the second run completed,
because it only checked `workflow_run.conclusion == 'success'` without
verifying it was for the latest commit.
## Test plan
- [x] Push a PR with the Automerge label, verify it merges when all
checks pass
- [x] Push a follow-up commit that breaks browser-tests — verify the
earlier success doesn't trigger a merge
- [x] Verify PRs without the Automerge label are unaffected
## Summary
- Fixes the automerge workflow that was merging PRs immediately without
waiting for CI
- Replaces `gh pr merge --auto` (requires branch protection) with a
`workflow_run` trigger that fires after CI completes
- When CI passes on a PR with the "Automerge" label, the PR is
squash-merged automatically
## Test plan
- [x] Merge this PR manually to get the workflow on `main`
- [x] Create a test PR, add the "Automerge" label, and verify it only
merges after CI passes
## Summary
- Adds a new GitHub Actions workflow that enables auto-merge when the
"Automerge" label is added to a PR
- Uses `gh pr merge --auto --squash` to squash-merge once all required
CI checks pass
## Test plan
- [ ] Add the "Automerge" label to this PR to verify the workflow
triggers
- [ ] Confirm the PR is auto-merged after CI passes
## Summary
- Adds a structured bug report issue template using GitHub's YAML form
format
- Includes fields for environment, steps to reproduce, expected/actual
behavior, and additional context
## Test plan
- [x] Verify the template appears when creating a new issue on GitHub
- [x] Confirm all form fields render correctly
- 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
## Summary
- Add four view modes for net worth and account balance charts:
- **Stacked Accounts**: existing stacked bar chart showing balances by
account
- **Line**: net worth line chart with linear/log scale toggle
- **Change**: bar chart showing MoM%, YoY%, Rolling 12M changes
- **Waterfall**: bridge chart showing Start -> Change -> End transition
- Add Vitest for frontend unit testing with 52 tests covering all
calculation functions
- Proper sign handling for liabilities (credit cards and loans subtract
from net worth)
- Log scale disabled with warning when net worth includes zero or
negative values
- Shared components and logic between dashboard and account detail
charts
## New Files
- `vitest.config.ts` - Vitest configuration
- `resources/js/lib/chart-calculations.ts` - Pure computation functions
- `resources/js/lib/chart-calculations.test.ts` - 52 unit tests
- `resources/js/hooks/use-chart-views.ts` - Shared chart view state hook
- `resources/js/components/charts/` - Reusable chart components
## Test plan
- [x] Dashboard net worth chart shows view toggle and all four views
work
- [ ] Account detail page chart shows view toggle and three views work
(no stacked)
- [x] Line chart scale toggle works (log disabled with warning when
applicable)
- [x] Change chart series toggle (MoM, YoY, 12M EUR, 12M %) works
- [x] Waterfall chart month selector works
- [x] All 52 unit tests pass (`bun run test`)
- [x] Build succeeds (`bun run build`)
- [x] Dark mode styling correct