Add native NixOS deployment and UI-managed provider credentials
This commit is contained in:
@@ -3,6 +3,8 @@
|
|||||||
/web/dist/
|
/web/dist/
|
||||||
/web/*.tsbuildinfo
|
/web/*.tsbuildinfo
|
||||||
/bin/
|
/bin/
|
||||||
|
/result
|
||||||
|
/result-*
|
||||||
.env*
|
.env*
|
||||||
/secrets/
|
/secrets/
|
||||||
*.key
|
*.key
|
||||||
|
|||||||
+122
-10
@@ -36,11 +36,99 @@ The container is unprivileged with a read-only root and a persistent named
|
|||||||
volume at /data. A host bind mount must be writable by UID/GID 10001.
|
volume at /data. A host bind mount must be writable by UID/GID 10001.
|
||||||
Never change the port mapping to public 0.0.0.0 without VPN/firewall isolation.
|
Never change the port mapping to public 0.0.0.0 without VPN/firewall isolation.
|
||||||
|
|
||||||
|
Native NixOS: the flake exports nixosModules.default. Import it into your host,
|
||||||
|
then set services.finance-duck.enable=true and publicURL to the exact browser
|
||||||
|
origin. Deploy with your normal nixos-rebuild switch. The module runs ONE
|
||||||
|
systemd service; React is embedded in the Go binary, not a separate service.
|
||||||
|
Defaults: listen 127.0.0.1:8080, service user finance-duck, persistent data at
|
||||||
|
/var/lib/finance-duck (0700). It does not configure networking or Tailscale.
|
||||||
|
Optional services.finance-duck.environmentFile names an existing runtime file
|
||||||
|
read by systemd for optional provider startup fallbacks. Both Enable Banking
|
||||||
|
and OpenRouter can be configured directly in Settings without that file.
|
||||||
|
Keep private keys outside the Nix store and readable only by the service.
|
||||||
|
See README's native NixOS section for optional environment-file permissions.
|
||||||
|
|
||||||
|
Deployed host: bender@100.87.224.3
|
||||||
|
--------------------------------
|
||||||
|
URL: https://nixos.taile9e6d9.ts.net:8444 (Tailscale only, no application login).
|
||||||
|
The backend binds 127.0.0.1:8080. Existing Funnel on 443 and OpenClaw on 8443
|
||||||
|
are separate. Never enable Funnel for Finance Duck.
|
||||||
|
|
||||||
|
Update from your workstation:
|
||||||
|
ssh -t bender@100.87.224.3 'sudo finance-duck-update'
|
||||||
|
Or on the host:
|
||||||
|
sudo finance-duck-update
|
||||||
|
sudo finance-duck-update --no-pull # deploy existing checkout edits
|
||||||
|
|
||||||
|
The updater serializes updates, pulls ~/projects/finance-duck as bender with
|
||||||
|
git pull --ff-only, creates a Git-filtered source snapshot, updates ONLY the
|
||||||
|
finance-duck input in /etc/nixos/flake.lock, runs nixos-rebuild switch, then
|
||||||
|
checks /api/health with the correct Host. The app has its own pinned Nixpkgs;
|
||||||
|
the host's Nixpkgs and NixVirt pins stay unchanged. A build failure leaves the
|
||||||
|
running service untouched; an activation/health failure exits nonzero.
|
||||||
|
|
||||||
|
Application and deployment source are tracked in the project repository.
|
||||||
|
Commit and push changes before running the normal updater. New local source
|
||||||
|
files must be tracked with git add before a --no-pull deployment. Pull refuses
|
||||||
|
to overwrite conflicting local edits; resolve them before retrying. No
|
||||||
|
automatic stashing, resetting, committing or pushing occurs. Keep credentials
|
||||||
|
and live finance data out of Git.
|
||||||
|
|
||||||
|
Host files:
|
||||||
|
/etc/nixos/flake.nix application input and module import
|
||||||
|
/etc/nixos/modules/finance-duck.nix service, tailnet proxy, update command
|
||||||
|
/var/lib/finance-duck-source replaceable Git-filtered source snapshot
|
||||||
|
/var/lib/finance-duck persistent data, finance-duck:finance-duck 0700
|
||||||
|
The source snapshot excludes .git, ignored files and untracked files. It is
|
||||||
|
not a data backup. The initial deployment starts empty without provider
|
||||||
|
credentials; it does not copy the workstation's finance/ directory.
|
||||||
|
|
||||||
|
Inspect on the host:
|
||||||
|
systemctl status finance-duck finance-duck-tailscale-serve
|
||||||
|
sudo journalctl -u finance-duck -f
|
||||||
|
tailscale serve status
|
||||||
|
curl -f https://nixos.taile9e6d9.ts.net:8444/api/health
|
||||||
|
|
||||||
|
Before significant upgrades, stop the service and back up the entire data
|
||||||
|
directory to a protected location (including hidden recovery files). Restart
|
||||||
|
after the backup, and protect any separate credential files as well:
|
||||||
|
sudo systemctl stop finance-duck
|
||||||
|
sudo install -d -m 0700 /var/backups/finance-duck
|
||||||
|
sudo sh -c 'umask 077; tar -C /var/lib -czf /var/backups/finance-duck/data-$(date +%Y%m%dT%H%M%S).tar.gz finance-duck'
|
||||||
|
sudo systemctl start finance-duck
|
||||||
|
Copy backups to secure off-host storage; a backup on this disk cannot protect
|
||||||
|
against disk loss.
|
||||||
|
|
||||||
|
Binary/system rollback:
|
||||||
|
sudo nixos-rebuild switch --rollback
|
||||||
|
This switches the previous NixOS generation, NOT financial data or config
|
||||||
|
source. The generation before the first deployment has no Finance Duck service.
|
||||||
|
For a lasting rollback, restore the intended source/config/lock before the
|
||||||
|
next update. Do not remove or roll back live data blindly.
|
||||||
|
|
||||||
OpenRouter
|
OpenRouter
|
||||||
----------
|
----------
|
||||||
Set OPENROUTER_API_KEY in the process environment, then choose a model in
|
Open Settings -> OpenRouter credentials, paste the API key, and Save key.
|
||||||
Settings. The model and chosen endpoint must support strict structured outputs
|
Choose the exact provider/model identifier in Classification preferences and
|
||||||
and the configured privacy routing. Every classification request sets:
|
save preferences. Replace key rotates it; Remove key disables AI. No SSH,
|
||||||
|
Nix rebuild or restart is needed. Changes affect future classification
|
||||||
|
requests; in-flight requests retain their original key.
|
||||||
|
|
||||||
|
The key is stored as private 0600 plaintext in state/openrouter.json under
|
||||||
|
the data directory, never in config.toml or browser storage. API responses
|
||||||
|
never return the saved key. Backups of the data directory contain this secret.
|
||||||
|
Only allow trusted users to reach the application; there is no separate
|
||||||
|
administrator login for changing credentials.
|
||||||
|
|
||||||
|
OPENROUTER_API_KEY remains a startup fallback only when the saved credential
|
||||||
|
file is absent. A saved key overrides it. Removing the key in Settings saves
|
||||||
|
an explicit disable, which also overrides the environment after restart.
|
||||||
|
Malformed saved credentials fail startup rather than reverting to another key.
|
||||||
|
|
||||||
|
Configured means present, not verified with OpenRouter. A successful
|
||||||
|
AI classification -> Analyse request verifies access with the selected model.
|
||||||
|
The model and endpoint must support strict structured outputs and the
|
||||||
|
configured privacy routing. Every classification request sets:
|
||||||
provider.data_collection = deny
|
provider.data_collection = deny
|
||||||
provider.zdr = true
|
provider.zdr = true
|
||||||
provider.require_parameters = true
|
provider.require_parameters = true
|
||||||
@@ -71,17 +159,39 @@ Configure the allowed redirect URL to the exact externally reachable URL:
|
|||||||
https://finance.example.internal/api/banking/callback
|
https://finance.example.internal/api/banking/callback
|
||||||
The browser must be able to reach this callback through your VPN.
|
The browser must be able to reach this callback through your VPN.
|
||||||
|
|
||||||
Environment variables (all three required when enabling banking):
|
Open Settings -> Enable Banking credentials. Enter the application ID, upload
|
||||||
|
its matching private-key PEM file, and Save configuration. Settings supplies
|
||||||
|
the exact browser-origin callback URL to register. Keys must be a single
|
||||||
|
unencrypted RSA PKCS#1 or PKCS#8 PEM, >=2048 bits and <=32 KiB.
|
||||||
|
No SSH, environment file, Nix rebuild or restart is needed.
|
||||||
|
|
||||||
|
Save validates local credentials without contacting Enable Banking. It does
|
||||||
|
not register/activate the app or authorize an account. Configured means local
|
||||||
|
setup is present; complete registration above and authorize under Accounts.
|
||||||
|
|
||||||
|
Leave the key upload empty when editing the same application to keep its key.
|
||||||
|
Same-app key/callback rotation preserves sessions and cursors. Changing the
|
||||||
|
application ID requires a new key upload and reconnecting your banks. Remove
|
||||||
|
configuration confirms before disabling banking and clearing local connection
|
||||||
|
state. Canonical accounts/history remain; upstream bank consent is not revoked.
|
||||||
|
Each successful change invalidates pending authorization flows.
|
||||||
|
|
||||||
|
Credentials persist privately (0600) in state/enablebanking.json beneath the
|
||||||
|
data directory. The API never returns the private key; browser storage never
|
||||||
|
contains it. Session state is bound to the application configuration generation,
|
||||||
|
so stale sessions cannot be revived by a restart after app changes/removal.
|
||||||
|
Back up the entire data directory securely, not isolated credential/state files.
|
||||||
|
|
||||||
|
Optional environment startup fallback, used ONLY when no saved config exists:
|
||||||
ENABLEBANKING_APP_ID=<registered application ID>
|
ENABLEBANKING_APP_ID=<registered application ID>
|
||||||
ENABLEBANKING_KEY_FILE=/run/secrets/enablebanking.key
|
ENABLEBANKING_KEY_FILE=/run/secrets/enablebanking.key
|
||||||
ENABLEBANKING_REDIRECT_URL=https://finance.example.internal/api/banking/callback
|
ENABLEBANKING_REDIRECT_URL=https://finance.example.internal/api/banking/callback
|
||||||
Use a PEM RSA private key (PKCS#1 or PKCS#8, at least 2048 bits). Mount it
|
The referenced key must be readable by the service. Environment changes need
|
||||||
read-only with permissions allowing the service user to read it. Never commit
|
a restart. UI configuration or explicit removal overrides all three variables;
|
||||||
it to Git. The Compose file contains a commented example key mount; set the
|
invalid saved configuration fails startup rather than using another key.
|
||||||
container-side KEY_FILE path when enabling that mount.
|
|
||||||
|
|
||||||
Accounts shows a copyable callback URL. Register it exactly with Enable Banking
|
Accounts shows the configured callback URL. Register it exactly with Enable
|
||||||
and set ENABLEBANKING_REDIRECT_URL to the same value. The redirect carries a
|
Banking. Settings saves the current browser callback URL. The redirect carries a
|
||||||
one-time code and state, not a reusable API key. Go verifies state, exchanges the
|
one-time code and state, not a reusable API key. Go verifies state, exchanges the
|
||||||
code for a session_id, and persists session details locally with mode 0600.
|
code for a session_id, and persists session details locally with mode 0600.
|
||||||
|
|
||||||
@@ -143,6 +253,8 @@ finance/
|
|||||||
merchants.finance
|
merchants.finance
|
||||||
journal/YYYY/YYYY-MM.finance
|
journal/YYYY/YYYY-MM.finance
|
||||||
state/sync-state.json sensitive local consent/session metadata
|
state/sync-state.json sensitive local consent/session metadata
|
||||||
|
state/openrouter.json sensitive UI-managed OpenRouter key or explicit disable
|
||||||
|
state/enablebanking.json sensitive UI-managed banking key/configuration or disable
|
||||||
cache/finance.duckdb disposable analytical projection
|
cache/finance.duckdb disposable analytical projection
|
||||||
|
|
||||||
The custom grammar is deliberately small:
|
The custom grammar is deliberately small:
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ Use the address you open Finance Duck at through your VPN, followed by `/api/ban
|
|||||||
https://finance.example.internal/api/banking/callback
|
https://finance.example.internal/api/banking/callback
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace `finance.example.internal` throughout these examples with your actual hostname. Register the exact URL with Enable Banking and configure the same value on the server, including scheme, hostname, port if applicable, and path.
|
Replace `finance.example.internal` throughout these examples with your actual hostname. **Settings → Enable Banking credentials** shows the exact callback URL to register, including scheme, hostname, port and path. On the deployed server it is `https://nixos.taile9e6d9.ts.net:8444/api/banking/callback`.
|
||||||
|
|
||||||
For local use, the corresponding URL is:
|
For local use, the corresponding URL is:
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ http://localhost:8080/api/banking/callback
|
|||||||
|
|
||||||
**This is a browser redirect, not a webhook.** Your browser must be connected to the VPN and able to reach the callback. Finance Duck does not need to be publicly exposed. The server needs outbound HTTPS access to Enable Banking.
|
**This is a browser redirect, not a webhook.** Your browser must be connected to the VPN and able to reach the callback. Finance Duck does not need to be publicly exposed. The server needs outbound HTTPS access to Enable Banking.
|
||||||
|
|
||||||
The **Accounts** screen displays a copyable callback URL. Before configuration it suggests the current browser origin; afterward it displays `ENABLEBANKING_REDIRECT_URL`.
|
**Settings** shows a copyable callback URL for the current browser origin and saves it with the application credentials. **Accounts** displays the configured callback URL, or suggests the current origin before configuration.
|
||||||
|
|
||||||
### 2. Generate an application key and certificate
|
### 2. Generate an application key and certificate
|
||||||
|
|
||||||
@@ -74,10 +74,10 @@ nix-shell -p openssl --run '
|
|||||||
```
|
```
|
||||||
|
|
||||||
- Upload **`secrets/enablebanking.crt`**, the public certificate, to Enable Banking.
|
- Upload **`secrets/enablebanking.crt`**, the public certificate, to Enable Banking.
|
||||||
- Keep **`secrets/enablebanking.key`** private on your server and back it up securely.
|
- Upload **`secrets/enablebanking.key`** through Finance Duck's Settings, and back up your original key securely. Never upload it to Enable Banking in place of the public certificate.
|
||||||
- Do not rerun the generation command over a key already in use. A different private key will not authenticate against the existing registered certificate.
|
- Do not rerun the generation command over a key already in use. A different private key will not authenticate against the existing registered certificate.
|
||||||
|
|
||||||
The application accepts PEM RSA keys in PKCS#1 or PKCS#8 format, at least 2048 bits. It creates signed application JWTs itself; you do not need to generate daily tokens.
|
The application accepts a single unencrypted PEM RSA key in PKCS#1 or PKCS#8 format, at least 2048 bits and no larger than 32 KiB. It creates signed application JWTs itself; you do not need to generate daily tokens.
|
||||||
|
|
||||||
### 3. Register and activate the Enable Banking application
|
### 3. Register and activate the Enable Banking application
|
||||||
|
|
||||||
@@ -92,9 +92,24 @@ Enable Banking documents restricted production access for individual, non-commer
|
|||||||
|
|
||||||
**Linking accounts in their control panel activates your application; it does not create Finance Duck's bank session.** You must also authorize from Finance Duck in step 5, even for the same account.
|
**Linking accounts in their control panel activates your application; it does not create Finance Duck's bank session.** You must also authorize from Finance Duck in step 5, even for the same account.
|
||||||
|
|
||||||
### 4. Configure Finance Duck and restart it
|
### 4. Configure Finance Duck in Settings
|
||||||
|
|
||||||
For a native process, set all three banking variables in the environment of the process that starts Finance Duck:
|
Open **Settings → Enable Banking credentials**:
|
||||||
|
|
||||||
|
1. Enter the application ID from Enable Banking.
|
||||||
|
2. Select the matching private-key `.pem` or `.key` file.
|
||||||
|
3. Register the displayed callback URL in Enable Banking if you have not already.
|
||||||
|
4. Click **Save configuration**, then proceed to **Accounts** to authorize your bank.
|
||||||
|
|
||||||
|
Saving validates the local key and callback format and applies the provider immediately; it does not contact Enable Banking, register an application, or grant bank consent. **Configured** means locally configured, not externally verified. No SSH, environment file, Nix rebuild, or restart is required.
|
||||||
|
|
||||||
|
For the same application ID, leave the upload empty to keep the saved key, or upload a replacement to rotate it. Existing bank sessions and sync cursors are preserved. Changing the application ID requires a new upload and invalidates old local bank connections, so reconnect afterward. **Remove configuration** disables banking and invalidates local connections after confirmation; accounts and transaction history stay intact. It does not revoke consent at your bank. Successful configuration changes also cancel pending authorization flows.
|
||||||
|
|
||||||
|
The private key and application settings are stored atomically in `state/enablebanking.json` beneath the data directory, with permissions `0600`. The key is never returned by the API or saved in browser storage. Banking sessions are bound to that configuration's application generation so old sessions cannot be reused after switching applications or removal, even across restart. Protect backups of the entire data directory.
|
||||||
|
|
||||||
|
#### Optional environment-managed setup
|
||||||
|
|
||||||
|
For headless configuration, all three variables below remain a startup fallback **only when no saved banking configuration exists**. A UI-saved configuration, including explicit removal, overrides them. Environment changes require a restart; malformed saved credentials fail closed rather than reverting to the environment.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
export ENABLEBANKING_APP_ID="your-application-id"
|
export ENABLEBANKING_APP_ID="your-application-id"
|
||||||
@@ -111,7 +126,7 @@ This example assumes a VPN-accessible reverse proxy forwards to `127.0.0.1:8080`
|
|||||||
|
|
||||||
Stop the old process before restarting; only one process may write a finance directory. Native runs do **not** automatically load `.env` files. Shell exports also do not configure an already-running process or a systemd service—configure that service's environment separately.
|
Stop the old process before restarting; only one process may write a finance directory. Native runs do **not** automatically load `.env` files. Shell exports also do not configure an already-running process or a systemd service—configure that service's environment separately.
|
||||||
|
|
||||||
For Docker, use the container-side key path and the configuration in [Docker Compose](#docker-compose-recommended).
|
For Docker, use the container-side key path and the configuration in [Docker Compose](#docker-compose).
|
||||||
|
|
||||||
### 5. Authorize from the UI
|
### 5. Authorize from the UI
|
||||||
|
|
||||||
@@ -138,20 +153,94 @@ Reconnect → bank login and approval → callback code → new session
|
|||||||
|
|
||||||
Renewal preserves local account identities and existing transaction history. It does not create a second copy of the account or its transactions. Transient provider failures are displayed separately from expired consent.
|
Renewal preserves local account identities and existing transaction history. It does not create a second copy of the account or its transactions. Transient provider failures are displayed separately from expired consent.
|
||||||
|
|
||||||
Reconnecting renews bank consent, not your application registration. Problems with the registered certificate/private key must be corrected in the server configuration and Enable Banking control panel.
|
Reconnecting renews bank consent, not your application registration. Correct certificate registration in the Enable Banking control panel and update the matching private key in **Settings** (or your optional environment-managed setup).
|
||||||
|
|
||||||
## Deployment options
|
## Deployment options
|
||||||
|
|
||||||
| Option | Best fit | Tradeoff / current support |
|
| Option | Best fit | Included support |
|
||||||
| --- | --- | --- |
|
| ------------------------ | ------------------------------------ | -------------------------------------------------------------------------------------------- |
|
||||||
| **Docker Compose** | Straightforward unattended deployment on a private server or VM | Included Dockerfile and Compose configuration; packages the Go runtime dependencies and frontend together. |
|
| **Native NixOS service** | Deployment on your own NixOS machine | Pinned flake package and one systemd service, with persistent state and runtime credentials. |
|
||||||
| **Native binary + systemd** | A small installation without a container daemon | Binary build is supported; configure your own service user, persistent data directory, credentials, and systemd unit. |
|
| **Docker Compose** | A private server using containers | Dockerfile and Compose configuration package the binary and frontend together. |
|
||||||
| **NixOS declarative OCI container** | Managing the application alongside other services in your NixOS configuration | Reuse the Docker image through `virtualisation.oci-containers`; a ready-made NixOS module is not included. |
|
| **Manual native run** | Development or troubleshooting | The quick-start command runs in your terminal. |
|
||||||
| **Manual native run** | Development, evaluation, or troubleshooting | The quick-start command works, but it is not an unattended service and stops when the process is terminated. |
|
|
||||||
|
|
||||||
A VM or VPS can host either of the first two options; it does not require a separate application architecture. Kubernetes and multiple replicas add little here: **run one application instance per canonical finance directory**.
|
**Run one application instance per canonical finance directory.** React is compiled and embedded in the Go binary: production needs neither Node.js nor a separate frontend service.
|
||||||
|
|
||||||
### Docker Compose (recommended)
|
### Native NixOS service (recommended on NixOS)
|
||||||
|
|
||||||
|
Add this input to your host flake after committing and pushing the deployment files:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
inputs.finance-duck.url = "git+https://git.larsnolden.com/lars/finance-duck.git";
|
||||||
|
```
|
||||||
|
|
||||||
|
Include `finance-duck` in your flake's `outputs` arguments, then add these entries to your existing `nixosSystem.modules` list:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
finance-duck.nixosModules.default
|
||||||
|
{
|
||||||
|
services.finance-duck = {
|
||||||
|
enable = true;
|
||||||
|
publicURL = "http://localhost:8080";
|
||||||
|
# Optional, once you have installed the runtime credential file:
|
||||||
|
# environmentFile = "/var/lib/finance-duck-secrets/environment";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep your existing host modules, including OpenClaw, unchanged. **Do not make this input's `nixpkgs` follow the host's NixOS 24.11 input:** Finance Duck needs Go 1.24 or newer. The package uses its own pinned toolchain; the service module works on the older host.
|
||||||
|
|
||||||
|
Deploy and inspect:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo nixos-rebuild switch --flake /etc/nixos#nixos
|
||||||
|
systemctl status finance-duck
|
||||||
|
journalctl -u finance-duck -f
|
||||||
|
```
|
||||||
|
|
||||||
|
The service runs as `finance-duck`, listens on **127.0.0.1:8080**, and stores all canonical data, settings, provider state, and the disposable DuckDB cache in **/var/lib/finance-duck**. It creates that directory with mode `0700`; rebuilds do not replace it. No firewall ports, VM, container, reverse proxy, or Tailscale configuration are created.
|
||||||
|
|
||||||
|
For access from another machine, an SSH tunnel is sufficient: `ssh -L 8080:127.0.0.1:8080 your-server`, then open `http://localhost:8080`. For your existing Tailscale setup, instead set `publicURL` to the host's **actual full MagicDNS HTTPS origin**, for example `https://nixos.YOUR-TAILNET.ts.net:8444`, rebuild, and publish the loopback listener:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo tailscale serve --bg --yes --https=8444 http://127.0.0.1:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Choose an unused port; `8444` leaves the existing OpenClaw `8443` endpoint alone. Keep access restricted to trusted tailnet users. This application has no login; do not enable Funnel or expose it publicly. The Enable Banking callback must use that exact HTTPS origin plus `/api/banking/callback`.
|
||||||
|
|
||||||
|
**Optional environment-managed credentials:** both Enable Banking and OpenRouter can be configured directly in **Settings**, without these steps. If you prefer a banking startup fallback, create a private runtime directory and install the key after the first rebuild creates the service account:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo install -d -o root -g finance-duck -m 0750 /var/lib/finance-duck-secrets
|
||||||
|
sudo install -o root -g finance-duck -m 0640 secrets/enablebanking.key \
|
||||||
|
/var/lib/finance-duck-secrets/enablebanking.key
|
||||||
|
sudo touch /var/lib/finance-duck-secrets/environment
|
||||||
|
sudo chmod 0600 /var/lib/finance-duck-secrets/environment
|
||||||
|
sudoedit /var/lib/finance-duck-secrets/environment
|
||||||
|
```
|
||||||
|
|
||||||
|
Put the three `ENABLEBANKING_*` variables from [bank setup](#optional-environment-managed-setup) in that file together, using `ENABLEBANKING_KEY_FILE=/var/lib/finance-duck-secrets/enablebanking.key`. Enable `environmentFile` in your Nix configuration and rebuild. Later environment-file changes require `sudo systemctl restart finance-duck`. An optional `OPENROUTER_API_KEY` provides the equivalent AI startup fallback. For each provider, saved Settings configuration or explicit removal takes precedence over its environment fallback. Never place private keys or credential values in Nix configuration or the Nix store.
|
||||||
|
|
||||||
|
**Existing local data:** this service does not automatically adopt `./finance`. Stop the previous instance, back it up, stop `finance-duck.service`, and copy the entire data directory into `/var/lib/finance-duck`, owned by `finance-duck:finance-duck`. Never run two writers against the same directory.
|
||||||
|
|
||||||
|
**Updates:** update the host's Finance Duck input with `nix flake update finance-duck` in `/etc/nixos`, then run `nixos-rebuild switch` again. Normal NixOS rollback restores the binary, not financial data; keep separate data backups. To build without installing a service, run `nix build` in this repository (the new flake and `nix/` files must be tracked by Git).
|
||||||
|
|
||||||
|
#### Deployed server
|
||||||
|
|
||||||
|
The server at `bender@100.87.224.3` serves Finance Duck at **https://nixos.taile9e6d9.ts.net:8444**, accessible only through Tailscale. Port 443's existing Funnel and OpenClaw on port 8443 are separate and unchanged.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh -t bender@100.87.224.3 'sudo finance-duck-update'
|
||||||
|
# Deploy edits already in the server checkout without pulling:
|
||||||
|
ssh -t bender@100.87.224.3 'sudo finance-duck-update --no-pull'
|
||||||
|
```
|
||||||
|
|
||||||
|
The command pulls `~/projects/finance-duck` with `git pull --ff-only`, snapshots its Git-tracked working files to `/var/lib/finance-duck-source`, updates only the host's `finance-duck` flake input, rebuilds NixOS, and checks backend health. New files must be tracked with Git to enter the snapshot. Ignored data, untracked secrets, and `.git` are excluded; never track credentials. The host's Nixpkgs and NixVirt pins are not updated.
|
||||||
|
|
||||||
|
Host configuration lives in `/etc/nixos/flake.nix` and `/etc/nixos/modules/finance-duck.nix`; the latter defines the service settings, tailnet proxy, and updater. Application data lives separately in `/var/lib/finance-duck`, owned by `finance-duck` with mode `0700`. The initial deployment has no bank/AI credentials and does not import workstation data.
|
||||||
|
|
||||||
|
Application and deployment source are tracked in the project repository. Commit and push changes before running the normal updater; `--no-pull` can deploy tracked local edits. Git refuses an update that would overwrite conflicting local edits. The updater never stashes, resets, commits, or pushes automatically. See `OPERATIONS.txt` for service inspection, backup, and rollback commands.
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
For an initial local deployment without bank credentials:
|
For an initial local deployment without bank credentials:
|
||||||
|
|
||||||
@@ -216,10 +305,10 @@ Configure a systemd service with:
|
|||||||
- An absolute binary path and `-data /var/lib/finance-duck`.
|
- An absolute binary path and `-data /var/lib/finance-duck`.
|
||||||
- `-listen 127.0.0.1:8080` behind your VPN-only reverse proxy.
|
- `-listen 127.0.0.1:8080` behind your VPN-only reverse proxy.
|
||||||
- `-public-url https://finance.example.internal` matching the browser origin.
|
- `-public-url https://finance.example.internal` matching the browser origin.
|
||||||
- Banking environment variables and a private key readable by that service user.
|
- Provider credentials configured through **Settings**, or optional banking environment variables and a private key readable by that service user.
|
||||||
- Automatic startup and a restart policy, such as `Restart=on-failure`.
|
- Automatic startup and a restart policy, such as `Restart=on-failure`.
|
||||||
|
|
||||||
The binary uses CGO/native DuckDB dependencies; it is not a universally portable static Go executable. Build for your target OS/architecture and provide the required native runtime libraries. On NixOS, deploy it with its runtime closure retained—copying a Nix-linked binary alone to another machine, or garbage-collecting its unrooted runtime paths, is not a reliable deployment method. The repository currently provides `shell.nix`, not a production Nix package or service module.
|
The binary uses CGO/native DuckDB dependencies; it is not a universally portable static Go executable. Build for your target OS/architecture and provide the required native runtime libraries. On NixOS, use the native flake package and service module above to retain its runtime closure—copying a Nix-linked binary alone to another machine, or garbage-collecting its unrooted runtime paths, is not a reliable deployment method.
|
||||||
|
|
||||||
### NixOS declarative OCI container
|
### NixOS declarative OCI container
|
||||||
|
|
||||||
@@ -233,7 +322,7 @@ docker build -t finance-duck:local .
|
|||||||
|
|
||||||
Make it available to the daemon used by the NixOS service, or publish it to a private registry and pin a version/digest. A rootless Docker image is not automatically available to the system Docker daemon or Podman.
|
Make it available to the daemon used by the NixOS service, or publish it to a private registry and pin a version/digest. A rootless Docker image is not automatically available to the system Docker daemon or Podman.
|
||||||
|
|
||||||
Keep private keys and API keys in protected runtime files or a secrets manager. **Do not embed secret values in Nix expressions or copy them into the world-readable Nix store.** This is an available deployment approach, not an included ready-to-enable Finance Duck NixOS module.
|
Keep private keys and API keys in protected runtime files or a secrets manager. **Do not embed secret values in Nix expressions or copy them into the world-readable Nix store.** The included NixOS module runs the native package; an OCI deployment requires your own container configuration.
|
||||||
|
|
||||||
### VPN and reverse-proxy requirements
|
### VPN and reverse-proxy requirements
|
||||||
|
|
||||||
@@ -243,7 +332,7 @@ For any deployment:
|
|||||||
- Point your chosen hostname at the VPN-accessible proxy and configure trusted HTTPS there. Finance Duck itself serves HTTP.
|
- Point your chosen hostname at the VPN-accessible proxy and configure trusted HTTPS there. Finance Duck itself serves HTTP.
|
||||||
- Forward to the application and preserve the public `Host` header. The application checks Host and mutation origins; forwarding only `X-Forwarded-Host` is insufficient.
|
- Forward to the application and preserve the public `Host` header. The application checks Host and mutation origins; forwarding only `X-Forwarded-Host` is insufficient.
|
||||||
- For native runs, configure `-public-url`; for the supplied Compose configuration, set `FINANCE_PUBLIC_URL`.
|
- For native runs, configure `-public-url`; for the supplied Compose configuration, set `FINANCE_PUBLIC_URL`.
|
||||||
- Set `ENABLEBANKING_REDIRECT_URL` to that same origin plus `/api/banking/callback`, and register it exactly with Enable Banking.
|
- Register the callback shown in **Settings → Enable Banking credentials**: the browser origin plus `/api/banking/callback`. For optional environment-managed setup, set `ENABLEBANKING_REDIRECT_URL` to that same URL.
|
||||||
- Allow outbound HTTPS to Enable Banking, and optionally OpenRouter. No public inbound bank webhook is required.
|
- Allow outbound HTTPS to Enable Banking, and optionally OpenRouter. No public inbound bank webhook is required.
|
||||||
- Allow sufficiently long proxy requests for sequential bulk AI previews.
|
- Allow sufficiently long proxy requests for sequential bulk AI previews.
|
||||||
|
|
||||||
@@ -251,7 +340,13 @@ A direct bind to a VPN interface is also supported with `-listen <VPN-IP>:8080`
|
|||||||
|
|
||||||
## Optional OpenRouter setup
|
## Optional OpenRouter setup
|
||||||
|
|
||||||
Set `OPENROUTER_API_KEY` in the server environment, or add it to the Compose `.env`, then restart/recreate the service and choose a supported model in **Settings**.
|
Open **Settings → OpenRouter credentials**, paste your API key, and click **Save key**. Then choose an exact OpenRouter `provider/model` identifier under **Classification preferences** and save those preferences. No SSH, Nix configuration changes, or service restart is needed.
|
||||||
|
|
||||||
|
Use **Replace key** to rotate the credential or **Remove key** to disable AI. Changes apply to future classifications immediately and survive restart; an already-running classification keeps the key it started with. “Configured” means a key is present, not that OpenRouter has accepted it. A successful **AI classification → Analyse** request checks the key, model, and private routing together.
|
||||||
|
|
||||||
|
The key is stored separately from preferences and journals in `state/openrouter.json` beneath your data directory (`/var/lib/finance-duck/state/openrouter.json` on the deployed server), as a private `0600` plaintext file. The API never returns it, and the UI clears the password field after saving rather than storing it in browser storage. Treat data-directory backups as containing credentials. Anyone able to access this no-login application can replace or remove the key: keep access restricted to trusted tailnet users.
|
||||||
|
|
||||||
|
For administrator-managed startup configuration, `OPENROUTER_API_KEY` in the server environment or Compose `.env` remains an optional fallback **only while no saved credential file exists**. A UI-saved key takes precedence; **Remove key** persists an explicit disable, so a restart cannot silently restore the environment key. Environment-only changes require a restart/recreate.
|
||||||
|
|
||||||
Bank imports do **not** require this key. Without AI, explicit merchant-default rules still work; unresolved transactions remain unclassified and editable.
|
Bank imports do **not** require this key. Without AI, explicit merchant-default rules still work; unresolved transactions remain unclassified and editable.
|
||||||
|
|
||||||
@@ -259,7 +354,7 @@ Every AI classification requests `provider.data_collection = "deny"`, `provider.
|
|||||||
|
|
||||||
## Data, backups, and recovery
|
## Data, backups, and recovery
|
||||||
|
|
||||||
Back up the **entire canonical finance directory**, including registry files, journals, `config.toml` when present, and operational/recovery state, plus the separately stored secrets. Stop the service for a consistent filesystem backup. DuckDB under `cache/` can be excluded and rebuilt.
|
Back up the **entire canonical finance directory**, including registry files, journals, `config.toml` when present, and operational/recovery state, plus any separately stored environment-managed secrets. `state/openrouter.json` and `state/enablebanking.json` contain UI-managed credentials: protect backups accordingly, including the matching banking session state. Stop the service for a consistent filesystem backup. DuckDB under `cache/` can be excluded and rebuilt.
|
||||||
|
|
||||||
Use **Settings → Rebuild index** while the app runs. For an offline rebuild, stop the service first and run:
|
Use **Settings → Rebuild index** while the app runs. For an offline rebuild, stop the service first and run:
|
||||||
|
|
||||||
|
|||||||
Generated
+27
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1788921488,
|
||||||
|
"narHash": "sha256-8+xWRxEkD6l217cIUdRxfeUGS9lQX0hVtUuNVsBaDzk=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "6aefcda9401be8acc2b74244fb3b37520ea1f0a8",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-26.05",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
description = "Finance Duck package and native NixOS service";
|
||||||
|
|
||||||
|
# Kept independent of the host's Nixpkgs: this application needs Go >= 1.24.
|
||||||
|
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{ self, nixpkgs }:
|
||||||
|
let
|
||||||
|
system = "x86_64-linux";
|
||||||
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
package = pkgs.callPackage ./nix/package.nix { };
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages.${system} = {
|
||||||
|
default = package;
|
||||||
|
finance-duck = package;
|
||||||
|
};
|
||||||
|
nixosModules.default = { lib, pkgs, ... }: {
|
||||||
|
imports = [ ./nix/service.nix ];
|
||||||
|
services.finance-duck.package =
|
||||||
|
lib.mkDefault
|
||||||
|
self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||||
|
};
|
||||||
|
checks.${system}.package = package;
|
||||||
|
formatter.${system} = pkgs.nixfmt;
|
||||||
|
};
|
||||||
|
}
|
||||||
+91
-10
@@ -1,10 +1,12 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -36,6 +38,7 @@ type State struct {
|
|||||||
Settings Settings `json:"settings"`
|
Settings Settings `json:"settings"`
|
||||||
Sessions []banking.Session `json:"sessions"`
|
Sessions []banking.Session `json:"sessions"`
|
||||||
CallbackURL string `json:"callback_url"`
|
CallbackURL string `json:"callback_url"`
|
||||||
|
BankingAppID string `json:"banking_app_id"`
|
||||||
Connections []Connection `json:"connections"`
|
Connections []Connection `json:"connections"`
|
||||||
}
|
}
|
||||||
type operational struct {
|
type operational struct {
|
||||||
@@ -44,6 +47,7 @@ type operational struct {
|
|||||||
SyncError string `json:"sync_error"`
|
SyncError string `json:"sync_error"`
|
||||||
Consents map[string]Consent `json:"consents"`
|
Consents map[string]Consent `json:"consents"`
|
||||||
AccountSync map[string]string `json:"account_sync"`
|
AccountSync map[string]string `json:"account_sync"`
|
||||||
|
BankingScope string `json:"banking_scope"`
|
||||||
}
|
}
|
||||||
type App struct {
|
type App struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -59,6 +63,7 @@ type App struct {
|
|||||||
previews map[string]Preview
|
previews map[string]Preview
|
||||||
authStates map[string]authorization
|
authStates map[string]authorization
|
||||||
callbackURL string
|
callbackURL string
|
||||||
|
bankingSettings bankingSettings
|
||||||
syncRequested chan struct{}
|
syncRequested chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,17 +120,13 @@ func Open(dir string) (*App, error) {
|
|||||||
if a.ops.AccountSync == nil {
|
if a.ops.AccountSync == nil {
|
||||||
a.ops.AccountSync = make(map[string]string)
|
a.ops.AccountSync = make(map[string]string)
|
||||||
}
|
}
|
||||||
a.classifier = classification.Client{APIKey: os.Getenv("OPENROUTER_API_KEY"), Model: a.settings.Model, IncludeAmount: a.settings.IncludeAmount}
|
apiKey, err := loadOpenRouterKey(filepath.Join(dir, "state", "openrouter.json"))
|
||||||
appID, key, redirect := os.Getenv("ENABLEBANKING_APP_ID"), os.Getenv("ENABLEBANKING_KEY_FILE"), os.Getenv("ENABLEBANKING_REDIRECT_URL")
|
|
||||||
a.callbackURL = redirect
|
|
||||||
if appID != "" || key != "" || redirect != "" {
|
|
||||||
if appID == "" || key == "" || redirect == "" {
|
|
||||||
return fail(errors.New("Enable Banking requires APP_ID, KEY_FILE and REDIRECT_URL environment variables"))
|
|
||||||
}
|
|
||||||
a.bank, err = banking.NewEnableBanking(appID, key, redirect)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fail(err)
|
return fail(err)
|
||||||
}
|
}
|
||||||
|
a.classifier = classification.Client{APIKey: apiKey, Model: a.settings.Model, IncludeAmount: a.settings.IncludeAmount}
|
||||||
|
if err = a.loadBankingSettings(); err != nil {
|
||||||
|
return fail(err)
|
||||||
}
|
}
|
||||||
a.index, err = analytics.Open(filepath.Join(dir, "cache", "finance.duckdb"))
|
a.index, err = analytics.Open(filepath.Join(dir, "cache", "finance.duckdb"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -155,7 +156,7 @@ func (a *App) snapshot(ctx context.Context) (State, error) {
|
|||||||
a.indexError = ""
|
a.indexError = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return State{Data: d, Revision: rev, Settings: a.settings, Sessions: copySessions(a.ops.Sessions), CallbackURL: a.callbackURL, Connections: a.connections(d), Status: Status{SyncError: a.ops.SyncError, LastSync: a.ops.LastSync, IndexError: a.indexError, BankingConfigured: a.bank != nil, AIConfigured: a.classifier.APIKey != ""}}, nil
|
return State{Data: d, Revision: rev, Settings: a.settings, Sessions: copySessions(a.ops.Sessions), CallbackURL: a.callbackURL, BankingAppID: a.bankingSettings.AppID, Connections: a.connections(d), Status: Status{SyncError: a.ops.SyncError, LastSync: a.ops.LastSync, IndexError: a.indexError, BankingConfigured: a.bank != nil, AIConfigured: a.classifier.APIKey != ""}}, nil
|
||||||
}
|
}
|
||||||
func (a *App) Snapshot(ctx context.Context) (State, error) {
|
func (a *App) Snapshot(ctx context.Context) (State, error) {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
@@ -237,6 +238,86 @@ func (a *App) saveOps() error {
|
|||||||
}
|
}
|
||||||
return atomicFile(filepath.Join(a.dir, "state", "sync-state.json"), append(b, '\n'))
|
return atomicFile(filepath.Join(a.dir, "state", "sync-state.json"), append(b, '\n'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeOpenRouterKey(key string) (string, error) {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
const invalid = "OpenRouter API key must be at most 4096 bytes and contain only non-whitespace ASCII characters"
|
||||||
|
if len(key) > 4096 {
|
||||||
|
return "", errors.New(invalid)
|
||||||
|
}
|
||||||
|
for i := range len(key) {
|
||||||
|
if key[i] < 0x21 || key[i] > 0x7e {
|
||||||
|
return "", errors.New(invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOpenRouterKey(path string) (string, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return normalizeOpenRouterKey(os.Getenv("OPENROUTER_API_KEY"))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("cannot read saved OpenRouter credential")
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
// Bound encoded storage too, allowing JSON escapes for a maximum-size key.
|
||||||
|
b, err := io.ReadAll(io.LimitReader(f, 32*1024+1))
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("cannot read saved OpenRouter credential")
|
||||||
|
}
|
||||||
|
invalid := errors.New("invalid saved OpenRouter credential")
|
||||||
|
if len(b) > 32*1024 {
|
||||||
|
return "", invalid
|
||||||
|
}
|
||||||
|
// Require exactly one case-sensitive string field, rejecting duplicates,
|
||||||
|
// unknown fields, null, and trailing JSON rather than silently disabling AI.
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(b))
|
||||||
|
if token, err := dec.Token(); err != nil || token != json.Delim('{') {
|
||||||
|
return "", invalid
|
||||||
|
}
|
||||||
|
if token, err := dec.Token(); err != nil || token != "api_key" {
|
||||||
|
return "", invalid
|
||||||
|
}
|
||||||
|
token, err := dec.Token()
|
||||||
|
key, ok := token.(string)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return "", invalid
|
||||||
|
}
|
||||||
|
if token, err := dec.Token(); err != nil || token != json.Delim('}') {
|
||||||
|
return "", invalid
|
||||||
|
}
|
||||||
|
if _, err := dec.Token(); err != io.EOF {
|
||||||
|
return "", invalid
|
||||||
|
}
|
||||||
|
key, err = normalizeOpenRouterKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", invalid
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SaveOpenRouterKey(ctx context.Context, key string) (State, error) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
key, err := normalizeOpenRouterKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return State{}, err
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(struct {
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
}{APIKey: key})
|
||||||
|
if err != nil {
|
||||||
|
return State{}, errors.New("cannot encode OpenRouter credential")
|
||||||
|
}
|
||||||
|
if err := atomicFile(filepath.Join(a.dir, "state", "openrouter.json"), append(b, '\n')); err != nil {
|
||||||
|
return State{}, errors.New("cannot save OpenRouter credential")
|
||||||
|
}
|
||||||
|
a.classifier.APIKey = key
|
||||||
|
return a.snapshot(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) SaveSettings(ctx context.Context, s Settings) (State, error) {
|
func (a *App) SaveSettings(ctx context.Context, s Settings) (State, error) {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
@@ -244,7 +325,7 @@ func (a *App) SaveSettings(ctx context.Context, s Settings) (State, error) {
|
|||||||
if len(s.Model) > 200 {
|
if len(s.Model) > 200 {
|
||||||
return State{}, errors.New("model name is too long")
|
return State{}, errors.New("model name is too long")
|
||||||
}
|
}
|
||||||
b := []byte("# Secrets belong in environment variables, never this file.\nclassification_model = " + strconv.Quote(s.Model) + "\ninclude_amount = " + strconv.FormatBool(s.IncludeAmount) + "\n")
|
b := []byte("# Preferences only. Manage secrets in Settings or environment variables, never this file.\nclassification_model = " + strconv.Quote(s.Model) + "\ninclude_amount = " + strconv.FormatBool(s.IncludeAmount) + "\n")
|
||||||
if err := atomicFile(filepath.Join(a.dir, "config.toml"), b); err != nil {
|
if err := atomicFile(filepath.Join(a.dir, "config.toml"), b); err != nil {
|
||||||
return State{}, err
|
return State{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,9 +86,12 @@ func TestFailedClassificationStillImportsAndRetryIsIdempotent(t *testing.T) {
|
|||||||
t.Fatalf("import not visible in analytics: %+v", dash.Totals)
|
t.Fatalf("import not visible in analytics: %+v", dash.Totals)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func mockClassifier(t *testing.T, a *App) {
|
func mockClassifier(t *testing.T, a *App, inspect ...func(*http.Request)) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
for _, check := range inspect {
|
||||||
|
check(r)
|
||||||
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
Messages []struct {
|
Messages []struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"finance-duck/internal/banking"
|
||||||
|
"finance-duck/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scope binds operational sessions to an application generation. It is committed
|
||||||
|
// with credentials, so a crash before saving sync-state cannot revive old sessions.
|
||||||
|
type bankingSettings struct {
|
||||||
|
AppID string `json:"app_id"`
|
||||||
|
RedirectURL string `json:"redirect_url"`
|
||||||
|
PrivateKey string `json:"private_key"`
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) clearBankingSessions(scope string) {
|
||||||
|
a.ops.Sessions = nil
|
||||||
|
a.ops.Consents = make(map[string]Consent)
|
||||||
|
a.ops.AccountSync = make(map[string]string)
|
||||||
|
a.ops.LastSync = ""
|
||||||
|
a.ops.SyncError = ""
|
||||||
|
a.ops.BankingScope = scope
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) loadBankingSettings() error {
|
||||||
|
path := filepath.Join(a.dir, "state", "enablebanking.json")
|
||||||
|
f, err := os.Open(path)
|
||||||
|
var cfg bankingSettings
|
||||||
|
var provider *banking.EnableBanking
|
||||||
|
fromEnvironment := os.IsNotExist(err)
|
||||||
|
if fromEnvironment {
|
||||||
|
cfg.AppID = os.Getenv("ENABLEBANKING_APP_ID")
|
||||||
|
cfg.RedirectURL = os.Getenv("ENABLEBANKING_REDIRECT_URL")
|
||||||
|
keyFile := os.Getenv("ENABLEBANKING_KEY_FILE")
|
||||||
|
cfg.Disabled = cfg.AppID == "" && cfg.RedirectURL == "" && keyFile == ""
|
||||||
|
if !cfg.Disabled {
|
||||||
|
if cfg.AppID == "" || cfg.RedirectURL == "" || keyFile == "" {
|
||||||
|
return errors.New("Enable Banking requires APP_ID, KEY_FILE and REDIRECT_URL environment variables")
|
||||||
|
}
|
||||||
|
key, e := os.Open(keyFile)
|
||||||
|
if e != nil {
|
||||||
|
return errors.New("cannot read Enable Banking private key")
|
||||||
|
}
|
||||||
|
b, e := io.ReadAll(io.LimitReader(key, banking.MaxPrivateKeyPEM+1))
|
||||||
|
key.Close()
|
||||||
|
if e != nil {
|
||||||
|
return errors.New("cannot read Enable Banking private key")
|
||||||
|
}
|
||||||
|
cfg.PrivateKey = string(b)
|
||||||
|
}
|
||||||
|
// A stable environment identity detects app-ID changes on restart while
|
||||||
|
// retaining sessions through key or callback rotation of the same app.
|
||||||
|
hash := sha256.Sum256([]byte(cfg.AppID))
|
||||||
|
cfg.Scope = "env_" + hex.EncodeToString(hash[:])
|
||||||
|
} else {
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("cannot read saved Enable Banking settings")
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
const limit = 256 * 1024 // Allows JSON escaping of a maximum-size PEM.
|
||||||
|
b, e := io.ReadAll(io.LimitReader(f, limit+1))
|
||||||
|
invalid := errors.New("invalid saved Enable Banking settings")
|
||||||
|
if e != nil || len(b) > limit {
|
||||||
|
return invalid
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(b))
|
||||||
|
if token, e := decoder.Token(); e != nil || token != json.Delim('{') {
|
||||||
|
return invalid
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool, 5)
|
||||||
|
for decoder.More() {
|
||||||
|
token, e := decoder.Token()
|
||||||
|
name, ok := token.(string)
|
||||||
|
if e != nil || !ok || seen[name] {
|
||||||
|
return invalid
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
value, e := decoder.Token()
|
||||||
|
if e != nil {
|
||||||
|
return invalid
|
||||||
|
}
|
||||||
|
if name == "disabled" {
|
||||||
|
cfg.Disabled, ok = value.(bool)
|
||||||
|
} else {
|
||||||
|
var text string
|
||||||
|
text, ok = value.(string)
|
||||||
|
switch name {
|
||||||
|
case "app_id":
|
||||||
|
cfg.AppID = text
|
||||||
|
case "redirect_url":
|
||||||
|
cfg.RedirectURL = text
|
||||||
|
case "private_key":
|
||||||
|
cfg.PrivateKey = text
|
||||||
|
case "scope":
|
||||||
|
cfg.Scope = text
|
||||||
|
default:
|
||||||
|
return invalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return invalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if token, e := decoder.Token(); e != nil || token != json.Delim('}') {
|
||||||
|
return invalid
|
||||||
|
}
|
||||||
|
if _, e := decoder.Token(); e != io.EOF || len(seen) != 5 || cfg.Scope == "" || len(cfg.Scope) > 256 {
|
||||||
|
return invalid
|
||||||
|
}
|
||||||
|
if cfg.Disabled && (cfg.AppID != "" || cfg.RedirectURL != "" || cfg.PrivateKey != "") {
|
||||||
|
return invalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !cfg.Disabled {
|
||||||
|
provider, err = banking.NewEnableBanking(cfg.AppID, []byte(cfg.PrivateKey), cfg.RedirectURL)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("invalid Enable Banking settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a.ops.BankingScope != cfg.Scope {
|
||||||
|
if a.ops.BankingScope == "" && fromEnvironment && !cfg.Disabled {
|
||||||
|
// Legacy sessions predate Settings and belong to the validated env app.
|
||||||
|
a.ops.BankingScope = cfg.Scope
|
||||||
|
} else {
|
||||||
|
a.clearBankingSessions(cfg.Scope)
|
||||||
|
}
|
||||||
|
// Fail closed if legacy binding or mismatch invalidation cannot persist.
|
||||||
|
if err = a.saveOps(); err != nil {
|
||||||
|
return errors.New("cannot bind Enable Banking sessions")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.bankingSettings = cfg
|
||||||
|
a.callbackURL = cfg.RedirectURL
|
||||||
|
if provider != nil {
|
||||||
|
a.bank = provider
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SaveBankingSettings(ctx context.Context, appID string, privateKey *string, redirectURL string) (State, error) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
key := ""
|
||||||
|
if privateKey != nil {
|
||||||
|
key = *privateKey
|
||||||
|
} else if !a.bankingSettings.Disabled && appID == a.bankingSettings.AppID {
|
||||||
|
key = a.bankingSettings.PrivateKey
|
||||||
|
}
|
||||||
|
if key == "" {
|
||||||
|
return State{}, errors.New("an Enable Banking private key is required for this application")
|
||||||
|
}
|
||||||
|
provider, err := banking.NewEnableBanking(appID, []byte(key), redirectURL)
|
||||||
|
if err != nil {
|
||||||
|
return State{}, err
|
||||||
|
}
|
||||||
|
cfg := bankingSettings{AppID: appID, PrivateKey: key, RedirectURL: redirectURL, Scope: a.bankingSettings.Scope}
|
||||||
|
if a.bankingSettings.Disabled || appID != a.bankingSettings.AppID || cfg.Scope == "" {
|
||||||
|
cfg.Scope = domain.NewID("bank")
|
||||||
|
}
|
||||||
|
return a.persistBankingSettings(ctx, cfg, provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) RemoveBankingSettings(ctx context.Context) (State, error) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
return a.persistBankingSettings(ctx, bankingSettings{Disabled: true, Scope: domain.NewID("bank")}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caller holds mu. Only the credential file must commit: an older sync-state
|
||||||
|
// remains unusable because its scope differs. The next operational save or Open
|
||||||
|
// writes the cleared sessions, without a fallible two-file transaction here.
|
||||||
|
func (a *App) persistBankingSettings(ctx context.Context, cfg bankingSettings, provider *banking.EnableBanking) (State, error) {
|
||||||
|
b, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return State{}, errors.New("cannot encode Enable Banking settings")
|
||||||
|
}
|
||||||
|
if err = atomicFile(filepath.Join(a.dir, "state", "enablebanking.json"), append(b, '\n')); err != nil {
|
||||||
|
return State{}, errors.New("cannot save Enable Banking settings")
|
||||||
|
}
|
||||||
|
if cfg.Scope != a.ops.BankingScope {
|
||||||
|
a.clearBankingSessions(cfg.Scope)
|
||||||
|
}
|
||||||
|
a.authStates = make(map[string]authorization)
|
||||||
|
a.bankingSettings = cfg
|
||||||
|
a.callbackURL = cfg.RedirectURL
|
||||||
|
a.bank = nil
|
||||||
|
if provider != nil {
|
||||||
|
a.bank = provider
|
||||||
|
}
|
||||||
|
return a.snapshot(ctx)
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"finance-duck/internal/banking"
|
||||||
|
"finance-duck/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
const bankingCallback = "http://localhost:8080/api/banking/callback"
|
||||||
|
|
||||||
|
func bankingKey(t *testing.T) (*rsa.PrivateKey, string) {
|
||||||
|
t.Helper()
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return key, string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exercise the live provider, verifying the actual signed JWT and callback sent
|
||||||
|
// upstream, rather than inspecting its private fields or merely saved metadata.
|
||||||
|
func bankingAuthorization(t *testing.T, a *App, key *rsa.PrivateKey, appID, redirect string) string {
|
||||||
|
t.Helper()
|
||||||
|
pending := ""
|
||||||
|
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
parts := strings.Split(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "), ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
t.Error("missing signed banking authorization")
|
||||||
|
w.WriteHeader(401)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||||
|
hash := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
||||||
|
if err != nil || rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, hash[:], sig) != nil {
|
||||||
|
t.Error("live provider signed with the wrong key")
|
||||||
|
w.WriteHeader(401)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
headerBytes, _ := base64.RawURLEncoding.DecodeString(parts[0])
|
||||||
|
var header map[string]string
|
||||||
|
if json.Unmarshal(headerBytes, &header) != nil || header["kid"] != appID {
|
||||||
|
t.Error("live provider signed for the wrong application")
|
||||||
|
}
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/aspsps":
|
||||||
|
fmt.Fprint(w, `{"aspsps":[{"name":"N26","country":"DE","maximum_consent_validity":3600}]}`)
|
||||||
|
case "/auth":
|
||||||
|
var req struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
Redirect string `json:"redirect_url"`
|
||||||
|
}
|
||||||
|
if json.NewDecoder(r.Body).Decode(&req) != nil || req.Redirect != redirect {
|
||||||
|
t.Error("wrong callback sent to banking provider")
|
||||||
|
}
|
||||||
|
pending = req.State
|
||||||
|
fmt.Fprint(w, `{"url":"https://enablebanking.com/auth/consent"}`)
|
||||||
|
case "/sessions":
|
||||||
|
fmt.Fprint(w, `{"session_id":"session-one","access":{"valid_until":"2099-01-01T00:00:00Z"},"aspsp":{"name":"N26","country":"DE"},"accounts":[{"uid":"uid-one","identification_hash":"stable-one","account_id":{"iban":"DE02120300000000202051"},"details":"Bank account","currency":"EUR"}]}`)
|
||||||
|
case "/accounts/uid-one/balances":
|
||||||
|
fmt.Fprint(w, `{"balances":[{"balance_amount":{"currency":"EUR","amount":"12.50"},"balance_type":"CLBD"}]}`)
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected banking request: %s", r.URL.Path)
|
||||||
|
w.WriteHeader(500)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(mock.Close)
|
||||||
|
provider, ok := a.bank.(*banking.EnableBanking)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("banking provider unavailable")
|
||||||
|
}
|
||||||
|
provider.BaseURL = mock.URL
|
||||||
|
provider.HTTPClient = mock.Client()
|
||||||
|
if _, err := a.Authorize(context.Background(), "N26", "DE"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
|
||||||
|
func reopenBankingApp(t *testing.T, a *App) *App {
|
||||||
|
t.Helper()
|
||||||
|
dir := a.dir
|
||||||
|
if err := a.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reopened, err := Open(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { reopened.Close() })
|
||||||
|
return reopened
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBankingRuntimeRotationPreservesConsentAndRejectsPendingCallback(t *testing.T) {
|
||||||
|
a, s := testApp(t)
|
||||||
|
s = seed(t, a, s)
|
||||||
|
key, keyPEM := bankingKey(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := a.SaveBankingSettings(ctx, "app-one", &keyPEM, bankingCallback); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pending := bankingAuthorization(t, a, key, "app-one", bankingCallback)
|
||||||
|
if err := a.Callback(ctx, "code", pending); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before, err := a.Snapshot(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
accountID := before.Sessions[0].Accounts[0].ID
|
||||||
|
a.ops.AccountSync[accountID] = "2026-09-01T00:00:00Z"
|
||||||
|
if err := a.saveOps(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pending = bankingAuthorization(t, a, key, "app-one", bankingCallback)
|
||||||
|
rotated, rotatedPEM := bankingKey(t)
|
||||||
|
callback := "https://finance.example/api/banking/callback"
|
||||||
|
after, err := a.SaveBankingSettings(ctx, "app-one", &rotatedPEM, callback)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(before.Sessions, after.Sessions) || !reflect.DeepEqual(before.Data, after.Data) || a.ops.AccountSync[accountID] == "" {
|
||||||
|
t.Fatal("same-application rotation discarded consent, cursor or canonical data")
|
||||||
|
}
|
||||||
|
if err := a.Callback(ctx, "code", pending); err == nil {
|
||||||
|
t.Fatal("rotation accepted a stale pending callback")
|
||||||
|
}
|
||||||
|
bankingAuthorization(t, a, rotated, "app-one", callback)
|
||||||
|
balances, err := a.Balances(ctx, accountID)
|
||||||
|
if err != nil || len(balances) != 1 || balances[0].Amount.String() != "12.50" {
|
||||||
|
t.Fatalf("rotated consent could not fetch balances: %v %v", balances, err)
|
||||||
|
}
|
||||||
|
a = reopenBankingApp(t, a)
|
||||||
|
bankingAuthorization(t, a, rotated, "app-one", callback)
|
||||||
|
if _, err := a.Balances(ctx, accountID); err != nil {
|
||||||
|
t.Fatal("same-app consent did not survive restart", err)
|
||||||
|
}
|
||||||
|
if _, err := a.SaveBankingSettings(ctx, "app-one", nil, bankingCallback); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bankingAuthorization(t, a, rotated, "app-one", bankingCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBankingAppSwitchAndDisableNeverReuseOldSessions(t *testing.T) {
|
||||||
|
for _, remove := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprint("remove=", remove), func(t *testing.T) {
|
||||||
|
a, s := testApp(t)
|
||||||
|
seed(t, a, s)
|
||||||
|
key, keyPEM := bankingKey(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := a.SaveBankingSettings(ctx, "app-one", &keyPEM, bankingCallback); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pending := bankingAuthorization(t, a, key, "app-one", bankingCallback)
|
||||||
|
if err := a.Callback(ctx, "code", pending); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before, _ := a.Snapshot(ctx)
|
||||||
|
accountID := before.Sessions[0].Accounts[0].ID
|
||||||
|
a.ops.AccountSync[accountID] = "2026-09-01T00:00:00Z"
|
||||||
|
if err := a.saveOps(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pending = bankingAuthorization(t, a, key, "app-one", bankingCallback)
|
||||||
|
var err error
|
||||||
|
if remove {
|
||||||
|
_, err = a.RemoveBankingSettings(ctx)
|
||||||
|
} else {
|
||||||
|
_, err = a.SaveBankingSettings(ctx, "app-two", &keyPEM, bankingCallback)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for restart := range 2 {
|
||||||
|
if restart != 0 {
|
||||||
|
// The credential save deliberately left old sync-state on disk.
|
||||||
|
a = reopenBankingApp(t, a)
|
||||||
|
}
|
||||||
|
if !remove {
|
||||||
|
bankingAuthorization(t, a, key, "app-two", bankingCallback)
|
||||||
|
}
|
||||||
|
if err := a.Callback(ctx, "code", pending); err == nil {
|
||||||
|
t.Fatal("old pending authorization accepted after app change")
|
||||||
|
}
|
||||||
|
if _, err := a.Balances(ctx, accountID); err == nil {
|
||||||
|
t.Fatal("old account UID used with changed credentials")
|
||||||
|
}
|
||||||
|
after, err := a.Snapshot(ctx)
|
||||||
|
if err != nil || len(after.Sessions) != 0 || len(a.ops.AccountSync) != 0 || !reflect.DeepEqual(before.Data, after.Data) {
|
||||||
|
t.Fatal("app change retained session/cursor or changed canonical data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBankingSavedCredentialsAndDisableOverrideEnvironment(t *testing.T) {
|
||||||
|
a, _ := testApp(t)
|
||||||
|
envKey, envPEM := bankingKey(t)
|
||||||
|
keyFile := filepath.Join(t.TempDir(), "env.pem")
|
||||||
|
if err := os.WriteFile(keyFile, []byte(envPEM), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("ENABLEBANKING_APP_ID", "environment-app")
|
||||||
|
t.Setenv("ENABLEBANKING_KEY_FILE", keyFile)
|
||||||
|
t.Setenv("ENABLEBANKING_REDIRECT_URL", bankingCallback)
|
||||||
|
a = reopenBankingApp(t, a)
|
||||||
|
bankingAuthorization(t, a, envKey, "environment-app", bankingCallback)
|
||||||
|
key, keyPEM := bankingKey(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
state, err := a.SaveBankingSettings(ctx, "saved-app", &keyPEM, bankingCallback)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(state)
|
||||||
|
if err != nil || strings.Contains(string(encoded), "PRIVATE KEY") || strings.Contains(string(encoded), "private_key") {
|
||||||
|
t.Fatal("private key exposed in State")
|
||||||
|
}
|
||||||
|
info, err := os.Stat(filepath.Join(a.dir, "state", "enablebanking.json"))
|
||||||
|
if err != nil || info.Mode().Perm() != 0600 {
|
||||||
|
t.Fatal("saved credential is not private")
|
||||||
|
}
|
||||||
|
// Saved settings must not even read a now-unavailable environment key.
|
||||||
|
t.Setenv("ENABLEBANKING_KEY_FILE", filepath.Join(t.TempDir(), "missing.pem"))
|
||||||
|
a = reopenBankingApp(t, a)
|
||||||
|
bankingAuthorization(t, a, key, "saved-app", bankingCallback)
|
||||||
|
if _, err := a.RemoveBankingSettings(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a = reopenBankingApp(t, a)
|
||||||
|
if _, err := a.Authorize(ctx, "N26", "DE"); err == nil {
|
||||||
|
t.Fatal("disabled saved configuration fell back to environment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBankingRejectedSettingsAndFailedWritePreserveActiveProvider(t *testing.T) {
|
||||||
|
a, _ := testApp(t)
|
||||||
|
key, keyPEM := bankingKey(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := a.SaveBankingSettings(ctx, "active-app", &keyPEM, bankingCallback); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bad := "secret-invalid-private-key"
|
||||||
|
for _, input := range []struct {
|
||||||
|
app string
|
||||||
|
key *string
|
||||||
|
callback string
|
||||||
|
}{
|
||||||
|
{"active-app", &bad, bankingCallback},
|
||||||
|
{"new-app", nil, bankingCallback},
|
||||||
|
{"", &keyPEM, bankingCallback},
|
||||||
|
{"secret invalid app", &keyPEM, bankingCallback},
|
||||||
|
{"active-app", &keyPEM, "https://secret.example/wrong"},
|
||||||
|
} {
|
||||||
|
if _, err := a.SaveBankingSettings(ctx, input.app, input.key, input.callback); err == nil || strings.Contains(err.Error(), "secret") {
|
||||||
|
t.Fatal("invalid configuration accepted or leaked in error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending := bankingAuthorization(t, a, key, "active-app", bankingCallback)
|
||||||
|
path := filepath.Join(a.dir, "state", "enablebanking.json")
|
||||||
|
if err := os.Rename(path, path+".backup"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(path, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := a.SaveBankingSettings(ctx, "new-app", &keyPEM, bankingCallback); err == nil {
|
||||||
|
t.Fatal("failed write reported success")
|
||||||
|
}
|
||||||
|
if _, err := a.RemoveBankingSettings(ctx); err == nil {
|
||||||
|
t.Fatal("failed removal reported success")
|
||||||
|
}
|
||||||
|
if err := a.Callback(ctx, "code", pending); err != nil {
|
||||||
|
t.Fatal("failed credential write invalidated active authorization", err)
|
||||||
|
}
|
||||||
|
bankingAuthorization(t, a, key, "active-app", bankingCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBankingMalformedSavedSettingsFailClosed(t *testing.T) {
|
||||||
|
_, keyPEM := bankingKey(t)
|
||||||
|
for _, malformed := range []string{
|
||||||
|
`{}`, `null`, `{"private_key":"secret-invalid-key"}`, `{"disabled":true,"scope":"bank_test","app_id":"","redirect_url":"","private_key":"","disabled":false}`,
|
||||||
|
} {
|
||||||
|
a, _ := testApp(t)
|
||||||
|
if _, err := a.SaveBankingSettings(context.Background(), "valid-app", &keyPEM, bankingCallback); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dir := a.dir
|
||||||
|
if err := a.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("ENABLEBANKING_APP_ID", "environment-app")
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "state", "enablebanking.json"), []byte(malformed), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reopened, err := Open(dir)
|
||||||
|
if err == nil {
|
||||||
|
reopened.Close()
|
||||||
|
t.Fatal("invalid saved settings were accepted")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "APP_ID") {
|
||||||
|
t.Fatal("saved error leaked input or fell back to environment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBankingLegacyEnvironmentSessionsBindBeforeFirstSave(t *testing.T) {
|
||||||
|
a, s := testApp(t)
|
||||||
|
key, keyPEM := bankingKey(t)
|
||||||
|
keyFile := filepath.Join(t.TempDir(), "env.pem")
|
||||||
|
if err := os.WriteFile(keyFile, []byte(keyPEM), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("ENABLEBANKING_APP_ID", "legacy-app")
|
||||||
|
t.Setenv("ENABLEBANKING_KEY_FILE", keyFile)
|
||||||
|
t.Setenv("ENABLEBANKING_REDIRECT_URL", bankingCallback)
|
||||||
|
account := s.Data.Accounts[0]
|
||||||
|
account.ExternalAccountID = "uid-one"
|
||||||
|
// Simulate sync-state written by the version predating Settings.
|
||||||
|
a.ops.Sessions = []banking.Session{{ID: "legacy-session", ValidUntil: "2099-01-01T00:00:00Z", Accounts: []domain.Account{account}}}
|
||||||
|
a.ops.BankingScope = ""
|
||||||
|
a.ops.AccountSync[account.ID] = "2026-09-01T00:00:00Z"
|
||||||
|
if err := a.saveOps(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a = reopenBankingApp(t, a)
|
||||||
|
ctx := context.Background()
|
||||||
|
before, err := a.Snapshot(ctx)
|
||||||
|
if err != nil || len(before.Sessions) != 1 {
|
||||||
|
t.Fatal("legacy environment consent was not retained")
|
||||||
|
}
|
||||||
|
// Restart again before any UI save, proving the migration itself persisted.
|
||||||
|
a = reopenBankingApp(t, a)
|
||||||
|
after, err := a.SaveBankingSettings(ctx, "legacy-app", nil, bankingCallback)
|
||||||
|
if err != nil || !reflect.DeepEqual(before.Sessions, after.Sessions) || a.ops.AccountSync[account.ID] == "" {
|
||||||
|
t.Fatal("first same-app Settings save discarded legacy consent")
|
||||||
|
}
|
||||||
|
bankingAuthorization(t, a, key, "legacy-app", bankingCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBankingEnvironmentAppChangeInvalidatesBoundSessions(t *testing.T) {
|
||||||
|
a, _ := testApp(t)
|
||||||
|
key, keyPEM := bankingKey(t)
|
||||||
|
keyFile := filepath.Join(t.TempDir(), "env.pem")
|
||||||
|
if err := os.WriteFile(keyFile, []byte(keyPEM), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("ENABLEBANKING_APP_ID", "env-one")
|
||||||
|
t.Setenv("ENABLEBANKING_KEY_FILE", keyFile)
|
||||||
|
t.Setenv("ENABLEBANKING_REDIRECT_URL", bankingCallback)
|
||||||
|
a = reopenBankingApp(t, a)
|
||||||
|
ctx := context.Background()
|
||||||
|
pending := bankingAuthorization(t, a, key, "env-one", bankingCallback)
|
||||||
|
if err := a.Callback(ctx, "code", pending); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before, _ := a.Snapshot(ctx)
|
||||||
|
t.Setenv("ENABLEBANKING_APP_ID", "env-two")
|
||||||
|
a = reopenBankingApp(t, a)
|
||||||
|
bankingAuthorization(t, a, key, "env-two", bankingCallback)
|
||||||
|
if _, err := a.Balances(ctx, before.Sessions[0].Accounts[0].ID); err == nil {
|
||||||
|
t.Fatal("environment app change reused another application's account")
|
||||||
|
}
|
||||||
|
after, err := a.Snapshot(ctx)
|
||||||
|
if err != nil || len(after.Sessions) != 0 || !reflect.DeepEqual(before.Data, after.Data) {
|
||||||
|
t.Fatal("environment change retained sessions or lost canonical data")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -218,7 +218,13 @@ func (a *App) Balances(ctx context.Context, id string) ([]banking.Balance, error
|
|||||||
}
|
}
|
||||||
for _, account := range s.Data.Accounts {
|
for _, account := range s.Data.Accounts {
|
||||||
if account.ID == id && account.ExternalAccountID != "" {
|
if account.ID == id && account.ExternalAccountID != "" {
|
||||||
return a.bank.Balances(ctx, account.ExternalAccountID)
|
for _, session := range a.ops.Sessions {
|
||||||
|
for _, linked := range session.Accounts {
|
||||||
|
if linked.ID == account.ID && linked.ExternalAccountID == account.ExternalAccountID {
|
||||||
|
return a.bank.Balances(ctx, linked.ExternalAccountID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil, errors.New("account is not connected")
|
return nil, errors.New("account is not connected")
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func checkOpenRouterPreview(t *testing.T, a *App, s State, auth <-chan string, key string) {
|
||||||
|
t.Helper()
|
||||||
|
p, err := a.Preview(context.Background(), PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer a.CancelPreview(p.ID)
|
||||||
|
if key == "" {
|
||||||
|
if len(p.Changes) != 0 || len(p.Errors) != 2 {
|
||||||
|
t.Fatal("disabled AI did not leave both transactions unclassified")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(p.Errors) != 0 || len(p.Changes) != 2 {
|
||||||
|
t.Fatalf("classification failed: %+v", p.Errors)
|
||||||
|
}
|
||||||
|
for _, change := range p.Changes {
|
||||||
|
if change.After.CategoryID != "groceries" {
|
||||||
|
t.Fatal("provider classification was not applied to the preview")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case got := <-auth:
|
||||||
|
if got != "Bearer "+key {
|
||||||
|
t.Fatal("provider received the wrong Authorization credential")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatal("classification did not reach the provider")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-auth:
|
||||||
|
t.Fatal("unexpected provider request")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenRouterKeyRotationChangesProviderAuthorization(t *testing.T) {
|
||||||
|
a, s := testApp(t)
|
||||||
|
s = seed(t, a, s)
|
||||||
|
auth := make(chan string, 8)
|
||||||
|
mockClassifier(t, a, func(r *http.Request) { auth <- r.Header.Get("Authorization") })
|
||||||
|
for _, key := range []string{"first-private-key", "replacement-private-key", ""} {
|
||||||
|
var err error
|
||||||
|
s, err = a.SaveOpenRouterKey(context.Background(), " \t"+key+"\r\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Status.AIConfigured != (key != "") {
|
||||||
|
t.Fatal("credential status did not update immediately")
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(encoded), "private-key") {
|
||||||
|
t.Fatal("saved credential leaked into browser state")
|
||||||
|
}
|
||||||
|
checkOpenRouterPreview(t, a, s, auth, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenRouterSavedKeyAndDisableSurviveRestartOverrideEnvironment(t *testing.T) {
|
||||||
|
a, s := testApp(t)
|
||||||
|
s = seed(t, a, s)
|
||||||
|
auth := make(chan string, 8)
|
||||||
|
mockClassifier(t, a, func(r *http.Request) { auth <- r.Header.Get("Authorization") })
|
||||||
|
dir, baseURL := a.dir, a.classifier.BaseURL
|
||||||
|
t.Setenv("OPENROUTER_API_KEY", "environment-private-key")
|
||||||
|
reopen := func() {
|
||||||
|
t.Helper()
|
||||||
|
if err := a.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
a, err = Open(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a.classifier.BaseURL = baseURL
|
||||||
|
s, err = a.Snapshot(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if a != nil {
|
||||||
|
a.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
reopen()
|
||||||
|
checkOpenRouterPreview(t, a, s, auth, "environment-private-key")
|
||||||
|
for _, key := range []string{"saved-private-key", ""} {
|
||||||
|
var err error
|
||||||
|
s, err = a.SaveOpenRouterKey(context.Background(), key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(filepath.Join(dir, "state", "openrouter.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0600 {
|
||||||
|
t.Fatalf("credential permissions: %o, want 600", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
reopen()
|
||||||
|
if s.Status.AIConfigured != (key != "") {
|
||||||
|
t.Fatal("restarted credential status ignored saved preference")
|
||||||
|
}
|
||||||
|
checkOpenRouterPreview(t, a, s, auth, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenRouterMalformedStorageFailsClosedWithoutLeaking(t *testing.T) {
|
||||||
|
t.Setenv("OPENROUTER_API_KEY", "environment-private-key")
|
||||||
|
t.Setenv("ENABLEBANKING_APP_ID", "")
|
||||||
|
t.Setenv("ENABLEBANKING_KEY_FILE", "")
|
||||||
|
t.Setenv("ENABLEBANKING_REDIRECT_URL", "")
|
||||||
|
for name, content := range map[string]string{
|
||||||
|
"missing": `{}`,
|
||||||
|
"null": `{"api_key":null}`,
|
||||||
|
"wrong type": `{"api_key":123}`,
|
||||||
|
"case variant": `{"API_KEY":"saved-private-key"}`,
|
||||||
|
"unknown field": `{"api_key":"saved-private-key","extra":true}`,
|
||||||
|
"duplicate": `{"api_key":"saved-private-key","api_key":""}`,
|
||||||
|
"trailing JSON": `{"api_key":"saved-private-key"} {}`,
|
||||||
|
"malformed": `{"api_key":"saved-private-key`,
|
||||||
|
"control byte": `{"api_key":"saved-private-key\u0000"}`,
|
||||||
|
"oversized": `{"api_key":"` + strings.Repeat("k", 4097) + `"}`,
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.Mkdir(filepath.Join(dir, "state"), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "state", "openrouter.json"), []byte(content), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a, err := Open(dir)
|
||||||
|
if err == nil {
|
||||||
|
a.Close()
|
||||||
|
t.Fatal("malformed credential silently fell back to environment")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "private-key") || strings.Contains(err.Error(), strings.Repeat("k", 20)) {
|
||||||
|
t.Fatal("startup error leaked credential content")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenRouterRejectedKeysPreserveActiveCredential(t *testing.T) {
|
||||||
|
a, s := testApp(t)
|
||||||
|
s = seed(t, a, s)
|
||||||
|
auth := make(chan string, 8)
|
||||||
|
mockClassifier(t, a, func(r *http.Request) { auth <- r.Header.Get("Authorization") })
|
||||||
|
key := strings.Repeat("k", 4096)
|
||||||
|
s, err := a.SaveOpenRouterKey(context.Background(), key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("maximum-size key was rejected")
|
||||||
|
}
|
||||||
|
for name, invalid := range map[string]string{
|
||||||
|
"too long": key + "k",
|
||||||
|
"internal whitespace": "private-key value",
|
||||||
|
"control byte": "private-key\x00",
|
||||||
|
"DEL": "private-key\x7f",
|
||||||
|
"non ASCII": "private-key\u00e9",
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
_, err := a.SaveOpenRouterKey(context.Background(), invalid)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("invalid credential was accepted")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "private-key") || strings.Contains(err.Error(), strings.Repeat("k", 20)) {
|
||||||
|
t.Fatal("validation error leaked credential content")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
checkOpenRouterPreview(t, a, s, auth, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenRouterFailedWritePreservesActiveCredential(t *testing.T) {
|
||||||
|
a, s := testApp(t)
|
||||||
|
s = seed(t, a, s)
|
||||||
|
auth := make(chan string, 8)
|
||||||
|
mockClassifier(t, a, func(r *http.Request) { auth <- r.Header.Get("Authorization") })
|
||||||
|
s, err := a.SaveOpenRouterKey(context.Background(), "active-private-key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(a.dir, "state", "openrouter.json")
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A directory at the destination makes atomic rename fail even as root.
|
||||||
|
if err := os.Mkdir(path, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"replacement-private-key", ""} {
|
||||||
|
_, err := a.SaveOpenRouterKey(context.Background(), key)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("credential save unexpectedly succeeded")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "private-key") {
|
||||||
|
t.Fatal("persistence error leaked credential content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkOpenRouterPreview(t, a, s, auth, "active-private-key")
|
||||||
|
}
|
||||||
@@ -19,7 +19,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -58,21 +57,29 @@ type EnableBanking struct {
|
|||||||
|
|
||||||
var _ Provider = (*EnableBanking)(nil)
|
var _ Provider = (*EnableBanking)(nil)
|
||||||
|
|
||||||
func NewEnableBanking(appID, keyFile, redirectURL string) (*EnableBanking, error) {
|
// MaxPrivateKeyPEM bounds uploaded and environment-loaded private keys.
|
||||||
if strings.TrimSpace(appID) == "" {
|
const MaxPrivateKeyPEM = 32 * 1024
|
||||||
return nil, fmt.Errorf("Enable Banking application ID is required")
|
|
||||||
|
func NewEnableBanking(appID string, keyPEM []byte, redirectURL string) (*EnableBanking, error) {
|
||||||
|
if len(appID) == 0 || len(appID) > 256 {
|
||||||
|
return nil, errors.New("invalid Enable Banking application ID")
|
||||||
|
}
|
||||||
|
for _, c := range appID {
|
||||||
|
if c < 33 || c > 126 {
|
||||||
|
return nil, errors.New("invalid Enable Banking application ID")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
redirect, err := url.Parse(redirectURL)
|
redirect, err := url.Parse(redirectURL)
|
||||||
if err != nil || redirect.Host == "" || (redirect.Scheme != "https" && redirect.Scheme != "http") || redirect.User != nil {
|
if err != nil || redirect.Hostname() == "" || (redirect.Scheme != "https" && redirect.Scheme != "http") || redirect.User != nil || redirect.Opaque != "" || redirect.Path != "/api/banking/callback" || redirect.RawPath != "" || redirect.RawQuery != "" || redirect.ForceQuery || redirect.Fragment != "" || strings.Contains(redirectURL, "#") {
|
||||||
return nil, fmt.Errorf("invalid Enable Banking redirect URL")
|
return nil, errors.New("invalid Enable Banking redirect URL")
|
||||||
}
|
}
|
||||||
content, err := os.ReadFile(keyFile)
|
if len(keyPEM) > MaxPrivateKeyPEM {
|
||||||
if err != nil {
|
return nil, errors.New("Enable Banking private key exceeds size limit")
|
||||||
return nil, fmt.Errorf("read Enable Banking RSA private key: %w", err)
|
|
||||||
}
|
}
|
||||||
block, _ := pem.Decode(content)
|
content := bytes.TrimSpace(keyPEM)
|
||||||
if block == nil {
|
block, rest := pem.Decode(content)
|
||||||
return nil, fmt.Errorf("Enable Banking key must be PEM encoded")
|
if block == nil || !bytes.HasPrefix(content, []byte("-----BEGIN "+block.Type+"-----")) || len(bytes.TrimSpace(rest)) != 0 || len(block.Headers) != 0 {
|
||||||
|
return nil, errors.New("Enable Banking key must be a single PEM private key")
|
||||||
}
|
}
|
||||||
var key *rsa.PrivateKey
|
var key *rsa.PrivateKey
|
||||||
switch block.Type {
|
switch block.Type {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package banking
|
package banking
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto"
|
"crypto"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
@@ -14,8 +15,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -27,11 +26,8 @@ func testProvider(t *testing.T, handler http.HandlerFunc) (*EnableBanking, *rsa.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
path := filepath.Join(t.TempDir(), "private.pem")
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
|
||||||
if err := os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}), 0600); err != nil {
|
p, err := NewEnableBanking("test-app", keyPEM, "http://localhost:8080/api/banking/callback")
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
p, err := NewEnableBanking("test-app", path, "http://localhost:8080/api/banking/callback")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -268,3 +264,60 @@ func TestEnableBankingExpiredConsentRequiresReconnect(t *testing.T) {
|
|||||||
t.Fatalf("expired consent must request reconnection: %v", err)
|
t.Fatalf("expired consent must request reconnection: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEnableBankingValidatesUploadedCredentials(t *testing.T) {
|
||||||
|
_, key := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Error("credential validation must not call provider")
|
||||||
|
})
|
||||||
|
pkcs1 := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
|
||||||
|
der, err := x509.MarshalPKCS8PrivateKey(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pkcs8 := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
|
||||||
|
for _, content := range [][]byte{pkcs1, pkcs8} {
|
||||||
|
p, err := NewEnableBanking("test-app", content, "https://finance.example/api/banking/callback")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token, err := p.jwt()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
r.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
assertJWT(t, r, key)
|
||||||
|
}
|
||||||
|
weak, err := rsa.GenerateKey(rand.Reader, 1024)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for name, content := range map[string][]byte{
|
||||||
|
"invalid": []byte("secret-invalid-key"),
|
||||||
|
"oversized": bytes.Repeat([]byte("k"), MaxPrivateKeyPEM+1),
|
||||||
|
"multiple": append(append([]byte{}, pkcs1...), pkcs8...),
|
||||||
|
"prefix": append([]byte("secret-prefix\n"), pkcs1...),
|
||||||
|
"weak": pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(weak)}),
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
if _, err := NewEnableBanking("test-app", content, "https://finance.example/api/banking/callback"); err == nil || strings.Contains(err.Error(), "secret") {
|
||||||
|
t.Fatal("invalid PEM accepted or leaked")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, appID := range []string{"", "app one", "app\none", "app\u007fone", strings.Repeat("a", 257)} {
|
||||||
|
if _, err := NewEnableBanking(appID, pkcs1, "https://finance.example/api/banking/callback"); err == nil {
|
||||||
|
t.Fatal("invalid app ID accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, redirect := range []string{
|
||||||
|
"https://finance.example/", "https://finance.example/api/banking/callback?secret=value",
|
||||||
|
"https://finance.example/api/banking/callback#", "https://finance.example/api/banking/callback?",
|
||||||
|
"https://user:secret@finance.example/api/banking/callback", "ftp://finance.example/api/banking/callback",
|
||||||
|
"https://finance.example/api/banking/%63allback", "https:///api/banking/callback",
|
||||||
|
} {
|
||||||
|
if _, err := NewEnableBanking("test-app", pkcs1, redirect); err == nil || strings.Contains(err.Error(), "secret") {
|
||||||
|
t.Fatal("invalid callback accepted or leaked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ func New(a *app.App, assets fs.FS, publicURL string) (http.Handler, error) {
|
|||||||
s.mux.HandleFunc("POST /api/rebuild", func(w http.ResponseWriter, r *http.Request) { v, e := a.Rebuild(r.Context()); respond(w, v, e) })
|
s.mux.HandleFunc("POST /api/rebuild", func(w http.ResponseWriter, r *http.Request) { v, e := a.Rebuild(r.Context()); respond(w, v, e) })
|
||||||
s.mux.HandleFunc("POST /api/sync", func(w http.ResponseWriter, r *http.Request) { v, e := a.Sync(r.Context()); respond(w, v, e) })
|
s.mux.HandleFunc("POST /api/sync", func(w http.ResponseWriter, r *http.Request) { v, e := a.Sync(r.Context()); respond(w, v, e) })
|
||||||
s.mux.HandleFunc("POST /api/settings", s.settings)
|
s.mux.HandleFunc("POST /api/settings", s.settings)
|
||||||
|
s.mux.HandleFunc("POST /api/settings/openrouter", s.openRouterKey)
|
||||||
|
s.mux.HandleFunc("POST /api/settings/enablebanking", s.bankingSettings)
|
||||||
s.mux.HandleFunc("POST /api/banking/authorize", s.authorize)
|
s.mux.HandleFunc("POST /api/banking/authorize", s.authorize)
|
||||||
s.mux.HandleFunc("GET /api/banking/callback", s.callback)
|
s.mux.HandleFunc("GET /api/banking/callback", s.callback)
|
||||||
s.mux.HandleFunc("GET /api/balances", func(w http.ResponseWriter, r *http.Request) {
|
s.mux.HandleFunc("GET /api/balances", func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -287,6 +289,61 @@ func (s *Server) settings(w http.ResponseWriter, r *http.Request) {
|
|||||||
v, e := s.app.SaveSettings(r.Context(), b)
|
v, e := s.app.SaveSettings(r.Context(), b)
|
||||||
respond(w, v, e)
|
respond(w, v, e)
|
||||||
}
|
}
|
||||||
|
func (s *Server) openRouterKey(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var b struct {
|
||||||
|
APIKey *string `json:"api_key"`
|
||||||
|
}
|
||||||
|
d := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
||||||
|
d.DisallowUnknownFields()
|
||||||
|
// Decoder errors can quote request values. Never echo credential input.
|
||||||
|
if d.Decode(&b) != nil || d.Decode(&struct{}{}) != io.EOF || b.APIKey == nil {
|
||||||
|
respond(w, nil, errors.New("expected one JSON object with an api_key string"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, e := s.app.SaveOpenRouterKey(r.Context(), *b.APIKey)
|
||||||
|
respond(w, v, e)
|
||||||
|
}
|
||||||
|
func (s *Server) bankingSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var b struct {
|
||||||
|
AppID *string `json:"app_id"`
|
||||||
|
PrivateKey *string `json:"private_key"`
|
||||||
|
RedirectURL *string `json:"redirect_url"`
|
||||||
|
Remove bool `json:"remove"`
|
||||||
|
}
|
||||||
|
d := json.NewDecoder(io.LimitReader(r.Body, 256<<10))
|
||||||
|
d.DisallowUnknownFields()
|
||||||
|
// Parsing failures must not echo uploaded private-key content.
|
||||||
|
if d.Decode(&b) != nil || d.Decode(&struct{}{}) != io.EOF {
|
||||||
|
respond(w, nil, errors.New("invalid Enable Banking configuration request"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if b.Remove {
|
||||||
|
if b.AppID != nil || b.PrivateKey != nil || b.RedirectURL != nil {
|
||||||
|
respond(w, nil, errors.New("remove cannot be combined with banking credentials"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, e := s.app.RemoveBankingSettings(r.Context())
|
||||||
|
respond(w, v, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if b.AppID == nil || b.RedirectURL == nil {
|
||||||
|
respond(w, nil, errors.New("Enable Banking application ID and callback URL are required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheme, host := "http", r.Host
|
||||||
|
if r.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
if s.origin != nil {
|
||||||
|
scheme, host = s.origin.Scheme, s.origin.Host
|
||||||
|
}
|
||||||
|
if *b.RedirectURL != scheme+"://"+host+"/api/banking/callback" {
|
||||||
|
respond(w, nil, errors.New("Enable Banking callback URL must match this application's origin and /api/banking/callback path"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, e := s.app.SaveBankingSettings(r.Context(), *b.AppID, b.PrivateKey, *b.RedirectURL)
|
||||||
|
respond(w, v, e)
|
||||||
|
}
|
||||||
func (s *Server) authorize(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) authorize(w http.ResponseWriter, r *http.Request) {
|
||||||
var b struct {
|
var b struct {
|
||||||
Institution string `json:"institution"`
|
Institution string `json:"institution"`
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -61,3 +65,151 @@ func TestOriginAndHostGuardProtectNoLoginService(t *testing.T) {
|
|||||||
t.Fatalf("UI not served: %d %s", w.Code, b)
|
t.Fatalf("UI not served: %d %s", w.Code, b)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenRouterKeyIsWriteOnlyAndRequiresExplicitRemoval(t *testing.T) {
|
||||||
|
t.Setenv("OPENROUTER_API_KEY", "")
|
||||||
|
t.Setenv("ENABLEBANKING_APP_ID", "")
|
||||||
|
t.Setenv("ENABLEBANKING_KEY_FILE", "")
|
||||||
|
t.Setenv("ENABLEBANKING_REDIRECT_URL", "")
|
||||||
|
a, err := app.Open(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer a.Close()
|
||||||
|
h, err := New(a, fstest.MapFS{}, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const secret = "test-openrouter-private-key"
|
||||||
|
check := func(method, path, body, origin string, want int) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
r := httptest.NewRequest(method, "http://localhost:8080"+path, strings.NewReader(body))
|
||||||
|
r.Header.Set("Content-Type", "application/json")
|
||||||
|
r.Header.Set("Origin", origin)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
if strings.Contains(w.Body.String(), secret) {
|
||||||
|
t.Fatal("credential leaked in HTTP response")
|
||||||
|
}
|
||||||
|
if w.Code != want {
|
||||||
|
t.Fatalf("%s %s: got %d, want %d: %s", method, path, w.Code, want, w.Body.String())
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
configured := func(w *httptest.ResponseRecorder, want bool) {
|
||||||
|
t.Helper()
|
||||||
|
var state app.State
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &state); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if state.Status.AIConfigured != want {
|
||||||
|
t.Fatalf("configured = %t, want %t", state.Status.AIConfigured, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const endpoint = "/api/settings/openrouter"
|
||||||
|
const origin = "http://localhost:8080"
|
||||||
|
keyJSON := `{"api_key":"` + secret + `"}`
|
||||||
|
check("POST", endpoint, keyJSON, "https://attacker.example", http.StatusForbidden)
|
||||||
|
configured(check("GET", "/api/state", "", origin, http.StatusOK), false)
|
||||||
|
configured(check("POST", endpoint, keyJSON, origin, http.StatusOK), true)
|
||||||
|
configured(check("GET", "/api/state", "", origin, http.StatusOK), true)
|
||||||
|
// Ordinary preference updates must not implicitly erase credentials.
|
||||||
|
configured(check("POST", "/api/settings", `{"model":"example/model","include_amount":false}`, origin, http.StatusOK), true)
|
||||||
|
for _, body := range []string{
|
||||||
|
`{}`,
|
||||||
|
`{"api_key":null}`,
|
||||||
|
`{"api_key":["` + secret + `"]}`,
|
||||||
|
`{"` + secret + `":"unexpected field"}`,
|
||||||
|
keyJSON + `{}`,
|
||||||
|
`{"api_key":"` + secret + `\ninvalid"}`,
|
||||||
|
} {
|
||||||
|
check("POST", endpoint, body, origin, http.StatusBadRequest)
|
||||||
|
configured(check("GET", "/api/state", "", origin, http.StatusOK), true)
|
||||||
|
}
|
||||||
|
configured(check("POST", endpoint, `{"api_key":""}`, origin, http.StatusOK), false)
|
||||||
|
configured(check("GET", "/api/state", "", origin, http.StatusOK), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBankingConfigurationProtectsPrivateKeyAndCallbackOrigin(t *testing.T) {
|
||||||
|
t.Setenv("OPENROUTER_API_KEY", "")
|
||||||
|
t.Setenv("ENABLEBANKING_APP_ID", "")
|
||||||
|
t.Setenv("ENABLEBANKING_KEY_FILE", "")
|
||||||
|
t.Setenv("ENABLEBANKING_REDIRECT_URL", "")
|
||||||
|
a, err := app.Open(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer a.Close()
|
||||||
|
const origin = "https://finance.internal:8444"
|
||||||
|
const callback = origin + "/api/banking/callback"
|
||||||
|
const endpoint = "/api/settings/enablebanking"
|
||||||
|
h, err := New(a, fstest.MapFS{}, origin)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
keyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
|
||||||
|
secretLine := strings.Split(keyPEM, "\n")[1]
|
||||||
|
payload := func(v any) string {
|
||||||
|
t.Helper()
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
check := func(method, path, body, requestOrigin string, want int) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
// The reverse-proxy hop is HTTP; public Origin and callback are HTTPS.
|
||||||
|
r := httptest.NewRequest(method, "http://finance.internal:8444"+path, strings.NewReader(body))
|
||||||
|
r.Header.Set("Content-Type", "application/json")
|
||||||
|
r.Header.Set("Origin", requestOrigin)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
if strings.Contains(w.Body.String(), secretLine) || strings.Contains(w.Body.String(), "PRIVATE KEY") {
|
||||||
|
t.Fatal("private key leaked in banking response")
|
||||||
|
}
|
||||||
|
if w.Code != want {
|
||||||
|
t.Fatalf("%s %s: got %d, want %d: %s", method, path, w.Code, want, w.Body.String())
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
configured := func(w *httptest.ResponseRecorder, want bool) {
|
||||||
|
t.Helper()
|
||||||
|
var state app.State
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &state); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if state.Status.BankingConfigured != want {
|
||||||
|
t.Fatalf("banking configured = %t, want %t", state.Status.BankingConfigured, want)
|
||||||
|
}
|
||||||
|
if want && (state.BankingAppID != "bank-app" || state.CallbackURL != callback) {
|
||||||
|
t.Fatal("saved application metadata is not available to the UI")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
save := payload(map[string]any{"app_id": "bank-app", "private_key": keyPEM, "redirect_url": callback})
|
||||||
|
check("POST", endpoint, save, "https://attacker.example", http.StatusForbidden)
|
||||||
|
configured(check("GET", "/api/state", "", origin, http.StatusOK), false)
|
||||||
|
configured(check("POST", endpoint, save, origin, http.StatusOK), true)
|
||||||
|
configured(check("GET", "/api/state", "", origin, http.StatusOK), true)
|
||||||
|
// A callback correction can retain the current signing key.
|
||||||
|
configured(check("POST", endpoint, payload(map[string]any{"app_id": "bank-app", "private_key": nil, "redirect_url": callback}), origin, http.StatusOK), true)
|
||||||
|
for _, body := range []string{
|
||||||
|
`{}`,
|
||||||
|
payload(map[string]any{"app_id": "bank-app", "redirect_url": "https://attacker.example/api/banking/callback"}),
|
||||||
|
payload(map[string]any{"app_id": "bank-app", "redirect_url": "http://finance.internal:8444/api/banking/callback"}),
|
||||||
|
payload(map[string]any{"app_id": "different-app", "private_key": nil, "redirect_url": callback}),
|
||||||
|
payload(map[string]any{"app_id": "", "private_key": "", "redirect_url": callback}),
|
||||||
|
payload(map[string]any{"remove": true, "private_key": keyPEM}),
|
||||||
|
payload(map[string]any{secretLine: "unknown field"}),
|
||||||
|
save + `{}`,
|
||||||
|
} {
|
||||||
|
check("POST", endpoint, body, origin, http.StatusBadRequest)
|
||||||
|
configured(check("GET", "/api/state", "", origin, http.StatusOK), true)
|
||||||
|
}
|
||||||
|
configured(check("POST", endpoint, `{"remove":true}`, origin, http.StatusOK), false)
|
||||||
|
configured(check("GET", "/api/state", "", origin, http.StatusOK), false)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
lib,
|
||||||
|
buildGoModule,
|
||||||
|
buildNpmPackage,
|
||||||
|
nodejs,
|
||||||
|
stdenv,
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
# Explicit allowlist: never include live finance/, secrets/, .env, node_modules,
|
||||||
|
# generated binaries, or a local DuckDB file in either package derivation.
|
||||||
|
root = ../.;
|
||||||
|
source = lib.fileset.toSource {
|
||||||
|
inherit root;
|
||||||
|
fileset = lib.fileset.unions [
|
||||||
|
../go.mod
|
||||||
|
../go.sum
|
||||||
|
../cmd
|
||||||
|
../internal
|
||||||
|
../web/embed.go
|
||||||
|
];
|
||||||
|
};
|
||||||
|
frontend = buildNpmPackage {
|
||||||
|
pname = "finance-duck-frontend";
|
||||||
|
version = "0.1.0";
|
||||||
|
inherit nodejs;
|
||||||
|
src = lib.fileset.toSource {
|
||||||
|
root = ../web;
|
||||||
|
fileset = lib.fileset.unions [
|
||||||
|
../web/src
|
||||||
|
../web/index.html
|
||||||
|
../web/package.json
|
||||||
|
../web/package-lock.json
|
||||||
|
../web/tsconfig.json
|
||||||
|
../web/vite.config.ts
|
||||||
|
];
|
||||||
|
};
|
||||||
|
npmDepsHash = "sha256-Sq4qmgNpg8b3fN8v1QHiISMQt5ZHI6oZ8J0M5svV0Ys=";
|
||||||
|
npmFlags = [ "--ignore-scripts" ];
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
mkdir -p "$out"
|
||||||
|
cp -r dist/. "$out/"
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
in
|
||||||
|
buildGoModule {
|
||||||
|
pname = "finance-duck";
|
||||||
|
version = "0.1.0";
|
||||||
|
src = source;
|
||||||
|
vendorHash = "sha256-ks/X1pmBjX1BTyQBSpvn1EbbUuv8RMi4n7at+o31mnw=";
|
||||||
|
proxyVendor = true;
|
||||||
|
env.CGO_ENABLED = "1";
|
||||||
|
nativeBuildInputs = [ stdenv.cc ];
|
||||||
|
subPackages = [ "cmd/finance-duck" ];
|
||||||
|
postConfigure = ''
|
||||||
|
mkdir -p web/dist
|
||||||
|
cp -r ${frontend}/. web/dist/
|
||||||
|
'';
|
||||||
|
checkPhase = ''
|
||||||
|
runHook preCheck
|
||||||
|
go test ./internal/...
|
||||||
|
runHook postCheck
|
||||||
|
'';
|
||||||
|
passthru = { inherit frontend; };
|
||||||
|
meta = {
|
||||||
|
description = "Private personal finance dashboard with canonical plaintext journals";
|
||||||
|
mainProgram = "finance-duck";
|
||||||
|
platforms = [ "x86_64-linux" ];
|
||||||
|
};
|
||||||
|
}
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
cfg = config.services.finance-duck;
|
||||||
|
address =
|
||||||
|
if lib.hasInfix ":" cfg.listenAddress then "[${cfg.listenAddress}]" else cfg.listenAddress;
|
||||||
|
validOrigin =
|
||||||
|
builtins.match "https?://([A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?|[[][0-9A-Fa-f:]+[]])(:[0-9]+)?" cfg.publicURL
|
||||||
|
!= null;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.finance-duck = {
|
||||||
|
enable = lib.mkEnableOption "Finance Duck, a private financial dashboard without application authentication";
|
||||||
|
package = lib.mkOption {
|
||||||
|
type = lib.types.package;
|
||||||
|
description = "Finance Duck package, including the embedded dashboard.";
|
||||||
|
};
|
||||||
|
publicURL = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
description = "Exact HTTP(S) browser origin, including any non-default port, without a trailing slash or path.";
|
||||||
|
};
|
||||||
|
listenAddress = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "127.0.0.1";
|
||||||
|
description = "HTTP listen address. Non-loopback listeners must be isolated by a firewall or VPN.";
|
||||||
|
};
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 8080;
|
||||||
|
description = "HTTP listen port; the native module does not open the firewall.";
|
||||||
|
};
|
||||||
|
environmentFile = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
description = ''
|
||||||
|
Absolute runtime environment-file path, never a Nix store path.
|
||||||
|
Optional startup fallbacks; both providers can be configured in Settings.
|
||||||
|
When configured, the file must exist. For banking, set ENABLEBANKING_APP_ID,
|
||||||
|
ENABLEBANKING_KEY_FILE and ENABLEBANKING_REDIRECT_URL together.
|
||||||
|
OPENROUTER_API_KEY is the optional AI fallback. For each provider,
|
||||||
|
saved UI configuration or explicit removal overrides its environment.
|
||||||
|
The environment file may be root:root 0600; the referenced private key must
|
||||||
|
be readable by finance-duck (for example root:finance-duck 0640).
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
assertions = [
|
||||||
|
{
|
||||||
|
assertion = validOrigin;
|
||||||
|
message = "services.finance-duck.publicURL must be an exact HTTP(S) origin, without userinfo, path, query or fragment.";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion = cfg.listenAddress != "" && builtins.match "[A-Za-z0-9.:_-]+" cfg.listenAddress != null;
|
||||||
|
message = "services.finance-duck.listenAddress must be a nonempty address without brackets or a port.";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion =
|
||||||
|
cfg.environmentFile == null
|
||||||
|
|| (
|
||||||
|
lib.hasPrefix "/" cfg.environmentFile
|
||||||
|
&& !lib.hasPrefix "/nix/store/" cfg.environmentFile
|
||||||
|
&& builtins.match "/[A-Za-z0-9_./-]+" cfg.environmentFile != null
|
||||||
|
&& !(lib.elem ".." (lib.splitString "/" cfg.environmentFile))
|
||||||
|
);
|
||||||
|
message = "services.finance-duck.environmentFile must be an absolute runtime path outside /nix/store, with no parent-directory traversal.";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
users.groups.finance-duck = { };
|
||||||
|
users.users.finance-duck = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = "finance-duck";
|
||||||
|
home = "/var/lib/finance-duck";
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.services.finance-duck = {
|
||||||
|
description = "Finance Duck private financial dashboard";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
wants = [ "network-online.target" ];
|
||||||
|
after = [ "network-online.target" ];
|
||||||
|
environment.HOME = "/var/lib/finance-duck";
|
||||||
|
serviceConfig = {
|
||||||
|
ExecStart = lib.escapeShellArgs [
|
||||||
|
"${cfg.package}/bin/finance-duck"
|
||||||
|
"-data"
|
||||||
|
"/var/lib/finance-duck"
|
||||||
|
"-listen"
|
||||||
|
"${address}:${toString cfg.port}"
|
||||||
|
"-public-url"
|
||||||
|
cfg.publicURL
|
||||||
|
];
|
||||||
|
User = "finance-duck";
|
||||||
|
Group = "finance-duck";
|
||||||
|
StateDirectory = "finance-duck";
|
||||||
|
StateDirectoryMode = "0700";
|
||||||
|
WorkingDirectory = "/var/lib/finance-duck";
|
||||||
|
UMask = "0077";
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = "5s";
|
||||||
|
TimeoutStopSec = "30s";
|
||||||
|
NoNewPrivileges = true;
|
||||||
|
ProtectSystem = "strict";
|
||||||
|
ProtectHome = true;
|
||||||
|
PrivateTmp = true;
|
||||||
|
PrivateDevices = true;
|
||||||
|
CapabilityBoundingSet = "";
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (cfg.environmentFile != null) {
|
||||||
|
EnvironmentFile = cfg.environmentFile;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -453,7 +453,7 @@ function ConnectForm({
|
|||||||
{copied ? "Copied" : "Copy callback URL"}
|
{copied ? "Copied" : "Copy callback URL"}
|
||||||
</button>
|
</button>
|
||||||
<p>
|
<p>
|
||||||
Set <code>ENABLEBANKING_REDIRECT_URL</code> on your server and
|
Configure your application in <a href="#settings">Settings</a> and
|
||||||
register this exact URL with Enable Banking. It must match exactly,
|
register this exact URL with Enable Banking. It must match exactly,
|
||||||
including scheme, hostname, port and path.
|
including scheme, hostname, port and path.
|
||||||
</p>
|
</p>
|
||||||
@@ -494,8 +494,9 @@ function ConnectForm({
|
|||||||
</Field>
|
</Field>
|
||||||
{!state.status.banking_configured && (
|
{!state.status.banking_configured && (
|
||||||
<p className="muted small">
|
<p className="muted small">
|
||||||
Set the Enable Banking application ID, signing key and callback URL
|
Add your Enable Banking application ID and private key in{" "}
|
||||||
on your server first. Credentials never enter this browser form.
|
<a href="#settings">Settings</a> first, then return here to
|
||||||
|
authorize your bank.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|||||||
+364
-8
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Database,
|
Database,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { State } from "./api";
|
import type { State } from "./api";
|
||||||
|
import { APIError } from "./api";
|
||||||
import { ErrorMessage, Field, Modal } from "./ui";
|
import { ErrorMessage, Field, Modal } from "./ui";
|
||||||
import type { Mutate } from "./ui";
|
import type { Mutate } from "./ui";
|
||||||
export function Settings({ state, mutate }: { state: State; mutate: Mutate }) {
|
export function Settings({ state, mutate }: { state: State; mutate: Mutate }) {
|
||||||
@@ -18,6 +19,86 @@ export function Settings({ state, mutate }: { state: State; mutate: Mutate }) {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [rebuild, setRebuild] = useState(false);
|
const [rebuild, setRebuild] = useState(false);
|
||||||
|
const [apiKey, setApiKey] = useState("");
|
||||||
|
const [keyAction, setKeyAction] = useState<"save" | "remove" | null>(null);
|
||||||
|
const [bankingAppID, setBankingAppID] = useState(state.banking_app_id);
|
||||||
|
const [bankingAction, setBankingAction] = useState<"save" | "remove" | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [removeBanking, setRemoveBanking] = useState(false);
|
||||||
|
const [callbackCopied, setCallbackCopied] = useState(false);
|
||||||
|
const privateKeyInput = useRef<HTMLInputElement>(null);
|
||||||
|
const callbackURL = `${window.location.origin}/api/banking/callback`;
|
||||||
|
const needsPrivateKey =
|
||||||
|
!state.status.banking_configured ||
|
||||||
|
bankingAppID.trim() !== state.banking_app_id;
|
||||||
|
const credentialsBusy = keyAction !== null || bankingAction !== null;
|
||||||
|
useEffect(() => {
|
||||||
|
setBankingAppID(state.banking_app_id);
|
||||||
|
}, [state.banking_app_id]);
|
||||||
|
const updateOpenRouterKey = async (key: string) => {
|
||||||
|
if (busy || credentialsBusy) return;
|
||||||
|
setKeyAction(key ? "save" : "remove");
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await mutate(
|
||||||
|
"/api/settings/openrouter",
|
||||||
|
{ api_key: key },
|
||||||
|
key ? "OpenRouter key saved" : "OpenRouter key removed",
|
||||||
|
);
|
||||||
|
setApiKey("");
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Could not update the OpenRouter key.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setKeyAction(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const saveBankingSettings = async () => {
|
||||||
|
if (busy || credentialsBusy) return;
|
||||||
|
setError("");
|
||||||
|
const appID = bankingAppID.trim();
|
||||||
|
const file = privateKeyInput.current?.files?.[0];
|
||||||
|
if (!appID) {
|
||||||
|
setError("Enter the Enable Banking application ID.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (needsPrivateKey && !file) {
|
||||||
|
setError("Upload a private key for this Enable Banking application.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file && (file.size === 0 || file.size > 32 * 1024)) {
|
||||||
|
setError(
|
||||||
|
"Upload a non-empty PEM private key file no larger than 32 KiB.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBankingAction("save");
|
||||||
|
try {
|
||||||
|
await mutate(
|
||||||
|
"/api/settings/enablebanking",
|
||||||
|
{
|
||||||
|
app_id: appID,
|
||||||
|
redirect_url: callbackURL,
|
||||||
|
private_key: file ? await file.text() : null,
|
||||||
|
},
|
||||||
|
"Enable Banking configuration saved",
|
||||||
|
);
|
||||||
|
setBankingAppID(appID);
|
||||||
|
if (privateKeyInput.current) privateKeyInput.current.value = "";
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof APIError
|
||||||
|
? err.message
|
||||||
|
: "Could not save Enable Banking configuration. Check the application ID, RSA PEM key and callback URL, then try again.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setBankingAction(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="section-heading">
|
<div className="section-heading">
|
||||||
@@ -31,18 +112,223 @@ export function Settings({ state, mutate }: { state: State; mutate: Mutate }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ErrorMessage error={error} />
|
<ErrorMessage error={error} />
|
||||||
|
<section className="panel">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<h3>OpenRouter credentials</h3>
|
||||||
|
<p>Manage the API key used for AI classification.</p>
|
||||||
|
</div>
|
||||||
|
<span className="badge" role="status">
|
||||||
|
{state.status.ai_configured ? (
|
||||||
|
<CheckCircle2 size={14} />
|
||||||
|
) : (
|
||||||
|
<AlertCircle size={14} />
|
||||||
|
)}
|
||||||
|
{state.status.ai_configured ? "Configured" : "Not configured"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const key = apiKey.trim();
|
||||||
|
if (!key) return;
|
||||||
|
await updateOpenRouterKey(key);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="form-body">
|
||||||
|
<Field
|
||||||
|
label="OpenRouter API key"
|
||||||
|
hint={
|
||||||
|
state.status.ai_configured
|
||||||
|
? "The current key is never displayed. Enter a new key to replace it."
|
||||||
|
: "Enter your OpenRouter API key to enable AI classification."
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="openrouter-api-key"
|
||||||
|
autoComplete="new-password"
|
||||||
|
maxLength={4096}
|
||||||
|
spellCheck={false}
|
||||||
|
required
|
||||||
|
value={apiKey}
|
||||||
|
disabled={busy || credentialsBusy}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<p className="muted small">
|
||||||
|
Stored locally on this server, not in browser storage. Changes
|
||||||
|
apply to future classifications without a restart. Configured
|
||||||
|
means a key is present, not that it has been validated.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="form-actions">
|
||||||
|
{state.status.ai_configured && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button secondary"
|
||||||
|
disabled={busy || credentialsBusy}
|
||||||
|
onClick={() => updateOpenRouterKey("")}
|
||||||
|
>
|
||||||
|
{keyAction === "remove" ? "Removing…" : "Remove key"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="button primary"
|
||||||
|
disabled={busy || credentialsBusy || !apiKey.trim()}
|
||||||
|
>
|
||||||
|
<Save size={16} />
|
||||||
|
{keyAction === "save"
|
||||||
|
? "Saving…"
|
||||||
|
: state.status.ai_configured
|
||||||
|
? "Replace key"
|
||||||
|
: "Save key"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section className="panel">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<h3>Enable Banking credentials</h3>
|
||||||
|
<p>Manage the application used to connect your banks.</p>
|
||||||
|
</div>
|
||||||
|
<span className="badge" role="status">
|
||||||
|
{state.status.banking_configured ? (
|
||||||
|
<CheckCircle2 size={14} />
|
||||||
|
) : (
|
||||||
|
<AlertCircle size={14} />
|
||||||
|
)}
|
||||||
|
{state.status.banking_configured ? "Configured" : "Not configured"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await saveBankingSettings();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="form-body">
|
||||||
|
<p>
|
||||||
|
First register your application and its public certificate in the{" "}
|
||||||
|
<a
|
||||||
|
href="https://enablebanking.com/cp/applications"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Enable Banking control panel
|
||||||
|
</a>
|
||||||
|
, using the exact callback URL below. Upload only the matching
|
||||||
|
private key here. After saving, go to{" "}
|
||||||
|
<a href="#accounts">Accounts</a> to authorize your bank.
|
||||||
|
</p>
|
||||||
|
<Field label="Application ID">
|
||||||
|
<input
|
||||||
|
name="enablebanking-app-id"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
maxLength={256}
|
||||||
|
required
|
||||||
|
value={bankingAppID}
|
||||||
|
disabled={busy || credentialsBusy}
|
||||||
|
onChange={(e) => setBankingAppID(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Private key PEM file"
|
||||||
|
hint={
|
||||||
|
needsPrivateKey
|
||||||
|
? "Required for a new application. RSA PKCS#1 or PKCS#8, at least 2048 bits; maximum 32 KiB."
|
||||||
|
: "Optional: leave empty to keep the saved key. RSA PKCS#1 or PKCS#8, at least 2048 bits; maximum 32 KiB."
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={privateKeyInput}
|
||||||
|
type="file"
|
||||||
|
name="enablebanking-private-key"
|
||||||
|
accept=".pem,.key"
|
||||||
|
required={needsPrivateKey}
|
||||||
|
disabled={busy || credentialsBusy}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Callback URL"
|
||||||
|
hint="Register this exact URL in Enable Banking, including scheme, hostname, port and path."
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value={callbackURL}
|
||||||
|
onFocus={(e) => e.target.select()}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button secondary"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(callbackURL);
|
||||||
|
setCallbackCopied(true);
|
||||||
|
} catch {
|
||||||
|
setError(
|
||||||
|
"Clipboard unavailable. Select and copy the callback URL above.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{callbackCopied ? "Copied" : "Copy callback URL"}
|
||||||
|
</button>
|
||||||
|
<p className="muted small">
|
||||||
|
Credentials are stored locally on this server, not in browser
|
||||||
|
storage, and apply without a restart. The saved private key is
|
||||||
|
never displayed. Configured means credentials are present, not
|
||||||
|
that Enable Banking has verified them. Saving does not register or
|
||||||
|
test an application, or authorize a bank.
|
||||||
|
</p>
|
||||||
|
<p className="muted small">
|
||||||
|
Changing the application ID requires reconnecting your banks.
|
||||||
|
Rotating the key or updating the callback for the same application
|
||||||
|
preserves existing local bank sessions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="form-actions">
|
||||||
|
{state.status.banking_configured && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button secondary"
|
||||||
|
disabled={busy || credentialsBusy}
|
||||||
|
onClick={() => {
|
||||||
|
setError("");
|
||||||
|
setRemoveBanking(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove configuration
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="button primary"
|
||||||
|
disabled={busy || credentialsBusy || !bankingAppID.trim()}
|
||||||
|
>
|
||||||
|
<Save size={16} />
|
||||||
|
{bankingAction === "save" ? "Saving…" : "Save configuration"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
<div className="dashboard-grid">
|
<div className="dashboard-grid">
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
<div className="panel-heading">
|
<div className="panel-heading">
|
||||||
<div>
|
<div>
|
||||||
<h3>Classification preferences</h3>
|
<h3>Classification preferences</h3>
|
||||||
<p>Provider credentials are configured on the server.</p>
|
<p>Choose the model and what AI classification shares.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
className="form-body"
|
className="form-body"
|
||||||
onSubmit={async (e) => {
|
onSubmit={async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (busy || credentialsBusy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
@@ -58,7 +344,10 @@ export function Settings({ state, mutate }: { state: State; mutate: Mutate }) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Field label="Default AI model">
|
<Field
|
||||||
|
label="Default AI model"
|
||||||
|
hint="Use the exact OpenRouter provider/model identifier, for example openai/gpt-4o-mini."
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
value={model}
|
value={model}
|
||||||
@@ -77,7 +366,10 @@ export function Settings({ state, mutate }: { state: State; mutate: Mutate }) {
|
|||||||
Disabled by default for privacy. Enabling this shares the amount
|
Disabled by default for privacy. Enabling this shares the amount
|
||||||
with the configured AI provider to help classification.
|
with the configured AI provider to help classification.
|
||||||
</p>
|
</p>
|
||||||
<button className="button primary" disabled={busy}>
|
<button
|
||||||
|
className="button primary"
|
||||||
|
disabled={busy || credentialsBusy}
|
||||||
|
>
|
||||||
<Save size={16} />
|
<Save size={16} />
|
||||||
{busy ? "Saving…" : "Save preferences"}
|
{busy ? "Saving…" : "Save preferences"}
|
||||||
</button>
|
</button>
|
||||||
@@ -165,7 +457,7 @@ export function Settings({ state, mutate }: { state: State; mutate: Mutate }) {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="button secondary"
|
className="button secondary"
|
||||||
disabled={busy}
|
disabled={busy || credentialsBusy}
|
||||||
onClick={() => setRebuild(true)}
|
onClick={() => setRebuild(true)}
|
||||||
>
|
>
|
||||||
<RefreshCw size={16} />
|
<RefreshCw size={16} />
|
||||||
@@ -181,11 +473,74 @@ export function Settings({ state, mutate }: { state: State; mutate: Mutate }) {
|
|||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
{removeBanking && (
|
||||||
|
<Modal
|
||||||
|
title="Remove Enable Banking configuration?"
|
||||||
|
close={() => {
|
||||||
|
if (!busy && !credentialsBusy) setRemoveBanking(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="form-body">
|
||||||
|
<p>
|
||||||
|
This disables Enable Banking and invalidates saved local bank
|
||||||
|
connections and pending authorizations. Your accounts and
|
||||||
|
transaction history are kept. It does not revoke upstream bank
|
||||||
|
consent; manage that separately with your bank.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You will need to configure the application and reconnect to sync
|
||||||
|
again.
|
||||||
|
</p>
|
||||||
|
<ErrorMessage error={error} />
|
||||||
|
</div>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
className="button secondary"
|
||||||
|
disabled={busy || credentialsBusy}
|
||||||
|
onClick={() => setRemoveBanking(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button danger"
|
||||||
|
disabled={busy || credentialsBusy}
|
||||||
|
onClick={async () => {
|
||||||
|
if (busy || credentialsBusy) return;
|
||||||
|
setBankingAction("remove");
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await mutate(
|
||||||
|
"/api/settings/enablebanking",
|
||||||
|
{ remove: true },
|
||||||
|
"Enable Banking configuration removed",
|
||||||
|
);
|
||||||
|
setBankingAppID("");
|
||||||
|
if (privateKeyInput.current)
|
||||||
|
privateKeyInput.current.value = "";
|
||||||
|
setRemoveBanking(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof APIError
|
||||||
|
? err.message
|
||||||
|
: "Could not remove Enable Banking configuration.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setBankingAction(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bankingAction === "remove"
|
||||||
|
? "Removing…"
|
||||||
|
: "Remove configuration"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
{rebuild && (
|
{rebuild && (
|
||||||
<Modal
|
<Modal
|
||||||
title="Rebuild the analytics index?"
|
title="Rebuild the analytics index?"
|
||||||
close={() => {
|
close={() => {
|
||||||
if (!busy) setRebuild(false);
|
if (!busy && !credentialsBusy) setRebuild(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="form-body">
|
<div className="form-body">
|
||||||
@@ -198,15 +553,16 @@ export function Settings({ state, mutate }: { state: State; mutate: Mutate }) {
|
|||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
<button
|
<button
|
||||||
className="button secondary"
|
className="button secondary"
|
||||||
disabled={busy}
|
disabled={busy || credentialsBusy}
|
||||||
onClick={() => setRebuild(false)}
|
onClick={() => setRebuild(false)}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="button primary"
|
className="button primary"
|
||||||
disabled={busy}
|
disabled={busy || credentialsBusy}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
if (busy || credentialsBusy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
|
|||||||
+3
-1
@@ -76,6 +76,7 @@ export interface State {
|
|||||||
data: Dataset;
|
data: Dataset;
|
||||||
revision: string;
|
revision: string;
|
||||||
callback_url: string;
|
callback_url: string;
|
||||||
|
banking_app_id: string;
|
||||||
connections: Connection[];
|
connections: Connection[];
|
||||||
status: {
|
status: {
|
||||||
sync_error: string;
|
sync_error: string;
|
||||||
@@ -192,7 +193,8 @@ export function normalizeState(state: State): State {
|
|||||||
!state.settings ||
|
!state.settings ||
|
||||||
!("sessions" in state) ||
|
!("sessions" in state) ||
|
||||||
!("connections" in state) ||
|
!("connections" in state) ||
|
||||||
typeof state.callback_url !== "string"
|
typeof state.callback_url !== "string" ||
|
||||||
|
typeof state.banking_app_id !== "string"
|
||||||
)
|
)
|
||||||
throw new Error("The server returned an incompatible state response.");
|
throw new Error("The server returned an incompatible state response.");
|
||||||
for (const key of [
|
for (const key of [
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ function App() {
|
|||||||
if (!state) throw new Error("Load the journal before making changes.");
|
if (!state) throw new Error("Load the journal before making changes.");
|
||||||
const revisionless = [
|
const revisionless = [
|
||||||
"/api/settings",
|
"/api/settings",
|
||||||
|
"/api/settings/openrouter",
|
||||||
|
"/api/settings/enablebanking",
|
||||||
"/api/sync",
|
"/api/sync",
|
||||||
"/api/rebuild",
|
"/api/rebuild",
|
||||||
].includes(path);
|
].includes(path);
|
||||||
|
|||||||
Reference in New Issue
Block a user